Compare commits

...

2 Commits

Author SHA1 Message Date
Hoseok(Jose) LEE 3e99bdcad8 Release v1.12.4 - 레지스터 필드 디바이스 계약 정합 + 테이블 UI 정돈 (Modbus/OPC-UA/CAN) 4 weeks ago
Hoseok(Jose) LEE 901aca6273 Release v1.12.3 — 빈 디바이스 DB-init 안정화(fail-soft) + Wi-Fi AP DFS 채널 선택 1 month ago
  1. 25
      BSP-INTEGRATION.md
  2. 12
      CHANGELOG.md
  3. 4
      DELIVERABLE-MANIFEST.txt
  4. 43
      RELEASE-NOTES.md
  5. 2
      VERSION
  6. 76
      src/config_validator.py
  7. 9
      src/db_manager.py
  8. 11
      src/firmware/config_safety.py
  9. 8
      src/firmware/fw_routes.py
  10. 77
      src/migrations.py
  11. 82
      src/network/ap_engine.py
  12. 6
      src/network/ap_renderer.py
  13. 31
      src/network/apply_engine.py
  14. 9
      src/network/netmodel.py
  15. 2
      src/network/watchdog.py
  16. 64
      src/server.py
  17. 6
      src/static/js/api.js
  18. 15
      src/static/js/app.js
  19. 20
      src/static/js/constants.js
  20. 14
      src/static/js/pages/can-bus.js
  21. 10
      src/static/js/pages/can.js
  22. 36
      src/static/js/pages/modbus.js
  23. 29
      src/static/js/pages/opcua.js
  24. 27
      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/`)를 함께 둡니다.

12
CHANGELOG.md

@ -4,6 +4,18 @@
---
## [v1.12.4] — 2026-07-09
### Changed
- **프로토콜 레지스터 필드를 디바이스 config-reader 계약에 정렬 (Modbus/OPC-UA)**:
- Modbus 레지스터 `idt` 를 디바이스가 기대하는 **공백형**으로 저장 (`unsignedInteger`→`unsigned integer`, `unsignedShort`→`unsigned short`). UI 드롭다운 라벨은 그대로 유지(저장 시에만 변환, 로드 시 역변환). 이전에는 camelCase 로 저장돼 **Modbus 설정 전체가 로드 실패**할 수 있었습니다.
- **Modbus·OPC-UA 레지스터 폼에서 `Shift`/`Expr`/`Mask` 제거** — 해당 프로토콜에서는 디바이스가 사용하지 않는 필드입니다(`Expr` 은 저장되나 값에 미적용, `Shift`/`Mask` 는 Modbus/OPC-UA 레지스터 필드가 아님). **CAN 은 유지**(실제 적용됨).
- **ODT `string` 옵션**: Modbus·CAN 에서 제거(디바이스가 Modbus 는 거부, CAN 은 출력 없음), **OPC-UA 는 유지**. 백엔드도 Modbus/CAN 의 `string` 저장을 400 으로 거부.
- **레지스터 매핑 테이블 UI 정돈**: 컬럼 헤더를 축약어 없이 풀네임으로 통일 — Modbus `Field / Address / Input Type / Output Type / Default Value`, OPC-UA `Field / Namespace / Node ID / Output Type / Default Value`, CAN `Field / CAN ID / Output Type / Default Value / Shift / Expr / Mask`. CAN Baud Rate 드롭다운에서 미사용 `NONE` 항목을 표시에서 숨김(내부 데이터 계약·firmware 값 검증은 유지).
### Added
- 기존 디바이스 DB 의 위 표기를 **부팅 시 1회 자동 정합**(멱등 마이그레이션, 이전 계약-정합 마이그레이션을 거친 디바이스에서도 실행되도록 별도 플래그).
### Note
- 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`) 유지. dpworldapp 공유 DB·계약·타입 무변경(레지스터 표기 정합만).
## [v1.12.1] — 2026-06-29
### Added
- **텔레메트리 Uplink (Ethernet 전환)**: dpworldapp 클라우드 텔레메트리(프로토콜·업데이트·RTCM) 송신을 웹 UI 에서 Wi-Fi↔Ethernet 전환. 호스트 라우트(/32)만 사용 — dpworldapp·OS 설정 무접촉. 워치독 자가복원 + 관리 인터페이스 보호.

4
DELIVERABLE-MANIFEST.txt

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

43
RELEASE-NOTES.md

@ -1,13 +1,46 @@
# 릴리스 노트 — Web Configurator **v1.12.1**
# 릴리스 노트 — Web Configurator **v1.12.4**
- **배포일**: 2026-06-29
- **배포일**: 2026-07-09
- **대상**: 디바이스 BSP 이미지 포함용 (DP World Smart Solutions · IoT 디바이스)
- **패키지**: 내부 git 태그 `v1.12.1` (커밋 히스토리가 없는 **클린 스냅샷**)
- **이전 버전**: v1.12.0
- **패키지**: 내부 git 태그 `v1.12.4` (커밋 히스토리가 없는 **클린 스냅샷**)
- **이전 전달**: v1.12.1 — 본 전달은 v1.12.2·v1.12.3·v1.12.4 변경을 함께 포함합니다.
---
## 1. 한눈에 보기
## 0. v1.12.1 → v1.12.4 신규/변경 (이번 전달의 핵심)
이번 전달은 v1.12.1 위에 **v1.12.2·v1.12.3·v1.12.4** 를 함께 반영한 것입니다.
### v1.12.4 — 프로토콜 레지스터 필드를 디바이스 계약에 정합 (Modbus/OPC-UA)
- **Modbus `idt` 공백형 저장**: 레지스터 입력 데이터타입을 디바이스 config-reader 가 기대하는 공백형으로 저장합니다(`unsignedInteger`→`unsigned integer`, `unsignedShort`→`unsigned short`). UI 라벨은 그대로 두고 저장 시에만 변환하며, 로드 시 역변환합니다. 이전에는 camelCase 로 저장돼 **Modbus 설정 전체가 로드 실패**할 수 있었습니다.
- **Modbus·OPC-UA 폼에서 `Shift`/`Expr`/`Mask` 제거**: 해당 프로토콜에서 디바이스가 사용하지 않는 필드입니다(`Expr` 은 저장되나 값에 미적용, `Shift`/`Mask` 는 레지스터 필드가 아님). **CAN 은 유지**(CAN 에서는 실제 적용됨).
- **ODT `string`**: Modbus·CAN 에서 제거(디바이스가 Modbus 는 거부, CAN 은 출력 없음), OPC-UA 는 유지(완전 지원). 잘못된 저장은 400 으로 거부.
- 기존 디바이스 DB 의 위 표기는 **부팅 시 1회 자동 정합**(멱등 마이그레이션, 이전 계약-정합 마이그레이션을 거친 디바이스에서도 실행되도록 별도 플래그).
- dpworldapp 공유 DB·계약·타입 무변경(레지스터 표기 정합만). 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`) 유지.
- **레지스터 테이블 UI 정돈**: 3개 매핑 표(Modbus/OPC-UA/CAN)의 컬럼 헤더를 축약어 없이 풀네임으로 통일(`Output Type`·`Default Value`·`Input Type`·`Namespace`·`Node ID`·`Address`). CAN Baud Rate 드롭다운에서 미사용 `NONE` 항목을 표시에서 숨김(데이터 계약·firmware 값 검증은 유지).
### 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.4

76
src/config_validator.py

@ -54,6 +54,25 @@ _REGISTER_MAX_ENTRIES = 30 # v2: reader truncates at 30
_REGISTER_STR_MAX_LEN = 64 # mask / id 문자열 상한
_REGISTER_SHIFT_RANGE = range(0, 64) # 0-63 inclusive
# 2026-07-08: the device config reader expects idt as space-separated lowercase
# (case-insensitive). The UI/Java emit camelCase for the two 'unsigned*' tokens
# (and legacy 'float64' for idt), which the device does not accept, causing it to
# reject the whole Modbus config. Canonicalize on store so the stored config
# matches the reader contract. Only the drifting tokens are mapped; matching
# tokens pass through.
_IDT_NATIVE_CANONICAL = {
"unsignedinteger": "unsigned integer",
"unsignedshort": "unsigned short",
"float64": "float",
}
# The device does not apply expr for Modbus/OPC-UA (the field is accepted but
# has no effect on the read value). Force a guaranteed-valid identity so the
# field is always present and valid.
_UNUSED_EXPR_DEFAULT = "x"
# odt='string': the device rejects it for Modbus (whole-config fail) and
# produces no telemetry for CAN (silent trap). OPC-UA fully supports string.
_ODT_STRING_UNSUPPORTED_PROTOCOLS = frozenset({"MODBUS", "CAN"})
# v1.4.6.7 C-2: defense in depth — frontend convertNestedToFlat이 ports를 Integer로 emit
# (state.js fix와 정합). 외부 도구 / Java app POST 회귀 / MMI 직접 입력에서 string port가
@ -179,6 +198,52 @@ def process_protocol_config(data):
if "CAN" not in result or result["CAN"] is None:
result["CAN"] = []
# NOTE: canonicalization of register entries is NOT done here. This function runs
# on the MERGED result (preserved + incoming), and canonicalizing the merged view
# would rewrite preserved/inactive arrays a partial POST never touched (breaking the
# C1 partial-POST preservation invariant). Canonicalization is applied to the
# INCOMING data in the POST handler (server.py) and once to existing data by
# migrate_protocol_field_cleanup.
return result
def _canonicalize_idt(value):
"""Map a drifting idt token to the native space form (case-insensitive).
Non-string or already-canonical values pass through unchanged."""
if not isinstance(value, str):
return value
return _IDT_NATIVE_CANONICAL.get(value.strip().lower(), value)
def canonicalize_protocol_entries(protocol_cfg):
"""Align stored MODBUS / OPC_UA register entries with the device config-reader
contract. Returns a NEW dict; does not mutate the input.
- MODBUS: canonicalize idt to the native space form; drop shift/mask
(not part of the device's Modbus register fields); force expr to the identity default.
- OPC_UA: drop shift/mask; force expr to the identity default.
- CAN: untouched - the device applies its shift/expr/mask.
"""
if not isinstance(protocol_cfg, dict):
return protocol_cfg
result = dict(protocol_cfg)
for arr_key, has_idt in (("MODBUS", True), ("OPC_UA", False)):
arr = result.get(arr_key)
if not isinstance(arr, list):
continue
new_arr = []
for entry in arr:
if not isinstance(entry, dict):
new_arr.append(entry)
continue
e = dict(entry)
if has_idt and "idt" in e:
e["idt"] = _canonicalize_idt(e["idt"])
e.pop("shift", None)
e.pop("mask", None)
e["expr"] = _UNUSED_EXPR_DEFAULT
new_arr.append(e)
result[arr_key] = new_arr
return result
@ -525,7 +590,8 @@ def _is_numeric_value(v):
return False
def _validate_register_entry(entry, label, errors, require_addr=False, require_id=False):
def _validate_register_entry(entry, label, errors, require_addr=False, require_id=False,
protocol_key=None):
"""Per-entry field validation for a single register mapping object."""
if not isinstance(entry, dict):
errors.append(f"{label} must be an object")
@ -595,6 +661,13 @@ def _validate_register_entry(entry, label, errors, require_addr=False, require_i
_odt_normalized = _odt_lower
if _odt_normalized not in _REGISTER_ODT_VALUES:
errors.append(f"{label}.odt={odt!r} not in reader odt set")
elif (_odt_normalized == "string"
and protocol_key in _ODT_STRING_UNSUPPORTED_PROTOCOLS):
# the device rejects Modbus odt=string (whole-config fail) and emits
# no telemetry for CAN string; only OPC-UA supports it.
errors.append(
f"{label}.odt='string' not supported for {protocol_key} "
f"(device rejects it / produces no output)")
# dv — numeric normally; arbitrary string allowed when odt=string
dv = entry.get("dv")
@ -655,6 +728,7 @@ def validate_register_entries(data, merged_data=None):
_validate_register_entry(
entry, f"{arr_key} entry {i}", errors,
require_addr=require_addr, require_id=require_id,
protocol_key=arr_key,
)
return errors

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:

11
src/firmware/config_safety.py

@ -203,8 +203,17 @@ def detect_and_restore(db_path, default_file_path, backups_dir):
if os.path.isfile(proto):
with open(proto, "r", encoding="utf-8") as f:
proto_value = f.read()
if _as_dict(proto_value) is None:
proto_dict = _as_dict(proto_value)
if proto_dict is None:
return RestoreResult(False, "backup protocol_config invalid", backup)
# 2026-07-08: a backup captured from a pre-feature device carries camelCase
# Modbus idt (and dead shift/mask/legacy expr). Restoring it verbatim and then
# restarting dpworldapp (fw_controller) would write a config the device
# rejects (config-load fail -> telemetry stops), and the startup migration
# already ran this boot so it never self-heals. Canonicalize on this third
# protocol_config write path too (besides the POST handler and the migration).
from config_validator import canonicalize_protocol_entries
proto_value = json.dumps(canonicalize_protocol_entries(proto_dict))
# Both validated — now write atomically.
# v1.5.4.4 STAB-1 fix: 이전엔 두 _write 호출이 별도 connection / commit이라
# 두 번째 write 실패 시 device_config만 복원된 zombie state가 발생할 수 있었다.

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:

77
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,35 @@ 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.update_config("protocol_config", _mut)
db.set_schema_meta("contract_canonical_protocol_migrated", "true")
return True
def migrate_protocol_field_cleanup(db) -> bool:
"""Canonicalize protocol_config MODBUS/OPC_UA register entries to the device
config-reader contract (idt space form, drop shift/mask, identity expr). CAN
untouched. Uses its own flag so it runs even on devices already past the
contract_canonical migration. Defers until protocol_config is seeded."""
if db.get_schema_meta("protocol_field_cleanup_migrated") == "true":
return False
if _existing_config(db, "protocol_config") is None:
return False
from config_validator import canonicalize_protocol_entries
def _mut(cur):
if not isinstance(cur, dict) or not cur:
return cur
return canonicalize_protocol_entries(cur)
db.set_schema_meta("contract_canonical_migrated", "true")
db.update_config("protocol_config", _mut)
db.set_schema_meta("protocol_field_cleanup_migrated", "true")
return True
@ -205,7 +230,9 @@ _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
("protocol_field_cleanup", migrate_protocol_field_cleanup),
)

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:

64
src/server.py

@ -39,6 +39,7 @@ from config_validator import (
normalize_device_port_types,
validate_device_port_types_hard,
process_protocol_config,
canonicalize_protocol_entries,
ensure_meid_string,
validate_device_config,
validate_protocol_config,
@ -113,7 +114,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 +195,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 +203,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 +517,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 +537,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 +829,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 +969,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 방지)
@ -1024,6 +1032,14 @@ class ConfigHandler(BaseHTTPRequestHandler):
# Strip None values
cleaned = strip_none_values(data)
# 2026-07-08: canonicalize the arrays being WRITTEN (idt space-form,
# drop shift/mask, identity expr for Modbus/OPC-UA). Applied to the
# incoming data only, so preserved/inactive arrays (merged from the DB
# inside _merge_protocol) stay byte-for-byte unchanged (C1 partial-POST
# preservation invariant). Legacy stored data is canonicalized once by
# migrate_protocol_field_cleanup at startup.
cleaned = canonicalize_protocol_entries(cleaned)
# v1.5.1 T2: atomic RMW via update_config (H1/H2/H6 race fix).
# Replaces the get_config + merge + save_config triple with a single
# atomic BEGIN IMMEDIATE transaction so concurrent POSTs cannot lose
@ -1238,11 +1254,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();

20
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.4';
/**
* v1.7.0 pageId 표시명 (Pending Changes 패널·배지에서 사용).
@ -93,6 +93,20 @@ export const CAN_BAUDRATE_LABELS = {
1000: '1M/s',
};
export const RS485_BAUDRATES = [4800, 9600, 19200, 38400, 57600, 115200];
// v2: 'float' (idt); reader accepts (case-insensitive) float not float64. ODT_OPTIONS unchanged.
// v2: 'float' (idt); reader accepts (case-insensitive) float not float64.
export const IDT_OPTIONS = ['integer', 'float', 'boolean', 'unsignedInteger', 'short', 'unsignedShort'];
export const ODT_OPTIONS = ['integer', 'float64', 'boolean', 'string'];
// 2026-07-08 protocol-field-cleanup: per-protocol output-data-type lists. A single
// shared list previously let 'string' leak into Modbus/CAN, which the device
// rejects for Modbus (whole-config fail) or does not emit for CAN (no telemetry).
// OPC-UA fully supports string.
export const OPCUA_ODT_OPTIONS = ['integer', 'float64', 'boolean', 'string'];
export const MODBUS_ODT_OPTIONS = ['integer', 'float64', 'boolean'];
export const CAN_ODT_OPTIONS = ['integer', 'float64', 'boolean'];
// idt display alias: a stored native space-form re-selects the friendly camelCase
// dropdown option on load (Modbus register entries only carry idt).
export const IDT_DISPLAY_ALIAS = {
'unsigned integer': 'unsignedInteger',
'unsigned short': 'unsignedShort',
};

14
src/static/js/pages/can-bus.js

@ -7,14 +7,14 @@
import { state } from '../state.js';
import { populateCrudTable } from '../components/crud-table.js';
import { CAN_BAUDRATES, CAN_BAUDRATE_LABELS, ODT_OPTIONS } from '../constants.js';
import { CAN_BAUDRATES, CAN_BAUDRATE_LABELS, CAN_ODT_OPTIONS } from '../constants.js';
import { icon } from '../icons.js';
const CAN_COLUMNS = [
export const CAN_COLUMNS = [
{ key: 'field', label: 'Field', placeholder: 'CAN1', maxLength: 4 },
{ key: 'id', label: 'CAN ID', placeholder: '0x18FEFC28' },
{ key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS },
{ key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' },
{ key: 'odt', label: 'Output Type', type: 'select', options: CAN_ODT_OPTIONS },
{ key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '90px' },
{ key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' },
{ key: 'expr', label: 'Expr', placeholder: 'x*0.05' },
{ key: 'mask', label: 'Mask', placeholder: '0xff' },
@ -50,7 +50,7 @@ const canBusPage = {
<div class="form-group">
<label class="form-label">Baud Rate</label>
<select class="form-select" id="can_speed">
${CAN_BAUDRATES.map(v => `<option value="${v}" ${String(can.speed) === String(v) ? 'selected' : ''}>${CAN_BAUDRATE_LABELS[v] || v}</option>`).join('')}
${CAN_BAUDRATES.filter(v => v !== 'NONE').map(v => `<option value="${v}" ${String(can.speed) === String(v) ? 'selected' : ''}>${CAN_BAUDRATE_LABELS[v] || v}</option>`).join('')}
</select>
</div>
</div>
@ -73,8 +73,8 @@ const canBusPage = {
<tr>
<th style="width:90px;">Field</th>
<th style="width:120px;">CAN ID</th>
<th style="width:100px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:110px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th style="width:60px;">Shift</th>
<th style="width:150px;">Expr</th>
<th style="width:80px;">Mask</th>

10
src/static/js/pages/can.js

@ -10,13 +10,13 @@
import { state } from '../state.js';
import { populateCrudTable } from '../components/crud-table.js';
import { ODT_OPTIONS } from '../constants.js';
import { CAN_ODT_OPTIONS } from '../constants.js';
const CAN_COLUMNS = [
{ key: 'field', label: 'Field', placeholder: 'CAN1', maxLength: 4 },
{ key: 'id', label: 'CAN ID', placeholder: '0x18FEFC28' },
{ key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS },
{ key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' },
{ key: 'odt', label: 'Output Type', type: 'select', options: CAN_ODT_OPTIONS },
{ key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '90px' },
{ key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' },
{ key: 'expr', label: 'Expr', placeholder: 'x*0.05' },
{ key: 'mask', label: 'Mask', placeholder: '0xff' },
@ -49,8 +49,8 @@ export function renderCanPage(container) {
<tr>
<th style="width:90px;">Field</th>
<th style="width:120px;">CAN ID</th>
<th style="width:100px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:110px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th style="width:60px;">Shift</th>
<th style="width:150px;">Expr</th>
<th style="width:80px;">Mask</th>

36
src/static/js/pages/modbus.js

@ -9,7 +9,8 @@
* (LITTLE("little") / BIG("big")) 복구. Byte Order card에 two_byte_order
* + four_byte_order dropdown 모두 추가.
*
* Columns: Field, Addr, IDT, ODT, DV, Shift, Expr, Mask
* Columns: Field, Addr, IDT, ODT, DV
* (shift/expr/mask removed 2026-07-08 - native Modbus ignores them; see spec.)
*
* Data source: state.protocol.MODBUS (array), state.device.server.*,
* state.protocol.two_byte_order, state.protocol.four_byte_order
@ -17,21 +18,18 @@
import { state, bindIpGroup, bindInput } from '../state.js';
import { populateCrudTable } from '../components/crud-table.js';
import { IDT_OPTIONS, ODT_OPTIONS } from '../constants.js';
import { IDT_OPTIONS, MODBUS_ODT_OPTIONS } from '../constants.js';
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js';
import { setupPortInputs } from '../components/port-input.js';
import { escapeHtml } from '../utils.js';
import { icon } from '../icons.js';
const MODBUS_COLUMNS = [
export const MODBUS_COLUMNS = [
{ key: 'field', label: 'Field', placeholder: 'TEMP', maxLength: 4 },
{ key: 'addr', label: 'Addr', placeholder: '100' },
{ key: 'idt', label: 'IDT', type: 'select', options: IDT_OPTIONS },
{ key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS },
{ key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' },
{ key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' },
{ key: 'expr', label: 'Expr', placeholder: 'x*0.1' },
{ key: 'mask', label: 'Mask', placeholder: '0xffff' },
{ key: 'addr', label: 'Address', placeholder: '100', width: '90px' },
{ key: 'idt', label: 'Input Type', type: 'select', options: IDT_OPTIONS },
{ key: 'odt', label: 'Output Type', type: 'select', options: MODBUS_ODT_OPTIONS },
{ key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '100px' },
];
let _modbusTable = null;
@ -107,13 +105,10 @@ export function renderModbusPage(container) {
<thead>
<tr>
<th style="width:90px;">Field</th>
<th style="width:70px;">Addr</th>
<th style="width:120px;">IDT</th>
<th style="width:120px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:60px;">Shift</th>
<th style="width:100px;">Expr</th>
<th style="width:80px;">Mask</th>
<th style="width:100px;">Address</th>
<th style="width:140px;">Input Type</th>
<th style="width:120px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th class="col-action"></th>
</tr>
</thead>
@ -132,12 +127,9 @@ export function renderModbusPage(container) {
<div class="legend-grid">
<div><strong>Field</strong> Data field name</div>
<div><strong>Addr</strong> Modbus Register address</div>
<div><strong>IDT</strong> — Input Data Type (integer / float64 / boolean / unsignedInteger / short / unsignedShort)</div>
<div><strong>ODT</strong> Output Data Type</div>
<div><strong>IDT</strong> — Input Data Type (integer / float / boolean / unsignedInteger / short / unsignedShort)</div>
<div><strong>ODT</strong> — Output Data Type (integer / float64 / boolean)</div>
<div><strong>DV</strong> Default Value (used on connection failure)</div>
<div><strong>Shift</strong> Bit shift value</div>
<div><strong>Expr</strong> Conversion expression (e.g. x*0.1)</div>
<div><strong>Mask</strong> Bitmask (e.g. 0xffff)</div>
</div>
</div>
</div>

29
src/static/js/pages/opcua.js

@ -4,7 +4,8 @@
* v1.5.0 Phase 3: Server Endpoint card 추가 (opc_ua_server_ip + opc_ua_server_port)
* + 기존 OPC-UA Node Mapping table 유지.
*
* Columns: Field, NS (Namespace), Addr (Node ID), ODT, DV, Shift, Expr, Mask
* Columns: Field, NS (Namespace), Addr (Node ID), ODT, DV
* (shift/expr/mask removed 2026-07-08 - native OPC-UA ignores them; see spec.)
* Note: No IDT column (differs from MODBUS)
*
* Data source: state.protocol.OPC_UA (array), state.device.server.*
@ -12,21 +13,18 @@
import { state, bindIpGroup, bindInput } from '../state.js';
import { populateCrudTable } from '../components/crud-table.js';
import { ODT_OPTIONS } from '../constants.js';
import { OPCUA_ODT_OPTIONS } from '../constants.js';
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js';
import { setupPortInputs } from '../components/port-input.js';
import { escapeHtml } from '../utils.js';
import { icon } from '../icons.js';
const OPCUA_COLUMNS = [
export const OPCUA_COLUMNS = [
{ key: 'field', label: 'Field', placeholder: 'TMP1', maxLength: 4 },
{ key: 'ns', label: 'NS', placeholder: '3', width: '60px' },
{ key: 'addr', label: 'Addr', placeholder: '1001' },
{ key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS },
{ key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' },
{ key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' },
{ key: 'expr', label: 'Expr', placeholder: 'x*10' },
{ key: 'mask', label: 'Mask', placeholder: '0xffff' },
{ key: 'ns', label: 'Namespace', placeholder: '3', width: '80px' },
{ key: 'addr', label: 'Node ID', placeholder: '1001', width: '90px' },
{ key: 'odt', label: 'Output Type', type: 'select', options: OPCUA_ODT_OPTIONS },
{ key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '100px' },
];
let _opcuaTable = null;
@ -75,13 +73,10 @@ export function renderOpcuaPage(container) {
<thead>
<tr>
<th style="width:90px;">Field</th>
<th style="width:60px;">NS</th>
<th style="width:80px;">Addr</th>
<th style="width:120px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:60px;">Shift</th>
<th style="width:120px;">Expr</th>
<th style="width:80px;">Mask</th>
<th style="width:100px;">Namespace</th>
<th style="width:100px;">Node ID</th>
<th style="width:120px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th class="col-action"></th>
</tr>
</thead>

27
src/static/js/state.js

@ -5,7 +5,7 @@
* All page modules read from and write to this state.
*/
import { DEFAULTS } from './constants.js';
import { DEFAULTS, IDT_DISPLAY_ALIAS } from './constants.js';
const DEBUG = typeof location !== 'undefined' &&
(location.hostname === 'localhost' || location.hostname === '127.0.0.1');
@ -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.
@ -255,6 +261,15 @@ export function setProtocol(data) {
const lookup = String(data.protocol).toUpperCase().replace(/[-\s]+/g, '_');
data.protocol = PROTOCOL_VALUE_ALIAS[lookup] ?? data.protocol;
}
// 2026-07-08: a stored device-format idt (space form, e.g. 'unsigned integer') re-selects
// the friendly camelCase dropdown option. Only Modbus entries carry idt.
if (Array.isArray(data.MODBUS)) {
data.MODBUS.forEach(entry => {
if (entry && typeof entry.idt === 'string' && IDT_DISPLAY_ALIAS[entry.idt]) {
entry.idt = IDT_DISPLAY_ALIAS[entry.idt];
}
});
}
}
state.protocol = data || createDefaultProtocol();
_originalProtocol = _deepClone(state.protocol);
@ -717,15 +732,15 @@ export function buildDevicePayload() {
*/
export function buildProtocolPayload() {
const payload = state.protocol ? { ...state.protocol } : createDefaultProtocol();
// Ensure shift values are always strings (legacy compatibility)
['CAN', 'MODBUS', 'OPC_UA'].forEach(key => {
if (Array.isArray(payload[key])) {
payload[key] = payload[key].map(row => ({
// Ensure CAN shift values are strings (legacy compatibility). Modbus/OPC-UA no
// longer carry shift (removed 2026-07-08); the backend chokepoint strips any
// that slip through, so we must NOT re-inject shift='0' into their rows here.
if (Array.isArray(payload.CAN)) {
payload.CAN = payload.CAN.map(row => ({
...row,
shift: row.shift != null ? String(row.shift) : '0',
}));
}
});
return payload;
}

Loading…
Cancel
Save