Browse Source

Release v1.12.3 — 빈 디바이스 DB-init 안정화(fail-soft) + Wi-Fi AP DFS 채널 선택

v1.12.1 전달 이후의 v1.12.2·v1.12.3 변경을 함께 반영합니다.
dpworldapp 공유 DB(device/protocol)·계약·원자적 쓰기 계약은 무변경.

■ v1.12.2 — 신규/빈 디바이스 DB-init 안정화 (fail-soft)
펌웨어 업데이트 직후 동기화 문제로 device_config/protocol_config 가 비어
있을 때, 웹이 그 값을 생성·덮어써 dpworldapp 의 시딩을 선점하는 문제를 차단.
협력사 DB-init fix(2ca0ac1)를 포함하되, 미초기화 시 예외를 던져 fresh device
를 중단시키던 부분을 fail-soft 로 대체(렌더는 적용, DB 쓰기만 skip).

- db_manager: config_initialized(key) 단일 판정 신규
  (존재 AND 비어있지 않은 dict; 빈 {} = 미초기화).
- apply_engine: 미초기화 시 raise 제거 → 렌더/networkctl 은 적용하되 DB 미러
  쓰기만 skip(journal warn). _write_db / _write_db_restore 동일 적용.
  _lkg_bookkeeping: all-absent baseline 의 LKG 승격 방지(fresh device).
- server: 미초기화 응답 통일 — GET 204, POST 409 not_initialized(code 필드).
- netmodel(F3): 비-dict / malformed WIFI_SSID 내성 — crash 대신 안전 degrade.
- migrations: contract_canonical 을 device / protocol 로 분리, 각 키가 시딩될
  때까지 독립 defer(seed-ordering). 기존 통합 플래그 하위호환 유지.
- frontend(F2): device/protocolConfigAbsent 가드 — 로드 시 부재(204)였던
  키를 Save All 로 생성하지 않도록 차단하고 안내 토스트 표시.

■ v1.12.3 — Wi-Fi AP 채널 선택 + 네트워크 즉시적용 검증
- ap_engine: DFS 인지 채널 선택 — STA 가 DFS/no-IR 5GHz(52–64·100–144)에
  있어도 AP 가 맹목 추종하지 않고 비-DFS 채널 선택(2.4GHz STA 는 ch1–13 만
  추종). parse_iw_link_beacon_int 추가 — MCC(AP≠STA 채널) 시 hostapd
  beacon_int 을 STA BSS 와 일치(QCA6490 "STA/AP BI must match").
  AP 상태에 라디오-다운 사유(country_unset / channel_dfs / mcc_bi) 추가.
- ap_renderer: render_hostapd 에 beacon_int 파라미터 추가(기본 100).
- 즉시적용: 기존 systemctl start 방식 유지 — 디바이스 실측으로 재확인,
  관련 내부 재작업 시도는 검토 후 원복(순변화 없음).

■ 기타
- api.js: 저장 실패 시 서버 errors 메시지를 사용자에게 노출.
- 문서/버전: VERSION·constants.js → v1.12.3, RELEASE-NOTES §0,
  DELIVERABLE-MANIFEST, BSP-INTEGRATION 갱신.
- MemoryMax 48M→128M(v1.11.16) 주석 정합화(fw_routes / watchdog / server).
release/v1.12.3
Hoseok(Jose) LEE 1 month ago
parent
commit
901aca6273
  1. 25
      BSP-INTEGRATION.md
  2. 4
      DELIVERABLE-MANIFEST.txt
  3. 35
      RELEASE-NOTES.md
  4. 2
      VERSION
  5. 9
      src/db_manager.py
  6. 8
      src/firmware/fw_routes.py
  7. 56
      src/migrations.py
  8. 82
      src/network/ap_engine.py
  9. 6
      src/network/ap_renderer.py
  10. 31
      src/network/apply_engine.py
  11. 9
      src/network/netmodel.py
  12. 2
      src/network/watchdog.py
  13. 55
      src/server.py
  14. 6
      src/static/js/api.js
  15. 15
      src/static/js/app.js
  16. 2
      src/static/js/constants.js
  17. 6
      src/static/js/state.js

25
BSP-INTEGRATION.md

@ -26,12 +26,11 @@
| `deploy/dpworld-*.service` | `${systemd_system_unitdir}/` (제품이 사용하는 유닛만) |
| `deploy/*.service.d/` | `${systemd_system_unitdir}/<unit>.service.d/` |
| `deploy/dpworld-*.sh` | 각 유닛의 `ExecStart=` 가 가리키는 경로 |
| `deploy/dpworld-network-apply-ondemand.conf` | 해당 유닛이 참조하는 경로 |
| `deploy/nginx.conf` | Optional nginx sample site config. BSP/firmware가 nginx를 별도로 소유하면 생략 가능 |
권장 전체 기능 설치 목록:
- 항상 설치: `src/`, `deploy/web-configurator.service`
- 네트워크 apply 사용 시 (**권장 = 직접 교체**): `deploy/dpworld-network-apply-hardened.sh` 의 내용을 **`/usr/bin/dpworld-network-apply.sh` 원본 이름으로 설치**(펌웨어 base 스크립트 교체)하고 `deploy/dpworld-network-apply-ondemand.conf` 를 함께 설치. firmware-native base 유닛(`dpworld-network-apply.service`/`dpworld-network-seed.service`)이 그 경로를 호출하므로 자동 적용됨. **이 방식에서는 `.service.d` 드롭인을 설치하지 않습니다** (드롭인은 라이브 후적용 fallback 전용 — §7, `docs/firmware-boot-hardening.md`)
- 네트워크 apply 사용 시 (**권장 = 직접 교체**): `deploy/dpworld-network-apply-hardened.sh` 의 내용을 **`/usr/bin/dpworld-network-apply.sh` 원본 이름으로 설치**(펌웨어 base 스크립트 교체). firmware-native base 유닛(`dpworld-network-apply.service`/`dpworld-network-seed.service`)이 그 경로를 호출하므로 자동 적용됨. **이 방식에서는 `20-hardened.conf` ExecStart override 드롭인은 설치하지 않습니다** (`scripts/deploy.ps1` 라이브 후적용 fallback 전용 — §7, `docs/firmware-boot-hardening.md`). 단, 온디맨드 apply 는 웹이 `systemctl start dpworld-network-apply.service` 로 호출하는데, boot-seed `Requires=` 가 seed(boot-only oneshot)를 `--boot` 모드로 재트리거합니다. seed 도 하드닝본이라 드롭인이 없어도 안전 통과하여 `systemctl start` 는 성공하지만(필수 아님), 그 불필요한 `seed --boot` 재실행을 회피하려면 **`10-ondemand.conf` 드롭인 베이킹을 권장**합니다(완화 overlay) — `deploy/dpworld-network-apply-ondemand.conf``/lib/systemd/system/dpworld-network-apply.service.d/10-ondemand.conf` 로 설치(§7, 아래 참고).
- Wi-Fi AP 사용 시: `deploy/dpworld-ap-apply.service`, `deploy/dpworld-ap-seed.service`, `deploy/dpworld-hostapd-ap0.service`, `deploy/dpworld-udhcpd-ap0.service`, `deploy/dpworld-ap-apply.sh`를 함께 설치
- 복구 유닛 사용 시: `deploy/dpworld-net-recover.service` 설치
- `*.sh``0755` 실행 권한으로 `/usr/bin/`에 설치
@ -126,13 +125,21 @@ do_install() {
# 네트워크-적용 하드닝 (권장 = 직접 교체):
# 펌웨어 base 스크립트 /usr/bin/dpworld-network-apply.sh 를 하드닝본으로 교체한다.
# firmware-native 한 dpworld-network-apply.service / dpworld-network-seed.service 가
# 이 경로를 호출하므로 apply·--boot 모두 자동으로 하드닝 동작을 탄다. 드롭인은 설치하지 않는다.
# 이 경로를 호출하므로 apply·--boot 모두 자동으로 하드닝 동작을 탄다.
# ExecStart를 바꾸는 20-hardened.conf override 드롭인은 설치하지 않는다(scripts/deploy.ps1 fallback 전용).
# (파일 소유권은 BSP에서 조정 — firmware recipe의 base 파일을 이 패키지가 덮어쓰도록
# bbappend 또는 recipe 우선순위로 정리. 자세히 docs/firmware-boot-hardening.md)
install -m 0755 ${S}/deploy/dpworld-network-apply-hardened.sh \
${D}${bindir}/dpworld-network-apply.sh
# 온디맨드 apply 드롭인 (권장 bake — OPTIONAL 완화, 필수 아님):
# web-configurator 가 systemctl start dpworld-network-apply.service 로 온디맨드 호출할 때
# boot-seed Requires= 가 seed(boot-only oneshot)를 --boot 로 재트리거한다. seed 도 하드닝본이라
# 이 드롭인이 없어도 안전 통과(systemctl start 성공)한다. 이 드롭인은 Requires= 를 비워 그
# 불필요한 seed --boot 재실행만 회피하는 완화 overlay 이다(결함 fix 아님).
install -d ${D}${systemd_system_unitdir}/dpworld-network-apply.service.d
install -m 0644 ${S}/deploy/dpworld-network-apply-ondemand.conf \
${D}${systemd_system_unitdir}/dpworld-network-apply-ondemand.conf
${D}${systemd_system_unitdir}/dpworld-network-apply.service.d/10-ondemand.conf
# Fallback (라이브 후적용 전용 — 베이킹 시 설치하지 않음):
# 펌웨어 원본을 교체할 수 없는 경우에만, 하드닝본을 원래 -hardened 이름으로 설치하고
@ -181,13 +188,20 @@ curl -fsS http://127.0.0.1/ | head # optional: nginx 경유 사용 시
라이브 재로드 경로가 있어 QCA6490에서 PMU 워치독 재부팅 루프를 유발한 이력이 있습니다.
`deploy/dpworld-network-apply-hardened.sh` 가 이를 제거한 **완전 대체본**입니다.
> ★ **불변식(권장이 아니라 필수)**: canonical `/usr/bin/dpworld-network-apply.sh` 는 **반드시 하드닝본**이어야 합니다(①/② 중 하나로 보장). 웹의 온디맨드 apply(`systemctl start dpworld-network-apply.service`)는 이 경로 + `Requires=seed`(→ seed `--boot`)를 타므로, un-hardened 로 남기면 `modprobe -r wlan` → 부트루프가 재발합니다. 아래 표의 '권장'은 ①vs② **방식 선택**의 권장일 뿐, 하드닝 자체는 선택이 아닙니다(Codex Q1 잔여 리스크).
| | ① 직접 교체 (BSP 베이킹, **권장**) | ② override (라이브 후적용, fallback) |
|---|---|---|
| 대상 | 펌웨어 이미지를 빌드하는 협력사 | 이미 구워진 디바이스(우리 `deploy.ps1`) |
| 방법 | 하드닝본 내용을 **`/usr/bin/dpworld-network-apply.sh` 원본 이름**으로 설치(원본 교체) | 하드닝본을 `…-hardened.sh` 로 두고 `.service.d/20-hardened.conf` 드롭인 2개로 ExecStart 교체 |
| 드롭인 | **설치 안 함** | 설치함 |
| 드롭인 (하드닝) | **없음** (직접 교체는 ExecStart override 드롭인 불필요) | 20-hardened.conf 2개만 설치 |
| 드롭인 (온디맨드) | `10-ondemand.conf` **권장 bake** (완화 overlay·필수 아님 — `systemctl start` 는 없어도 성공, 불필요한 `seed --boot` 재트리거만 회피) | `10-ondemand.conf` **권장 bake** (동일) |
| 효과 | base 유닛이 그대로 하드닝 스크립트를 실행 | base 유닛의 ExecStart가 하드닝본으로 바뀜 |
> **온디맨드 드롭인 `10-ondemand.conf` (권장 bake — OPTIONAL 완화, 필수 아님)**: web-configurator/watchdog 는 온디맨드 네트워크 적용을 `systemctl start dpworld-network-apply.service` 로 호출합니다. 이 base 유닛은 `Requires=dpworld-network-seed.service`(boot-only oneshot)를 걸어 온디맨드 start 시 seed 를 `--boot` 로 재트리거하지만, seed 도 하드닝본이라 드롭인이 없어도 안전 통과합니다(systemctl start 성공, 'Dependency failed' 0). 이 드롭인은 `Requires=` 를 비워 그 불필요한 `seed --boot` 재실행만 회피하는 완화 overlay 입니다. `deploy/dpworld-network-apply-ondemand.conf``/lib/systemd/system/dpworld-network-apply.service.d/10-ondemand.conf` 로 설치(①·② 양쪽 모두 권장).
>
> **참고 (2026-07-01 .56 실측)**: 이 드롭인은 현재 .56 이미지에 미baked. 없어도 `systemctl start dpworld-network-apply.service` 는 성공함(Requires=seed 로 seed --boot 가 재트리거되나 seed 도 하드닝본이라 안전 통과). 다만 매 런타임 apply 마다 seed --boot 가 재실행되는 부수효과가 있어, 이 드롭인(또는 seed RemainAfterExit=yes) baking 이 권장됨.
절차 상세: `docs/firmware-boot-hardening.md` "펌웨어 베이킹 (BSP) — 원본 교체 방법".
### 이 패키지가 소유·제공하는 스크립트 (모두 포함됨)
@ -195,6 +209,7 @@ curl -fsS http://127.0.0.1/ | head # optional: nginx 경유 사용 시
|---|---|---|
| `deploy/dpworld-ap-apply.sh` | `/usr/bin/dpworld-ap-apply.sh` | `dpworld-ap-apply.service`, `dpworld-ap-seed.service` |
| `deploy/dpworld-network-apply-hardened.sh` | `/usr/bin/dpworld-network-apply.sh` (원본 교체) | firmware-native `dpworld-network-apply.service` / `dpworld-network-seed.service` |
| `deploy/dpworld-network-apply-ondemand.conf` | `/lib/systemd/system/dpworld-network-apply.service.d/10-ondemand.conf` (권장 bake) | web-configurator/watchdog 온디맨드 `systemctl start dpworld-network-apply.service` |
> fallback(②) 사용 시에는 `…-hardened.sh` 이름으로 설치하고 `.service.d` 드롭인 2개(`deploy/dpworld-network-apply.service.d/`, `deploy/dpworld-network-seed.service.d/`)를 함께 둡니다.

4
DELIVERABLE-MANIFEST.txt

@ -1,7 +1,7 @@
DP World Smart Solutions — Web Configurator
파트너 전달물 — v1.12.1
파트너 전달물 — v1.12.3 (이전 전달 v1.12.1 이후 v1.12.2·v1.12.3 반영 — 상세: RELEASE-NOTES.md §0)
BSP 이미지 포함용 클린 스냅샷. 커밋 히스토리 없음, 내부 전용 자료 없음.
일자: 2026-06-29
일자: 2026-07-01
================================================================================
이 패키지는 무엇인가

35
RELEASE-NOTES.md

@ -1,13 +1,38 @@
# 릴리스 노트 — Web Configurator **v1.12.1**
# 릴리스 노트 — Web Configurator **v1.12.3**
- **배포일**: 2026-06-29
- **배포일**: 2026-07-01
- **대상**: 디바이스 BSP 이미지 포함용 (DP World Smart Solutions · IoT 디바이스)
- **패키지**: 내부 git 태그 `v1.12.1` (커밋 히스토리가 없는 **클린 스냅샷**)
- **이전 버전**: v1.12.0
- **패키지**: 내부 git 태그 `v1.12.3` (커밋 히스토리가 없는 **클린 스냅샷**)
- **이전 전달**: v1.12.1 — 본 전달은 v1.12.2·v1.12.3 변경을 함께 포함합니다.
---
## 1. 한눈에 보기
## 0. v1.12.1 → v1.12.3 신규/변경 (이번 전달의 핵심)
이번 전달은 v1.12.1 위에 **v1.12.2(신규/빈 디바이스 DB-init 안정화)****v1.12.3(Wi-Fi AP 채널 선택 + 네트워크 즉시적용 실측 검증)** 을 함께 반영한 것입니다.
### v1.12.2 — 신규/빈 디바이스 DB-init 안정화 (fail-soft)
펌웨어 업데이트 직후 동기화 문제로 `device_config`/`protocol_config` 가 비어 있을 때, **웹이 그 값을 생성·덮어쓰지 않도록** 하여 dpworldapp 의 시딩을 선점하지 않습니다. 단일 판정(`config_initialized`)을 아래에 일관 적용합니다.
| 경로 | 미초기화 시 동작 |
|---|---|
| 설정 조회(GET) | 빈 `{}` 를 정상 설정처럼 주지 않고 **204** 반환 |
| 설정 저장(POST) | **409 `not_initialized`** — 빈 폼 저장으로 실제 설정을 덮어쓰는 것 차단 |
| 네트워크 즉시적용 | DB 미러 쓰기는 **skip**(경고 기록)하되 **렌더 파일 적용은 계속**(연결 복구 보존) |
| 마이그레이션·서버 시작 | DB 부재/락에도 **크래시 없이 degraded** 로 계속 |
> ※ 협력사 자체 DB-init fix(`2ca0ac1`)를 **포함·강화**한 것입니다. 특히 미초기화 시 **예외를 던져 fresh device 를 중단시키던 부분**을, 렌더는 적용하고 DB 쓰기만 건너뛰는 **fail-soft** 로 대체했습니다.
### v1.12.3 — Wi-Fi AP 채널 선택 + 네트워크 즉시적용 검증
- **Wi-Fi AP DFS 인지 채널 선택**: STA(내부 클라이언트)가 DFS/NO-IR 5GHz 채널(52–64·100–144)에 있어도 AP 가 그 채널을 맹목 추종하지 않고 **비-DFS 채널을 선택**합니다(2.4GHz STA 는 ch1–13 만 추종). AP 상태에 라디오-다운 사유(`country_unset`/`channel_dfs`/`mcc_bi`)를 추가해 진단을 명확히 했습니다.
- **네트워크 즉시적용**: 기존 방식(`systemctl start dpworld-network-apply.service`)을 **유지**합니다. 디바이스 실측으로 정상 동작을 재확인했고, 관련 내부 재작업 시도는 검토 후 **원복**했습니다(순변화 없음).
- (참고) 즉시적용마다 boot-seed 스크립트가 한 번 더 실행되는 부수효과가 있으나 적용 자체는 성공합니다. 상세/불변식(하드닝본 필수 등)은 `BSP-INTEGRATION.md` 를 참조하세요.
---
## 1. (v1.12.1 참고) 한눈에 보기
아래 1~장은 **v1.12.1 전달 시점**의 내용입니다.
이번 릴리스는 **텔레메트리 Uplink(Ethernet 전환) 기능 추가 + 전체 안정화** 릴리스입니다.
- **신규 — 텔레메트리 Uplink**: dpworldapp 의 클라우드 텔레메트리(프로토콜·업데이트·RTCM 서버) 송신 경로를

2
VERSION

@ -1 +1 @@
v1.12.1
v1.12.3

9
src/db_manager.py

@ -371,6 +371,15 @@ class DBManager:
with self._lock:
return os.path.exists(self._json_path(key))
def config_initialized(self, key):
"""True iff the key exists AND holds a non-empty dict. An empty {} counts as
NOT initialized it is the residual state the seed-ordering bug produces
(web/old-code created an empty/partial row before dpworldapp seeded it).
Single source of truth for 'absent' across migrations / GET 204 / POST 409 /
apply DB-write guard (2026-06-29 strengthened merge)."""
v = self.get_config(key)
return isinstance(v, dict) and bool(v)
# ─── Schema meta (migration flags) ──────────────────────────
def _ensure_schema_meta_table(self) -> None:

8
src/firmware/fw_routes.py

@ -16,8 +16,8 @@ import traceback
_MB = 1024 * 1024
# 1.5 GB ceiling: the full firmware set is ~0.9 GB. The upload is buffered in
# the caller-provided tmp_dir (must be a persistent disk filesystem such as /opt,
# NEVER a RAM-backed tmpfs): the service runs under systemd MemoryMax=48M with
# no swap, and tmpfs pages are unreclaimable shmem charged to the cgroup, so
# NEVER a RAM-backed tmpfs): the service runs under systemd MemoryMax=128M
# (v1.11.16, was 48M) with no swap, and tmpfs pages are unreclaimable shmem charged to the cgroup, so
# buffering a multi-hundred-MB package on /tmp OOM-kills the service mid-upload.
# Disk writes go through reclaimable page cache and do not trigger the OOM killer.
_MAX_UPLOAD = 1536 * _MB
@ -35,7 +35,7 @@ class RouteError(Exception):
class FirmwareRoutes:
def __init__(self, controller, tmp_dir=None):
# B14: fail-closed — a missing tmp_dir must never silently fall through
# to gettempdir()/tmpfs. Under MemoryMax=48M with no swap, buffering a
# to gettempdir()/tmpfs. Under MemoryMax=128M (v1.11.16, was 48M) with no swap, buffering a
# multi-hundred-MB ZIP on tmpfs OOM-kills the service (v1.10.2 incident).
if not tmp_dir:
raise ValueError(
@ -83,7 +83,7 @@ class FirmwareRoutes:
# The full ZIP must be buffered to tmp_dir before stage_zip can open it (a ZIP's
# central directory is at the end, so extraction needs the whole file). tmp_dir
# MUST be a disk-backed filesystem (e.g. /opt), NEVER tmpfs/RAM: the service runs
# under systemd MemoryMax=48M with no swap, and tmpfs pages are unreclaimable
# under systemd MemoryMax=128M (v1.11.16, was 48M) with no swap, and tmpfs pages are unreclaimable
# shmem charged to the cgroup, so buffering a multi-hundred-MB package on /tmp
# OOM-kills the service mid-upload. Disk writes go through reclaimable page cache.
try:

56
src/migrations.py

@ -130,26 +130,19 @@ def migrate_port_types(db) -> bool:
return True
# v2 contract alignment migration
def migrate_contract_canonical(db) -> bool:
"""Rewrite existing DB values to v2 device config-reader contract canonical forms.
Idempotent (gated by schema_meta flag). Rewrites:
- device_config: rs485_parity "no" "none"
- protocol_config: two_byte_order/four_byte_order "littleSwap""little swap",
"bigSwap""big swap"
- protocol_config OPC_UA/MODBUS/CAN arrays: per-entry idt "float64""float"
Returns True if ran, False if already applied.
"""
# v2/v3 contract alignment migration — split into per-key functions (2026-06-29)
def migrate_contract_canonical_device(db) -> bool:
"""device_config rs485_parity 'no''none'. Defers until device_config seeded.
Honors legacy contract_canonical_migrated flag (both halves done) 2026-06-29 split."""
if db.get_schema_meta("contract_canonical_migrated") == "true":
return False
if (_existing_config(db, "device_config") is None or
_existing_config(db, "protocol_config") is None):
if db.get_schema_meta("contract_canonical_device_migrated") == "true":
return False
if _existing_config(db, "device_config") is None:
return False
# device_config: rs485_parity "no" → "none"
def _mut_device(cur):
def _mut(cur):
if not isinstance(cur, dict) or not cur:
return cur
cfg = dict(cur)
@ -157,12 +150,25 @@ def migrate_contract_canonical(db) -> bool:
cfg["rs485_parity"] = "none"
return cfg
db.update_config("device_config", _mut_device)
db.update_config("device_config", _mut)
db.set_schema_meta("contract_canonical_device_migrated", "true")
return True
# protocol_config: byte_order camelCase → space + idt float64 → float
_BYTE_ORDER_MAP = {"littleSwap": "little swap", "bigSwap": "big swap"}
def _mut_protocol(cur):
def migrate_contract_canonical_protocol(db) -> bool:
"""protocol_config byte_order camelCase→space + idt float64→float. Defers until seeded.
Honors legacy contract_canonical_migrated flag 2026-06-29 split."""
if db.get_schema_meta("contract_canonical_migrated") == "true":
return False
if db.get_schema_meta("contract_canonical_protocol_migrated") == "true":
return False
if _existing_config(db, "protocol_config") is None:
return False
def _mut(cur):
if not isinstance(cur, dict) or not cur:
return cur
cfg = dict(cur)
@ -170,7 +176,6 @@ def migrate_contract_canonical(db) -> bool:
v = cfg.get(bo_key)
if v in _BYTE_ORDER_MAP:
cfg[bo_key] = _BYTE_ORDER_MAP[v]
# rewrite idt float64 → float in all register arrays
for arr_key in ("OPC_UA", "MODBUS", "CAN"):
arr = cfg.get(arr_key)
if not isinstance(arr, list):
@ -178,15 +183,13 @@ def migrate_contract_canonical(db) -> bool:
new_arr = []
for entry in arr:
if isinstance(entry, dict) and entry.get("idt") == "float64":
entry = dict(entry)
entry["idt"] = "float"
entry = dict(entry); entry["idt"] = "float"
new_arr.append(entry)
cfg[arr_key] = new_arr
return cfg
db.update_config("protocol_config", _mut_protocol)
db.set_schema_meta("contract_canonical_migrated", "true")
db.update_config("protocol_config", _mut)
db.set_schema_meta("contract_canonical_protocol_migrated", "true")
return True
@ -205,7 +208,8 @@ _MIGRATIONS = (
("log_compress_split", migrate_log_compress_split),
("port_types", migrate_port_types),
("seed_ap_config", migrate_seed_ap_config), # AP: seed
("contract_canonical", migrate_contract_canonical), # v2
("contract_canonical_device", migrate_contract_canonical_device), # v3 split
("contract_canonical_protocol", migrate_contract_canonical_protocol), # v3 split
)

82
src/network/ap_engine.py

@ -36,8 +36,29 @@ def parse_iw_link_channel(out):
return (None, None)
def parse_iw_link_beacon_int(out):
"""`iw dev wlan0 link` 출력의 STA 연결 BSS beacon interval → int(TU) 또는 None.
MCC(AP 채널 STA 채널) hostapd beacon_int 값에 맞춰야 공존 가능
(QCA6490 interface-combination 제약 'STA/AP BI must match')."""
for ln in (out or "").splitlines():
t = ln.strip()
if t.startswith("beacon int:"):
try:
return int(t.split(":", 1)[1].strip().split()[0])
except (ValueError, IndexError):
return None
return None
# country 별 2.4GHz 기본 채널(MCC opt-in 시)
_DEFAULT_2G = 6
_DEFAULT_5G_NONDFS = 36
# DFS / no-IR 5GHz channels (UNII-2A 52-64, UNII-2e 100-144). The QCA6490 AP path has no
# DFS-master, so an AP cannot beacon here; resolve_channel must avoid these even when the
# STA is associated on one (MCC #channels<=2 lets the AP use a different channel).
DFS_5G = frozenset({52, 56, 60, 64, 100, 104, 108, 112,
116, 120, 124, 128, 132, 136, 140, 144})
def _parse_first_int(text):
@ -107,28 +128,40 @@ def _parse_neigh_by_mac(out):
class ApEngine:
def __init__(self, ap_dir, state_path, runner=None,
live_country=None, sta_channel=None, country_pending=None):
live_country=None, sta_channel=None, country_pending=None,
sta_beacon_int=None):
self.ap_dir = ap_dir
self.state_path = state_path
self.runner = runner or _default_runner
self._live_country = live_country or (lambda: "")
self._sta_channel = sta_channel or (lambda: (None, None)) # (channel, hw_mode) or (None,None)
self._sta_beacon_int = sta_beacon_int or (lambda: None) # STA BSS beacon interval (TU) or None
self._country_pending = country_pending or (lambda: False)
self._lock = threading.Lock() # serialize apply(): fixed tmp file + single service start
def resolve_channel(self, intent):
"""§7: 기본 SCC(STA 채널 추종). MCC(2g/5g 명시)는 옵트인."""
"""§7: 기본 SCC(STA 채널 추종) — 단 STA 가 DFS/no-IR 채널이면 추종하지 않는다.
AP DFS-master 미지원이라 DFS 채널엔 beacon 불가; MCC(#channels<=2) 가 확인돼
STA 다른 채널을 써도 되므로 -DFS 채널을 고른다. MCC(2g/5g 명시) 옵트인."""
sta_ch, sta_hw = self._sta_channel()
band = intent.get("ap_band", "auto")
# STA 채널이 AP 로도 유효할 때만 SCC 추종 대상:
# 2.4GHz = ch1..13 (ch14 는 11b-only/no-IR → AP 불가), 5GHz = 비-DFS (DFS-master 부재).
sta2g_usable = sta_ch if (sta_hw == "g" and 1 <= (sta_ch or 0) <= 13) else None
sta5_usable = sta_ch if (sta_hw == "a" and sta_ch not in DFS_5G) else None
if band == "auto":
if sta_ch:
return (sta_ch, sta_hw) # SCC
return (_DEFAULT_2G, "g") # STA 없음 → 2.4G 기본
if sta2g_usable:
return (sta2g_usable, "g") # STA 2.4G(1..13) → SCC
if sta5_usable:
return (sta5_usable, "a") # STA 비-DFS 5G → SCC
if sta_hw == "a" and sta_ch:
return (_DEFAULT_5G_NONDFS, "a") # STA DFS 5G → 비-DFS 5G(MCC)
return (_DEFAULT_2G, "g") # STA 없음/2.4G ch14 → 2.4G 기본(MCC)
# ap_channel 0(DEFAULT)=auto 센티넬 → 'or'가 기본/STA 채널을 채움(의도된 동작)
if band == "2g":
return (intent.get("ap_channel") or _DEFAULT_2G, "g")
sta5 = sta_ch if sta_hw == "a" else None
return (intent.get("ap_channel") or (sta5 or 36), "a")
# 5g: 명시 채널 우선 → 없으면 STA 비-DFS 5G 추종 → 그것도 없으면 기본 36(비-DFS)
return (intent.get("ap_channel") or (sta5_usable or _DEFAULT_5G_NONDFS), "a")
def _persist(self, state, intent):
try:
@ -172,7 +205,12 @@ class ApEngine:
marker = os.path.join(self.ap_dir, "ap-enabled")
if intent["ap_enabled"]:
ch, hw = self.resolve_channel(intent)
self._write("hostapd-ap0.conf", render_hostapd(intent, ch, hw, self._live_country()))
# BI(High/Codex): MCC(AP 채널 ≠ STA 채널)면 hostapd beacon_int 을 STA BSS 와
# 맞춘다(QCA6490 'STA/AP BI must match'). SCC/무-STA → None → 렌더 기본 100.
_sta_ch, _sta_hw = self._sta_channel()
_bi = self._sta_beacon_int() if (_sta_ch and (ch, hw) != (_sta_ch, _sta_hw)) else None
self._write("hostapd-ap0.conf",
render_hostapd(intent, ch, hw, self._live_country(), _bi))
self._write("udhcpd-ap0.conf", render_udhcpd(intent))
# Persist intent BEFORE marker so state reflects intent even if marker write fails.
self._persist("APPLYING", intent)
@ -232,6 +270,9 @@ class ApEngine:
by_mac = _parse_neigh_by_mac(out_n)
for client in client_details:
client["ip"] = by_mac.get(client["mac"])
hostapd_reason = ""
if ap_enabled and not hostapd_running:
hostapd_reason = self._hostapd_down_reason()
return {
"ap_enabled": ap_enabled,
"ap0_up": ap0_up,
@ -239,4 +280,29 @@ class ApEngine:
"clients": clients,
"client_details": client_details,
"country_pending": bool(self._country_pending()),
"hostapd_reason": hostapd_reason,
}
def _hostapd_down_reason(self):
"""E2: ap_enabled 인데 hostapd 가 안 떴을 때 best-effort 사유(웹 진단용).
country 미설정(regdomain 미적용)이면 모든 채널 no-IR; 렌더 채널이 DFS AP 불가."""
lc = str(self._live_country() or "").strip().upper()
if not (len(lc) == 2 and lc.isalpha()):
return "country_unset"
conf_ch = None
try:
conf = os.path.join(self.ap_dir, "hostapd-ap0.conf")
if os.path.exists(conf):
with open(conf, encoding="utf-8") as f:
for line in f:
if line.startswith("channel="):
conf_ch = int((line.split("=", 1)[1].strip() or "0"))
break
except (OSError, ValueError):
conf_ch = None
if conf_ch in DFS_5G:
return "channel_dfs"
sta_ch, _sta_hw = self._sta_channel()
if conf_ch and sta_ch and conf_ch != sta_ch:
return "mcc_bi" # AP 가 STA 와 다른 채널(MCC) — beacon-interval 불일치로 공존 실패 가능
return "hostapd_down"

6
src/network/ap_renderer.py

@ -1,7 +1,9 @@
# src/network/ap_renderer.py
"""intent → hostapd-ap0.conf / udhcpd-ap0.conf 렌더 (spec §6). 채널/country는 ap_engine 산정값 주입."""
def render_hostapd(intent, channel, hw_mode, country):
def render_hostapd(intent, channel, hw_mode, country, beacon_int=100):
# beacon_int: MCC(AP 채널 ≠ STA 채널) 시 STA 의 BSS beacon interval 과 일치시켜야
# 공존 가능(QCA6490 "STA/AP BI must match"). SCC/무-STA 는 기본 100. ap_engine 이 산정.
hidden = 1 if intent.get("ap_hidden") else 0
return (
"interface=ap0\n"
@ -11,7 +13,7 @@ def render_hostapd(intent, channel, hw_mode, country):
f"channel={channel}\n"
f"country_code={country}\n"
"ieee80211d=1\n"
"beacon_int=100\n"
f"beacon_int={int(beacon_int) if beacon_int else 100}\n"
"wmm_enabled=1\n"
"auth_algs=1\n"
"wpa=2\n"

31
src/network/apply_engine.py

@ -129,20 +129,26 @@ class ApplyEngine:
"absent": [k for k in keys if k not in dev]}
def _write_db(self, fields):
# review F1/merge: never CREATE device_config (dpworldapp-owned). When uninitialized
# the network renders + networkctl still apply (connectivity recovery); only the DB
# mirror is skipped, journaled as a warn. No raise (avoids FAILED_CRITICAL escalation).
if not getattr(self.db, "config_initialized", lambda k: True)("device_config"):
self.journal.event("apply", phase="WRITING", action="db_write_skipped_uninitialized",
result="warn", detail={"note": "device_config not initialized; renders applied, DB mirror skipped"})
return
def mut(cur):
if not isinstance(cur, dict) or not cur:
raise RuntimeError("device_config is not initialized")
base = cur
base = cur if isinstance(cur, dict) else {}
out = dict(base); out.update(fields) # partial-merge (§4.1)
return out
self.db.update_config("device_config", mut)
def _write_db_restore(self, present, absent):
"""I1: 롤백 전용 — present 키 복원 + 스냅샷 시점 부재 키 pop."""
"""I1: 롤백 전용 — present 복원 + absent pop. review F1/merge: device_config 미초기화 시
재생성 금지({} 포함) restore_files 네트워크 적용본 유지하므로 dpworldapp 정상 seed."""
if not getattr(self.db, "config_initialized", lambda k: True)("device_config"):
return
def mut(cur):
if not isinstance(cur, dict) or not cur:
raise RuntimeError("device_config is not initialized")
base = cur
base = cur if isinstance(cur, dict) else {}
out = dict(base); out.update(present)
for k in absent:
out.pop(k, None)
@ -477,9 +483,18 @@ class ApplyEngine:
"country_pending": self._cur.get("country_pending", False)}
def _lkg_bookkeeping(self, aid):
"""I4: LKG 마킹/prune 은 부가 북키핑 — 실패해도 COMMITTED 유지, 저널 warn 만."""
"""I4: LKG 마킹/prune 은 부가 북키핑 — 실패해도 COMMITTED 유지, 저널 warn 만.
NEW-5: device_config 미초기화(fresh device, _write_db skip) 시엔 스냅샷 baseline
'all-absent' LKG 승격하면 이후 dpworldapp device_config seed
rollback_to_lkg 네트워크 키를 전부 strip. 따라서 미초기화면 LKG 마킹은 생략한다
(prune 그대로 수행). 미초기화로 LKG 없으면 rollback_to_lkg 안전하게 no-op."""
try:
if getattr(self.db, "config_initialized", lambda k: True)("device_config"):
snapshot.mark_last_known_good(self.backups_dir, aid)
else:
self.journal.event("apply", phase="COMMITTED", action="lkg_skipped_uninitialized",
result="warn", apply_id=aid,
detail={"note": "device_config not initialized; snapshot not promoted to LKG"})
snapshot.prune(self.backups_dir)
except Exception as e: # noqa: BLE001
self.journal.event("apply", phase="COMMITTED", action="lkg_bookkeeping_failed",

9
src/network/netmodel.py

@ -45,11 +45,16 @@ def _norm_security(v):
return "none" if s == "open" else (s or "wpa/wpa2") # legacy 'Open' tolerant read (§1.2)
def intent_from_device(dev):
"""device_config dict → intent (§1.1). 누락 키는 빈 값으로 — KeyError 금지."""
"""device_config dict → intent (§1.1). 누락 키는 빈 값으로 — KeyError 금지.
review F3: -dict device_config / malformed WIFI_SSID 내성 (intent_from_persist I5 형제)
dpworldapp/legacy 비정형 값을 써도 crash 대신 프로파일/all-DHCP 안전 degrade."""
dev = dev if isinstance(dev, dict) else {}
g = dev.get
_ssid_raw = g("WIFI_SSID")
_ssid_raw = _ssid_raw if isinstance(_ssid_raw, list) else []
profiles = [{"ssid": _s(e.get("wifi_ssid")), "password": _s(e.get("wifi_passwd")),
"security": _norm_security(e.get("wifi_security"))}
for e in (g("WIFI_SSID") or [])[:MAX_PROFILES]] # RAW 보존 — gap 은 validator 가 검출
for e in _ssid_raw[:MAX_PROFILES] if isinstance(e, dict)] # RAW 보존 — gap 은 validator 가 검출
wifi_static = _s(g("wifi_static")) == "on"
# I2: _ip_u32==0 (empty/"0.0.0.0") → dhcp; _ip_u32==None (unparseable) → static
# (raw ip preserved so validator rejects it with a clear error)

2
src/network/watchdog.py

@ -343,7 +343,7 @@ class NetworkWatchdog:
def _check_fw_md5(self):
"""§9 펌웨어 drift 감시 — 시작 시 1회: 렌더 계약 검증된 dpworldapp 빌드인지.
I6: 1MB 청크 read MemoryMax=48M 하에서 수십 MB 바이너리 전체 read 금지."""
I6: 1MB 청크 read MemoryMax=128M(v1.11.16, 48M) 하에서 수십 MB 바이너리 전체 read 금지."""
try:
h = hashlib.md5()
with open("/usr/bin/dpworldapp", "rb") as f:

55
src/server.py

@ -113,7 +113,7 @@ _FW = FirmwareRoutes(
db_path=DB_PATH,
),
# v1.10.2: buffer the streamed firmware ZIP on the persistent /opt eMMC partition,
# NOT the default /tmp tmpfs — under MemoryMax=48M with no swap, buffering a
# NOT the default /tmp tmpfs — under MemoryMax=128M (v1.11.16, was 48M) with no swap, buffering a
# multi-hundred-MB package on RAM-backed tmpfs OOM-kills the service mid-upload.
# `or` (not the 2-arg default): an empty FW_UPLOAD_TMP must still resolve to /opt,
# never fall through FirmwareRoutes' `tmp_dir or gettempdir()` back to /tmp.
@ -194,7 +194,7 @@ except Exception as _e: # noqa: BLE001 — server must never fail to import du
_AP = None
_AP_INIT_ERROR = None
try:
from network.ap_engine import ApEngine, parse_iw_link_channel
from network.ap_engine import ApEngine, parse_iw_link_channel, parse_iw_link_beacon_int
from network.ap_routes import ApRoutes, RouteError as ApRouteError
_AP_ENGINE = ApEngine(
ap_dir=os.environ.get("AP_DIR", "/home/root/network/ap"),
@ -202,6 +202,7 @@ try:
runner=_net_run,
live_country=(lambda: _NET_ENGINE.live_country()) if _NET_ENGINE else (lambda: ""),
sta_channel=lambda: parse_iw_link_channel(_net_run(["iw", "dev", "wlan0", "link"], 5)[1]),
sta_beacon_int=lambda: parse_iw_link_beacon_int(_net_run(["iw", "dev", "wlan0", "link"], 5)[1]),
country_pending=(lambda: _NET_ENGINE.country_pending()) if _NET_ENGINE else (lambda: False))
_AP = ApRoutes(engine=_AP_ENGINE, db=db)
except Exception as _ape:
@ -515,9 +516,13 @@ class ConfigHandler(BaseHTTPRequestHandler):
Log-management settings are stored separately in `log_config` (out of
reach of the legacy Java app); they are merged into the response so the
client still sees one combined device config.
review F1/merge: empty {} also triggers 204 so the frontend F2 guard
fires for the seed-ordering window (config_initialized = present AND
non-empty dict).
"""
try:
if not db.config_exists("device_config"):
if not db.config_initialized("device_config"):
self._send_no_content()
return
config = db.get_config("device_config")
@ -531,9 +536,12 @@ class ConfigHandler(BaseHTTPRequestHandler):
self._send_error_response("Failed to read device_config")
def _handle_get_protocol(self):
"""GET /setting/get-protocol — Retrieve protocol configuration."""
"""GET /setting/get-protocol — Retrieve protocol configuration.
review F1/merge: empty {} also triggers 204 (config_initialized guard).
"""
try:
if not db.config_exists("protocol_config"):
if not db.config_initialized("protocol_config"):
self._send_no_content()
return
config = db.get_config("protocol_config")
@ -820,16 +828,16 @@ class ConfigHandler(BaseHTTPRequestHandler):
self._send_json_response({"ok": False, "errors": ["request body must be a JSON object"]}, 400)
return
# v1.4.0.3: case-only alias normalization (e.g. "ON"→"on", "WPA/WPA2"→"wpa/wpa2")
# must run before enum validation so aliases pass validation correctly
current_device = db.get_config("device_config")
if not isinstance(current_device, dict) or not current_device:
# review F1/merge: never POST-create device_config (dpworldapp-owned).
# Absent or empty {} = seed-ordering window → 409 so the client retries later.
if not db.config_initialized("device_config"):
self._send_json_response(
{"ok": False, "errors": ["device_config is not initialized"]},
409,
)
{"ok": False, "errors": ["device_config is not initialized"],
"code": "not_initialized"}, 409)
return
# v1.4.0.3: case-only alias normalization (e.g. "ON"→"on", "WPA/WPA2"→"wpa/wpa2")
# must run before enum validation so aliases pass validation correctly
data = normalize_device_input(data)
# v1.4.4: Super_Relay 소유 키 strict reject (silent passthrough 방지)
@ -960,15 +968,14 @@ class ConfigHandler(BaseHTTPRequestHandler):
self._send_json_response({"ok": False, "errors": ["request body must be a JSON object"]}, 400)
return
# v1.4.0.3: case-only alias normalization (e.g. can_input "ON"→"on")
current_protocol = db.get_config("protocol_config")
if not isinstance(current_protocol, dict) or not current_protocol:
# review F1/merge: never POST-create protocol_config (dpworldapp-owned).
if not db.config_initialized("protocol_config"):
self._send_json_response(
{"ok": False, "errors": ["protocol_config is not initialized"]},
409,
)
{"ok": False, "errors": ["protocol_config is not initialized"],
"code": "not_initialized"}, 409)
return
# v1.4.0.3: case-only alias normalization (e.g. can_input "ON"→"on")
data = normalize_protocol_input(data)
# v1.4.4: Super_Relay 소유 키 strict reject (silent passthrough 방지)
@ -1238,11 +1245,19 @@ def main():
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# Ensure DB tables exist (safe for production — uses IF NOT EXISTS)
# Ensure DB tables exist (safe for production — uses IF NOT EXISTS).
# review F4: fail-soft like the migrations block below — a transient DB lock during
# concurrent dpworldapp seeding on a fresh device must defer (retry on next restart),
# not crash-loop the service (CREATE TABLE takes a write lock).
try:
db.ensure_tables()
# Migrate log-management settings into their own key (one-time, idempotent)
migrate_log_config(db)
except sqlite3.OperationalError as exc:
print(f"[startup] WARNING: DB busy during table-init/log-migrate ({exc!r}); "
"will retry on next service restart.", flush=True)
except (sqlite3.DatabaseError, json.JSONDecodeError, ValueError) as exc:
print(f"[startup] WARNING: table-init/log-migrate error (skipped, will retry): {exc!r}", flush=True)
# Run all pending schema/data migrations (one-time, idempotent)
try:

6
src/static/js/api.js

@ -48,6 +48,9 @@ export async function saveDevice(data) {
if (res.status === 400 && Array.isArray(err.errors) && err.errors.length > 0) {
throw new Error('Invalid value: ' + err.errors.slice(0, 3).join(' · '));
}
if (Array.isArray(err.errors) && err.errors.length) {
throw new Error(err.errors.slice(0, 3).join(' · '));
}
throw new Error(err.message || `Save failed: ${res.status}`);
}
return res.json();
@ -80,6 +83,9 @@ export async function saveProtocol(data) {
if (res.status === 400 && Array.isArray(err.errors) && err.errors.length > 0) {
throw new Error('Invalid value: ' + err.errors.slice(0, 3).join(' · '));
}
if (Array.isArray(err.errors) && err.errors.length) {
throw new Error(err.errors.slice(0, 3).join(' · '));
}
throw new Error(err.message || err.error || `Failed to save protocol config: ${res.status}`);
}
return res.json();

15
src/static/js/app.js

@ -300,6 +300,10 @@ export async function loadAllData() {
state.isDirty = false;
// B6: clear the guard flag — a successful load means Save All is safe.
state.configLoadFailed = false;
// F2: record per-key absence so handleSaveAll can refuse to CREATE dpworldapp-owned
// rows that were absent (204) at load time — dpworldapp must seed them on first boot.
state.deviceConfigAbsent = (device == null);
state.protocolConfigAbsent = (protocol == null);
if (DEBUG) console.log('[App] Data loaded. state.device:', state.device);
if (DEBUG) console.log('[App] Data loaded. state.protocol:', state.protocol);
} catch (e) {
@ -558,6 +562,17 @@ export async function handleSaveAll({ skipConfirm = false } = {}) {
return false;
}
// F2: refuse to CREATE dpworldapp-owned keys that were absent (204) at load time.
// dpworldapp seeds device_config/protocol_config on first boot; if the web POSTs them
// first with empty defaults, dpworldapp sees the key "exists" and skips seeding → null
// fields → error. Operator must wait for the device app to initialize, then reload.
if (state.deviceConfigAbsent || state.protocolConfigAbsent) {
showToast('Device configuration is not yet initialized by the device app — wait until the device is ready, then reload before saving.', 'error');
const btn = document.getElementById('btn-save-all');
if (btn) btn.disabled = true;
return false;
}
// v1.5.0 P4b T3: Show confirm modal listing dirty pages if pageDirty matrix available
if (!skipConfirm) {
const dirtyPages = getDirtyPages();

2
src/static/js/constants.js

@ -9,7 +9,7 @@
* Bump APP_VERSION on every release and add a matching CHANGELOG.md entry.
*/
export const APP_NAME = 'DP World Smart Solutions';
export const APP_VERSION = 'v1.12.1';
export const APP_VERSION = 'v1.12.3';
/**
* v1.7.0 pageId 표시명 (Pending Changes 패널·배지에서 사용).

6
src/static/js/state.js

@ -19,6 +19,12 @@ export const state = {
/** B6: set true when loadAllData catches an error; cleared on successful load.
* handleSaveAll refuses to run when true to prevent empty-defaults from wiping real DB. */
configLoadFailed: false,
/** F2: set true when device_config was absent (204) at load time; cleared on present load.
* handleSaveAll refuses to CREATE the key only dpworldapp seeds it on first boot. */
deviceConfigAbsent: false,
/** F2: set true when protocol_config was absent (204) at load time; cleared on present load.
* handleSaveAll refuses to CREATE the key only dpworldapp seeds it on first boot. */
protocolConfigAbsent: false,
/**
* v1.5.0 P1: per-page dirty matrix.
* Phase 1: 9 기존 page id + firmware placeholder.

Loading…
Cancel
Save