From 901aca6273263756aeb2229ae445639172a5a39b Mon Sep 17 00:00:00 2001 From: "Hoseok(Jose) LEE" Date: Thu, 2 Jul 2026 10:39:59 +0900 Subject: [PATCH] =?UTF-8?q?Release=20v1.12.3=20=E2=80=94=20=EB=B9=88=20?= =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20DB-init=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94(fail-soft)=20+=20Wi-Fi=20AP=20DFS=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=20=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- BSP-INTEGRATION.md | 25 ++++++++--- DELIVERABLE-MANIFEST.txt | 4 +- RELEASE-NOTES.md | 35 +++++++++++++--- VERSION | 2 +- src/db_manager.py | 9 ++++ src/firmware/fw_routes.py | 8 ++-- src/migrations.py | 58 ++++++++++++++------------ src/network/ap_engine.py | 82 +++++++++++++++++++++++++++++++++---- src/network/ap_renderer.py | 6 ++- src/network/apply_engine.py | 33 +++++++++++---- src/network/netmodel.py | 9 +++- src/network/watchdog.py | 2 +- src/server.py | 61 ++++++++++++++++----------- src/static/js/api.js | 6 +++ src/static/js/app.js | 15 +++++++ src/static/js/constants.js | 2 +- src/static/js/state.js | 6 +++ 17 files changed, 273 insertions(+), 90 deletions(-) diff --git a/BSP-INTEGRATION.md b/BSP-INTEGRATION.md index d88c0d0..166fc80 100644 --- a/BSP-INTEGRATION.md +++ b/BSP-INTEGRATION.md @@ -26,12 +26,11 @@ | `deploy/dpworld-*.service` | `${systemd_system_unitdir}/` (제품이 사용하는 유닛만) | | `deploy/*.service.d/` | `${systemd_system_unitdir}/.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/`)를 함께 둡니다. diff --git a/DELIVERABLE-MANIFEST.txt b/DELIVERABLE-MANIFEST.txt index d42ebeb..a90d20f 100644 --- a/DELIVERABLE-MANIFEST.txt +++ b/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 ================================================================================ 이 패키지는 무엇인가 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 0bf3c0c..1fe5640 100644 --- a/RELEASE-NOTES.md +++ b/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 서버) 송신 경로를 diff --git a/VERSION b/VERSION index 51b86ba..00f8626 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.12.1 +v1.12.3 diff --git a/src/db_manager.py b/src/db_manager.py index 8997a31..1633891 100644 --- a/src/db_manager.py +++ b/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: diff --git a/src/firmware/fw_routes.py b/src/firmware/fw_routes.py index 0e684b2..4d15f68 100644 --- a/src/firmware/fw_routes.py +++ b/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: diff --git a/src/migrations.py b/src/migrations.py index 4109941..f5e469d 100644 --- a/src/migrations.py +++ b/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 + + +_BYTE_ORDER_MAP = {"littleSwap": "little swap", "bigSwap": "big swap"} - # 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 ) diff --git a/src/network/ap_engine.py b/src/network/ap_engine.py index d18de8c..ce620b3 100644 --- a/src/network/ap_engine.py +++ b/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" diff --git a/src/network/ap_renderer.py b/src/network/ap_renderer.py index 56c25d4..15fc1ca 100644 --- a/src/network/ap_renderer.py +++ b/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" diff --git a/src/network/apply_engine.py b/src/network/apply_engine.py index 5fcbf8f..03ac04b 100644 --- a/src/network/apply_engine.py +++ b/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: - snapshot.mark_last_known_good(self.backups_dir, aid) + 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", diff --git a/src/network/netmodel.py b/src/network/netmodel.py index 4f13cca..0364dce 100644 --- a/src/network/netmodel.py +++ b/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) diff --git a/src/network/watchdog.py b/src/network/watchdog.py index 2a53b82..218cbda 100644 --- a/src/network/watchdog.py +++ b/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: diff --git a/src/server.py b/src/server.py index 90d190e..59ac4be 100644 --- a/src/server.py +++ b/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) - db.ensure_tables() - - # Migrate log-management settings into their own key (one-time, idempotent) - migrate_log_config(db) + # 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: diff --git a/src/static/js/api.js b/src/static/js/api.js index 85c0a48..aea9a3d 100644 --- a/src/static/js/api.js +++ b/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(); diff --git a/src/static/js/app.js b/src/static/js/app.js index 9c393f4..2f8bb4f 100644 --- a/src/static/js/app.js +++ b/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(); diff --git a/src/static/js/constants.js b/src/static/js/constants.js index 041a17a..f429091 100644 --- a/src/static/js/constants.js +++ b/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 패널·배지에서 사용). diff --git a/src/static/js/state.js b/src/static/js/state.js index fef9f84..d00efe4 100644 --- a/src/static/js/state.js +++ b/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.