Browse Source
- web-configurator.service: MemoryMax 48M -> 128M (support-bundle+UI 동시 사용 시 OOM-kill 위험 제거; 장비 RAM 3.4GB 대비 3.7%, 누수 방지 캡 유지) - BSP 통합 가이드 정비: 네트워크-적용 하드닝본을 /usr/bin/dpworld-network-apply.sh 원본 교체(BSP 베이킹)하는 것을 1차 방식으로 명시. .service.d override 드롭인은 라이브 후적용(over-the-top) fallback으로 정리 - 최신 UI(v1.11.15: 사이드바 System/Firmware 그룹 + monitor 아이콘) 동기화 - 배포물 flat 레이아웃(루트=배포물), 내부 참조/산출물 정리release/v1.12.1
commit
955b47a335
119 changed files with 31479 additions and 0 deletions
@ -0,0 +1,6 @@ |
|||
*.service text eol=lf |
|||
# v1.7.1: deploy/ 셸 스크립트 + systemd drop-in conf — 장비에서 실행되므로 LF 강제 |
|||
deploy/*.sh text eol=lf |
|||
deploy/**/*.conf text eol=lf |
|||
# v1.6.0: golden fixtures 는 장비 바이트 그대로 보존 — 어떤 EOL 변환도 금지 (byte-exact 테스트) |
|||
tests/fixtures/network_golden/** -text |
|||
@ -0,0 +1,72 @@ |
|||
# Dependencies |
|||
node_modules/ |
|||
|
|||
# Python bytecode and test/cache output |
|||
__pycache__/ |
|||
*.py[cod] |
|||
*$py.class |
|||
.pytest_cache/ |
|||
.coverage |
|||
.coverage.* |
|||
htmlcov/ |
|||
.tox/ |
|||
.mypy_cache/ |
|||
.ruff_cache/ |
|||
.pyre/ |
|||
|
|||
# Virtual environments |
|||
.venv/ |
|||
venv/ |
|||
env/ |
|||
|
|||
# Local runtime data |
|||
dev_data.db* |
|||
*.db |
|||
*.db-* |
|||
*.sqlite |
|||
*.sqlite-* |
|||
*.sqlite3 |
|||
*.sqlite3-* |
|||
/db/ |
|||
/logs/ |
|||
*.log |
|||
*.log.* |
|||
network_journal.jsonl |
|||
|
|||
# Runtime/download workspaces |
|||
/config_backups/ |
|||
/fw_staging/ |
|||
/fw_upload/ |
|||
/network/ |
|||
log-download-*/ |
|||
kernel-bundle-*/ |
|||
dpw_fw_*.zip |
|||
|
|||
# Downloaded/generated bundles and firmware images |
|||
support-bundle_*.zip |
|||
logs_*.tar.gz |
|||
kernel-logs_*.tar.gz |
|||
forensic_*.tar.gz |
|||
webcfg-deploy-*.tar |
|||
*.zip |
|||
*.tar |
|||
*.tar.gz |
|||
*.tgz |
|||
*.gz |
|||
*.bin |
|||
*.img |
|||
|
|||
# Internal-only push runbook (not for the partner repo) |
|||
PARTNER-PUSH.md |
|||
|
|||
# Local environment and editor files |
|||
.env |
|||
.env.* |
|||
!.env.example |
|||
.DS_Store |
|||
Thumbs.db |
|||
.vscode/ |
|||
.idea/ |
|||
*.swp |
|||
*.swo |
|||
*~ |
|||
@ -0,0 +1,202 @@ |
|||
# BSP 통합 가이드 — Web Configurator v1.11.16 |
|||
|
|||
디바이스 이미지(Yocto/OE 등)에 포함하기 위한 **커밋 히스토리 없는 클린 스냅샷**입니다. |
|||
런타임 앱·systemd 유닛·샘플·문서와 optional nginx sample 설정만 들어 있고, 개발/내부 자료와 VCS 이력은 없습니다. |
|||
|
|||
--- |
|||
|
|||
## 1. 런타임 구조 |
|||
- **Python 3 애플리케이션, stdlib 전용** — pip·virtualenv·외부 패키지 없음. |
|||
- 실행 진입점: `python3 /opt/web-configurator/src/server.py` |
|||
- 리슨 포트: **9090** (유닛의 `Environment=PORT=` 로 변경 가능) |
|||
- 앱은 기본적으로 `:9090`에서 직접 서비스됩니다. nginx `:80` reverse proxy는 optional이며, `deploy/nginx.conf`는 BSP가 필요할 때 쓰는 sample 설정입니다. |
|||
- 영속 상태 — **패키지에 없음**(런타임/다른 컴포넌트가 생성): |
|||
- DB: `/home/root/db/dynamic_data.db` (`DB_PATH`) |
|||
- 로그: `/opt/log/dpworldapp` (`LOG_DIR`) |
|||
|
|||
> **nginx optional**: `scripts/deploy.ps1`는 nginx 설정을 설치하거나 수정하지 않습니다. BSP에서 `deploy/nginx.conf`를 사용할 때만 이미지 레이아웃에 맞게 설치하고, Python 앱 단독 프록시라면 `proxy_pass`를 `http://127.0.0.1:9090/` 로 맞추세요. |
|||
|
|||
--- |
|||
|
|||
## 2. 설치 경로 (`do_install`) |
|||
| 소스 (이 패키지) | 디바이스 설치 위치 | |
|||
|---|---| |
|||
| `src/` | `/opt/web-configurator/src/` | |
|||
| `deploy/web-configurator.service` | `${systemd_system_unitdir}/` | |
|||
| `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`) |
|||
- 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/`에 설치 |
|||
쓰기 가능 런타임 디렉토리 생성 (recipe `do_install` / tmpfiles.d / 유닛의 `ExecStartPre` 중 택1): |
|||
``` |
|||
/opt/web-configurator /opt/log/dpworldapp /opt/fw_staging /opt/fw_upload |
|||
/opt/config_backups /home/root/db /home/root/network |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 3. 런타임 의존성 (`RDEPENDS`) |
|||
**stdlib 전용** — 앱이 import하는 python3 표준 모듈만 이미지에 포함하면 됩니다. (외부 패키지 없음) |
|||
OE의 모듈형 python3 기준 예시: |
|||
``` |
|||
python3-core python3-sqlite3 python3-json python3-netserver python3-netclient |
|||
python3-compression python3-crypt python3-logging python3-datetime python3-threading |
|||
python3-shell python3-io python3-mime python3-stringold python3-ctypes |
|||
``` |
|||
실제 import: `sqlite3, http(.server), socketserver, socket, urllib, json, gzip, tarfile, |
|||
zipfile, hashlib, subprocess, shlex, logging, datetime, ipaddress, mimetypes, struct, |
|||
tempfile, shutil, re, io, os, sys, time, copy, traceback` — 사용하는 OE python3 분할 패키지에 맞춰 확인하세요. |
|||
|
|||
추가로 유닛이 런타임에 쓰는 시스템 패키지: nginx reverse proxy를 이미지에서 제공할 때만 `nginx`. |
|||
네트워크/AP 유닛 사용 시 `wpa-supplicant`, `hostapd`, `udhcpd`/`busybox`, `iproute2` 등. |
|||
|
|||
--- |
|||
|
|||
## 4. systemd |
|||
``` |
|||
SYSTEMD_SERVICE:${PN} = "web-configurator.service" |
|||
SYSTEMD_AUTO_ENABLE = "enable" |
|||
``` |
|||
제품에서 필요한 `dpworld-*` 유닛(network-apply, Wi-Fi AP, recovery)을 추가하세요. 전체 기능 이미지에서는 보통 다음처럼 함께 등록합니다: |
|||
``` |
|||
SYSTEMD_SERVICE:${PN} = "web-configurator.service \ |
|||
dpworld-ap-seed.service \ |
|||
dpworld-hostapd-ap0.service \ |
|||
dpworld-udhcpd-ap0.service" |
|||
``` |
|||
`dpworld-ap-apply.service`와 `dpworld-net-recover.service`는 웹 설정기/watchdog이 on-demand로 `systemctl start` 하므로 `[Install]` 없이 설치만 하면 됩니다. `dpworld-network-apply.service`/`dpworld-network-seed.service`는 firmware-native base 유닛이며 `/usr/bin/dpworld-network-apply.sh` 를 호출합니다 — 본 패키지는 **그 base 스크립트를 하드닝본으로 교체**합니다(권장, §7). (펌웨어 원본을 교체할 수 없는 라이브 디바이스에서는 `.service.d` override 드롭인이 fallback.) |
|||
|
|||
--- |
|||
|
|||
## 5. 샘플 레시피 — `web-configurator_1.11.16.bb` |
|||
```bitbake |
|||
SUMMARY = "DP World Smart Solutions — Web Configurator" |
|||
LICENSE = "CLOSED" |
|||
|
|||
# 방법 A — 내부 git 서버의 태그 릴리스를 fetch (배포물이 repo 루트에 평탄 배치됨): |
|||
SRC_URI = "git://<internal-host>/<path>/NewWebConfigurator.git;branch=<branch>;protocol=ssh" |
|||
SRCREV = "<v1.11.16 의 sha 또는 tag>" |
|||
S = "${WORKDIR}/git" |
|||
# 방법 B — git archive 로 만든 스냅샷 tarball 사용: |
|||
# (git -C <repo> archive v1.11.16 | gzip > web-configurator-v1.11.16.tar.gz) |
|||
# SRC_URI = "file://web-configurator-v1.11.16.tar.gz" |
|||
# S = "${WORKDIR}" |
|||
|
|||
inherit systemd |
|||
|
|||
RDEPENDS:${PN} += "python3-core python3-sqlite3 python3-json python3-netserver \ |
|||
python3-netclient python3-compression python3-crypt python3-logging \ |
|||
python3-datetime python3-threading python3-shell python3-io \ |
|||
python3-mime" |
|||
|
|||
# Optional: add nginx only if this recipe installs/enables the nginx reverse proxy. |
|||
# RDEPENDS:${PN} += "nginx" |
|||
|
|||
SYSTEMD_SERVICE:${PN} = "web-configurator.service" |
|||
SYSTEMD_AUTO_ENABLE = "enable" |
|||
|
|||
do_install() { |
|||
# 애플리케이션 |
|||
install -d ${D}/opt/web-configurator |
|||
cp -r ${S}/src ${D}/opt/web-configurator/ |
|||
|
|||
# systemd 유닛 |
|||
install -d ${D}${systemd_system_unitdir} |
|||
install -m 0644 ${S}/deploy/web-configurator.service ${D}${systemd_system_unitdir}/ |
|||
install -m 0644 ${S}/deploy/dpworld-ap-apply.service ${D}${systemd_system_unitdir}/ |
|||
install -m 0644 ${S}/deploy/dpworld-ap-seed.service ${D}${systemd_system_unitdir}/ |
|||
install -m 0644 ${S}/deploy/dpworld-hostapd-ap0.service ${D}${systemd_system_unitdir}/ |
|||
install -m 0644 ${S}/deploy/dpworld-udhcpd-ap0.service ${D}${systemd_system_unitdir}/ |
|||
install -m 0644 ${S}/deploy/dpworld-net-recover.service ${D}${systemd_system_unitdir}/ |
|||
|
|||
# 유닛 ExecStart 대상 스크립트 |
|||
install -d ${D}${bindir} |
|||
install -m 0755 ${S}/deploy/dpworld-ap-apply.sh ${D}${bindir}/dpworld-ap-apply.sh |
|||
|
|||
# 네트워크-적용 하드닝 (권장 = 직접 교체): |
|||
# 펌웨어 base 스크립트 /usr/bin/dpworld-network-apply.sh 를 하드닝본으로 교체한다. |
|||
# firmware-native 한 dpworld-network-apply.service / dpworld-network-seed.service 가 |
|||
# 이 경로를 호출하므로 apply·--boot 모두 자동으로 하드닝 동작을 탄다. 드롭인은 설치하지 않는다. |
|||
# (파일 소유권은 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 |
|||
install -m 0644 ${S}/deploy/dpworld-network-apply-ondemand.conf \ |
|||
${D}${systemd_system_unitdir}/dpworld-network-apply-ondemand.conf |
|||
|
|||
# Fallback (라이브 후적용 전용 — 베이킹 시 설치하지 않음): |
|||
# 펌웨어 원본을 교체할 수 없는 경우에만, 하드닝본을 원래 -hardened 이름으로 설치하고 |
|||
# deploy/dpworld-network-{apply,seed}.service.d/20-hardened.conf 드롭인 2개로 |
|||
# base 유닛의 ExecStart 를 override 한다. |
|||
|
|||
# Optional nginx site config. |
|||
# scripts/deploy.ps1 does not use this file. Skip this block if firmware/BSP |
|||
# owns nginx, or if the device will access the app directly on :9090. |
|||
if [ -f ${S}/deploy/nginx.conf ]; then |
|||
install -d ${D}${sysconfdir}/nginx |
|||
install -m 0644 ${S}/deploy/nginx.conf ${D}${sysconfdir}/nginx/web-configurator.conf |
|||
fi |
|||
|
|||
# 쓰기 가능 런타임 디렉토리 |
|||
install -d ${D}/opt/log/dpworldapp ${D}/opt/fw_staging ${D}/opt/fw_upload \ |
|||
${D}/opt/config_backups ${D}/home/root/db ${D}/home/root/network |
|||
} |
|||
|
|||
FILES:${PN} += "/opt/web-configurator /opt/log ${systemd_system_unitdir} \ |
|||
/opt/fw_staging /opt/fw_upload /opt/config_backups \ |
|||
/home/root/db /home/root/network ${bindir}/dpworld-*.sh \ |
|||
${sysconfdir}/nginx" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 6. 부팅 후 동작 확인 (smoke test) |
|||
```sh |
|||
systemctl status web-configurator # active (running) |
|||
curl -fsS http://127.0.0.1:9090/ | head # 앱 응답 |
|||
curl -fsS http://127.0.0.1/ | head # optional: nginx 경유 사용 시 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 7. firmware-native 전제 + 네트워크-적용 하드닝 통합 |
|||
|
|||
### firmware가 제공하는 것 (이 패키지에 **미포함**) |
|||
- **base 네트워크-적용 유닛** — `dpworld-network-apply.service`, `dpworld-network-seed.service`. 이 유닛들은 `/usr/bin/dpworld-network-apply.sh` 를 호출합니다(no-arg / `--boot`). |
|||
- **dpworldapp**(텔레메트리 바이너리), (전환기의 레거시 `app-runner`) — firmware-native. |
|||
- **nginx** — 제품 이미지가 `:80` reverse proxy를 제공할 때만. 앱을 `:9090`으로 직접 접근하면 불필요. |
|||
|
|||
### 네트워크-적용 하드닝본 통합 — ① 직접 교체(권장) vs ② override(fallback) |
|||
펌웨어 base `/usr/bin/dpworld-network-apply.sh` 에는 WiFi country 변경 시 `modprobe -r wlan` |
|||
라이브 재로드 경로가 있어 QCA6490에서 PMU 워치독 재부팅 루프를 유발한 이력이 있습니다. |
|||
`deploy/dpworld-network-apply-hardened.sh` 가 이를 제거한 **완전 대체본**입니다. |
|||
|
|||
| | ① 직접 교체 (BSP 베이킹, **권장**) | ② override (라이브 후적용, fallback) | |
|||
|---|---|---| |
|||
| 대상 | 펌웨어 이미지를 빌드하는 협력사 | 이미 구워진 디바이스(우리 `deploy.ps1`) | |
|||
| 방법 | 하드닝본 내용을 **`/usr/bin/dpworld-network-apply.sh` 원본 이름**으로 설치(원본 교체) | 하드닝본을 `…-hardened.sh` 로 두고 `.service.d/20-hardened.conf` 드롭인 2개로 ExecStart 교체 | |
|||
| 드롭인 | **설치 안 함** | 설치함 | |
|||
| 효과 | base 유닛이 그대로 하드닝 스크립트를 실행 | base 유닛의 ExecStart가 하드닝본으로 바뀜 | |
|||
|
|||
절차 상세: `docs/firmware-boot-hardening.md` "펌웨어 베이킹 (BSP) — 원본 교체 방법". |
|||
|
|||
### 이 패키지가 소유·제공하는 스크립트 (모두 포함됨) |
|||
| 스크립트 | 설치 위치(권장 ①) | 호출 주체 | |
|||
|---|---|---| |
|||
| `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` | |
|||
|
|||
> fallback(②) 사용 시에는 `…-hardened.sh` 이름으로 설치하고 `.service.d` 드롭인 2개(`deploy/dpworld-network-apply.service.d/`, `deploy/dpworld-network-seed.service.d/`)를 함께 둡니다. |
|||
|
|||
### 웹 설정기 앱이 런타임에 호출하는 유닛 |
|||
`server.py` 는 다음을 `systemctl start` 로 호출합니다: |
|||
`dpworld-network-apply.service`(firmware base 유닛 — 교체된 하드닝 스크립트 실행), `dpworld-ap-apply.service`, `dpworld-hostapd-ap0.service`, `dpworld-udhcpd-ap0.service`, `dpworld-net-recover.service`. |
|||
→ base 네트워크-적용 **유닛**(firmware-native)을 제외하면 모두 본 패키지에 포함됩니다. |
|||
@ -0,0 +1,42 @@ |
|||
# Changelog — Web Configurator |
|||
|
|||
파트너 전달용 변경 이력입니다. **v1.11.16부터** 이 형식으로 관리합니다. |
|||
|
|||
--- |
|||
|
|||
## [v1.11.16] — 2026-06-23 |
|||
### Changed |
|||
- `web-configurator.service` 의 `MemoryMax` 를 **48M → 128M** 로 상향. support-bundle(진단 zip, 메모리 빌드) |
|||
+ UI 동시 사용 시 실측 피크 ~40-50MB로 OOM-kill 위험이 있어 여유를 확보 (장비 RAM 3.4GB 대비 3.7%, |
|||
누수 방지 캡은 유지). 정적자산 최대 93KB·펌웨어 업로드는 디스크 스트리밍이라 메모리 영향 없음. |
|||
|
|||
### Docs |
|||
- BSP 통합 가이드 정비 — 네트워크-적용 하드닝본을 **`/usr/bin/dpworld-network-apply.sh` 원본 교체 |
|||
(BSP 베이킹)** 하는 것을 1차 방식으로 명시. `.service.d` override 드롭인은 라이브 후적용 fallback으로 정리. |
|||
(`RELEASE-NOTES.md` §3, `BSP-INTEGRATION.md` §7, `docs/firmware-boot-hardening.md`) |
|||
- 배포물을 repo 루트(flat) 구조로 정리. |
|||
|
|||
> 앱 코드·UI는 v1.11.15와 동일 (기능 무변경). 본 릴리스는 배포 단위의 메모리 정책 + 통합 가이드 정비입니다. |
|||
|
|||
--- |
|||
|
|||
## [v1.11.15] — 2026-06-23 |
|||
### Changed |
|||
- 사이드바 네비게이션 정비: **System** 그룹(Dashboard + Log)과 **Firmware** 그룹 분리, System 그룹에 monitor 아이콘 적용. (v1.11.14–v1.11.15) |
|||
- (v1.11.12–v1.11.13: 대시보드 visual-calm 시도 후 되돌림 — 최종 외형 변화 없음) |
|||
|
|||
--- |
|||
|
|||
## [v1.11.11] — 2026-06-23 |
|||
### Changed |
|||
- config 쓰기 경로를 디바이스 config-reader 계약에 정렬: RS485 parity `no→none`, 2/4-byte order 공백형 |
|||
(`little swap`/`big swap`), register `idt` `float`(`float64` 제거), protocol 목록에서 CAN 제거 |
|||
(`can_input` 토글로 대체), 채널 `ai2`/`ai3`/`di2`/`di3` 지원. |
|||
|
|||
### Added |
|||
- 기존 디바이스 DB의 옛 표기를 새 계약 표기로 **부팅 시 1회 자동 변환** (멱등 마이그레이션, 변경 전 자동 백업). |
|||
|
|||
--- |
|||
|
|||
## [v1.11.10] — 2026-06-20 |
|||
- 전체 코드리뷰 반영 — 입력 검증/정규화 강화 다수. |
|||
@ -0,0 +1,82 @@ |
|||
DP World Smart Solutions — Web Configurator |
|||
파트너 전달물 — v1.11.16 |
|||
BSP 이미지 포함용 클린 스냅샷. 커밋 히스토리 없음, 내부 전용 자료 없음. |
|||
일자: 2026-06-23 |
|||
|
|||
================================================================================ |
|||
이 패키지는 무엇인가 |
|||
================================================================================ |
|||
디바이스용 Python 3 웹 설정기(stdlib 전용 — pip 없음)와, 디바이스에서 구동하기 위한 |
|||
systemd 유닛입니다. nginx 설정은 optional sample입니다. 태그 릴리스를 평탄 스냅샷(`git archive`)으로 추출해 |
|||
커밋 히스토리와 개발/내부 문서가 포함돼 있지 않습니다. |
|||
|
|||
먼저 읽을 문서: |
|||
RELEASE-NOTES.md 이번 버전 변경/주의/배포방법 (한글) |
|||
BSP-INTEGRATION.md 설치 경로 + 샘플 bitbake recipe + RDEPENDS (한글) |
|||
|
|||
================================================================================ |
|||
구성 |
|||
================================================================================ |
|||
src/ 애플리케이션 (Python 3, stdlib 전용) |
|||
server.py 진입점 — HTTP 서버 (기본 PORT 9090) |
|||
config_validator.py config 쓰기 경로 검증/정규화 |
|||
dpworldapp_enums.py 디바이스 config-reader 계약 enum 집합 |
|||
enum_normalizer.py 입력 별칭 정규화 |
|||
migrations.py 부팅 시 1회 DB 마이그레이션 (schema_meta 게이트, 멱등) |
|||
db_manager.py SQLite(board_config) 접근 |
|||
network/ firmware/ network-apply / 펌웨어 OTA 서브시스템 |
|||
static/ 웹 UI (HTML/CSS/JS) |
|||
deploy/ 디바이스 통합 산출물 |
|||
web-configurator.service 메인 systemd 유닛 (python3 src/server.py, :9090) |
|||
nginx.conf optional reverse proxy sample (:80 → 앱) |
|||
dpworld-network-*.service.d / dpworld-network-apply-hardened.sh / *.conf |
|||
dpworld-ap-*.service / dpworld-ap-apply.sh / dpworld-hostapd-ap0 / dpworld-udhcpd-ap0 |
|||
dpworld-net-recover.service |
|||
docs/ architecture, DEPLOY, 가이드, |
|||
dpworldapp_schema_handoff/(데이터 계약+스키마), config-spec/ |
|||
scripts/deploy.ps1 (참고) PC→디바이스 라이브 배포 도구 — BSP 베이킹에는 불필요 |
|||
README.md CHANGELOG.md VERSION RELEASE-NOTES.md BSP-INTEGRATION.md DELIVERABLE-MANIFEST.txt |
|||
|
|||
================================================================================ |
|||
설치 위치 (디바이스) — 샘플 recipe는 BSP-INTEGRATION.md 참조 |
|||
================================================================================ |
|||
src/ → /opt/web-configurator/src/ |
|||
deploy/web-configurator.service → systemd 유닛 디렉토리 |
|||
deploy/dpworld-*.service + *.d/ → systemd 유닛 디렉토리 (제품이 사용하는 것만) |
|||
deploy/dpworld-*.sh / *.conf → 각 유닛 ExecStart 가 가리키는 경로 |
|||
deploy/nginx.conf → optional nginx 사이트 설정 디렉토리 (제품에서 사용할 때만) |
|||
enable: web-configurator.service (+ 필요한 dpworld-* 유닛) |
|||
|
|||
================================================================================ |
|||
런타임 요건 |
|||
================================================================================ |
|||
인터프리터 : python3 (stdlib 전용 — pip 패키지 없음) |
|||
리슨 : PORT 9090 (유닛 env로 변경 가능) |
|||
DB : /home/root/db/dynamic_data.db (env DB_PATH) |
|||
로그 : /opt/log/dpworldapp (env LOG_DIR) |
|||
쓰기 경로 : /opt/web-configurator /opt/log/dpworldapp /opt/fw_staging |
|||
/opt/fw_upload /opt/config_backups /home/root/db /home/root/network |
|||
메모리 상한 : MemoryMax=128M (유닛에 설정됨; v1.11.16에서 48M→128M 상향) |
|||
|
|||
nginx optional: deploy/nginx.conf 는 :80 reverse proxy가 필요한 BSP용 sample입니다. |
|||
scripts/deploy.ps1은 nginx를 설치/수정하지 않으며, 앱을 :9090으로 직접 접근하면 없어도 됩니다. |
|||
sample을 사용할 경우 proxy_pass 를 앱 포트(:9090)로 맞추세요. |
|||
|
|||
================================================================================ |
|||
전제 (디바이스 firmware 제공 — 이 패키지에 미포함) |
|||
================================================================================ |
|||
base 네트워크-적용 유닛은 firmware-native 입니다 (이 패키지에 없음): |
|||
dpworld-network-apply.service / dpworld-network-seed.service |
|||
(둘 다 /usr/bin/dpworld-network-apply.sh 를 호출함) |
|||
→ 권장(BSP 베이킹): 본 패키지의 동봉 하드닝본(dpworld-network-apply-hardened.sh) 내용을 |
|||
/usr/bin/dpworld-network-apply.sh 원본 이름으로 직접 교체 설치 (§7 ①). base 유닛이 그 경로를 |
|||
호출하므로 자동 적용. 펌웨어 원본 교체 불가한 라이브 디바이스는 .service.d/ override 드롭인 fallback (§7 ②). |
|||
dpworldapp(텔레메트리 바이너리)는 firmware-native. nginx는 제품 이미지가 :80 프록시를 제공할 때만 필요. |
|||
포함된 .sh: dpworld-ap-apply.sh, dpworld-network-apply-hardened.sh (자세히 BSP-INTEGRATION.md §7 + docs/firmware-boot-hardening.md) |
|||
|
|||
================================================================================ |
|||
미포함 (내부 전용) |
|||
================================================================================ |
|||
개발 커밋 히스토리, 내부 분석/설계 노트, 개발/감사 스크립트, VCS·에디터 설정, |
|||
테스트 스위트, 샘플 import config, 프런트 테스트 도구(package.json). 패키지에는 |
|||
런타임 앱·디바이스 통합 유닛·파트너용 문서만 포함됩니다. |
|||
@ -0,0 +1,165 @@ |
|||
# IoT Web Configurator |
|||
|
|||
> **파트너 전달 안내 (한글)** — 먼저 이 순서로 보세요: |
|||
> [`RELEASE-NOTES.md`](RELEASE-NOTES.md) (이번 버전·변경·주의·배포) → |
|||
> [`BSP-INTEGRATION.md`](BSP-INTEGRATION.md) (설치 경로·샘플 recipe·의존성) → |
|||
> [`DELIVERABLE-MANIFEST.txt`](DELIVERABLE-MANIFEST.txt) (패키지 구성). |
|||
|
|||
IoT 디바이스 설정을 위한 웹 인터페이스를 제공하는 경량 Python stdlib HTTP 서버입니다. |
|||
원래 Java Spring Boot 애플리케이션이었으나, 순수 Python(stdlib만 사용)으로 재작성되어 |
|||
메모리 사용량이 ~436 MB에서 10–30 MB로 줄었습니다. 모든 설정은 디바이스 내 |
|||
SQLite 데이터베이스(`board_config` 테이블)에 저장됩니다. |
|||
|
|||
--- |
|||
|
|||
## 저장소 구조 |
|||
|
|||
| 경로 | 설명 | |
|||
|---|---| |
|||
| `src/` | 애플리케이션 소스 — 서버, DB 매니저, 유효성 검사기, 로그 매니저, 네트워크 적용 엔진(`src/network/`), 펌웨어 OTA(`src/firmware/`), Wi-Fi AP(`src/network/ap_*`), 프론트엔드 SPA(`src/static/js/pages/` 하위 ~18개 페이지 모듈) | |
|||
| `docs/` | 설계 문서, 사양서, 운영 가이드 (`docs/DEPLOY.md` 참조) | |
|||
| `scripts/` | 라이브 배포 도구 (`deploy.ps1`) | |
|||
| `CHANGELOG.md` | 버전 변경 이력 | |
|||
|
|||
--- |
|||
|
|||
## 로컬 실행 |
|||
|
|||
```bash |
|||
# From the repo root |
|||
python src/server.py |
|||
``` |
|||
|
|||
서버는 기본적으로 `0.0.0.0:8080`에 바인딩됩니다 (로컬 개발용). 환경 변수로 재정의할 수 있습니다: |
|||
|
|||
| 변수 | 기본값 | 설명 | |
|||
|---|---|---| |
|||
| `HOST` | `0.0.0.0` | 바인딩 주소 | |
|||
| `PORT` | `8080` | 수신 포트 | |
|||
| `DB_PATH` | `/home/root/db/dynamic_data.db` | SQLite 데이터베이스 경로 | |
|||
| `LOG_DIR` | `/opt/log/dpworldapp` | 로그 파일 디렉터리 | |
|||
|
|||
> **포트 안내:** `8080`은 로컬 개발 환경에서만 사용하는 기본값입니다. 디바이스에서는 |
|||
> systemd 유닛(`deploy/web-configurator.service`)이 `PORT=9090`으로 설정하므로, |
|||
> 배포된 configurator는 **9090** 포트에서 수신합니다 — [docs/DEPLOY.md](docs/DEPLOY.md) §7 참조. |
|||
|
|||
예시: |
|||
|
|||
```bash |
|||
PORT=8888 DB_PATH=./dev_data.db python src/server.py |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 배포 |
|||
|
|||
디바이스 배포는 `scripts/deploy.ps1` (PowerShell, Windows 개발 호스트)을 사용합니다. |
|||
전체 절차는 **[docs/DEPLOY.md](docs/DEPLOY.md)** 를 참조하세요. 다음 내용을 다룹니다: |
|||
|
|||
- 버전 정책(`vMAJOR.MINOR.PATCH`) 및 릴리즈 절차 |
|||
- 사전 요건 및 사용 방법 |
|||
- 스크립트 동작 순서 (아카이브 → 전송 → 백업 → 교체 → 버전 스탬프 → 재시작 → 검증) |
|||
- 배포 검증 및 디바이스에서 `DEPLOYED_VERSION` 확인 |
|||
- 롤백 (`-Rollback` 플래그) |
|||
- 디바이스 아키텍처 (포트, 경로, 서비스 이름) |
|||
- 문제 해결 |
|||
|
|||
빠른 참조: |
|||
|
|||
```powershell |
|||
.\scripts\deploy.ps1 192.168.55.56 # deploy a tagged release |
|||
.\scripts\deploy.ps1 192.168.55.56 -Rollback # restore last backup |
|||
``` |
|||
|
|||
`deploy.ps1`은 nginx를 설치하거나 수정하지 않습니다. `deploy/nginx.conf`는 |
|||
`:80` 리버스 프록시를 통해 앱을 노출하는 제품을 위한 선택적 BSP 샘플일 뿐이며, |
|||
디바이스는 `:9090`으로 앱에 직접 접근할 수도 있습니다. |
|||
|
|||
--- |
|||
|
|||
## API 엔드포인트 |
|||
|
|||
### 디바이스 설정 |
|||
|
|||
| 메서드 | 경로 | 설명 | |
|||
|---|---|---| |
|||
| `GET` | `/setting/get-device` | 디바이스 설정 읽기 (비어있으면 204 반환) | |
|||
| `POST` | `/setting/device` | 디바이스 설정 저장 | |
|||
| `GET` | `/setting/get-protocol` | 프로토콜 설정 읽기 (비어있으면 204 반환) | |
|||
| `POST` | `/setting/protocol` | 프로토콜 설정 저장 | |
|||
| `GET` | `/api/mac` | WiFi MAC 주소 읽기 | |
|||
|
|||
### 로그 관리 |
|||
|
|||
| 메서드 | 경로 | 설명 | |
|||
|---|---|---| |
|||
| `GET` | `/setting/log-files` | 로그 파일 목록 조회 (크기, 수정 일시, 유형) | |
|||
| `GET` | `/setting/log-stats` | 로그 디렉터리 통계 | |
|||
| `POST` | `/setting/log-download` | 선택한 파일을 tar.gz로 다운로드 | |
|||
| `POST` | `/setting/log-compress` | 비활성 로그 파일 수동 압축 | |
|||
| `POST` | `/setting/log-delete` | 선택한 로그 파일 삭제 | |
|||
| `GET` | `/setting/kernel-bundle` | 커널 로그 번들 `.tar.gz` 다운로드 (journalctl 텍스트 내보내기 + `/opt/log` 커널 로그 + 부팅 이력 + pstore) — v1.1.0에서 추가 | |
|||
|
|||
### 대시보드 / 모니터링 |
|||
|
|||
| 메서드 | 경로 | 설명 | |
|||
|---|---|---| |
|||
| `GET` | `/api/health` | 서버 상태 (가동 시간, DB 백엔드) | |
|||
| `GET` | `/api/system-status` | 홈 대시보드 페이로드 — 최상위 키: `core_app`, `communication`, `network`, `system` (v1.0.x~); `hardware_modules` GNSS + WiFi 펌웨어 (v1.2.0에서 추가); `dpworldapp_status` 7단계 시작 추적기 + 5-상태 결과 + 런타임 설정 필드 6개 (v1.3.0에서 추가) | |
|||
| `GET` | `/api/support-bundle` | 진단 zip (상태 + 마스킹된 설정 + 최근 로그 + OS 진단) | |
|||
| `POST` | `/api/action/test-connections` | 설정된 서버 엔드포인트 TCP 프로브 | |
|||
| `POST` | `/api/action/restart-dpworldapp` | systemctl을 통해 dpworldapp 재시작 | |
|||
|
|||
### 네트워크 적용 엔진 (v1.6.0+) |
|||
|
|||
30초 watchdog를 사용하여 OS 상의 네트워크 설정을 적용/검증/롤백합니다. |
|||
전체 내용: **[docs/network-apply-engine-guide.md](docs/network-apply-engine-guide.md)**. |
|||
|
|||
| 메서드 | 경로 | 설명 | |
|||
|---|---|---| |
|||
| `GET` | `/api/network/state` | 라이브 인터페이스 + drift + watchdog + 최종 적용 + `country_pending` | |
|||
| `GET` | `/api/network/drift` | 전역 미적용 변경 수 (경량) | |
|||
| `GET` | `/api/network/apply/status?id=<apply_id>` | 진행 중인 적용 폴링 (confirm TTL도 이 요청으로 갱신) | |
|||
| `GET` | `/api/network/journal?limit=<n>` | 적용 저널 tail | |
|||
| `GET` | `/api/network/config` | 유효한 `net_config` 읽기 (watchdog 킬스위치) | |
|||
| `POST` | `/api/network/apply` | 네트워크 필드 적용 (또는 `dry_run`) — 비동기, `apply_id` 반환 | |
|||
| `POST` | `/api/network/apply/confirm` | TTL 내에 eth1 단절성 적용 확인 | |
|||
| `POST` | `/api/network/rollback` | 이전 적용 스냅샷으로 롤백 | |
|||
| `POST` | `/api/network/config` | `net_config` watchdog 키 쓰기 | |
|||
|
|||
### Wi-Fi AP |
|||
|
|||
현장 프로비저닝을 위한 Soft-AP(`ap0`) 관리. 전체 내용: |
|||
**docs/wifi-ap-guide.md**. |
|||
|
|||
| 메서드 | 경로 | 설명 | |
|||
|---|---|---| |
|||
| `GET` | `/api/network/ap/status` | AP 런타임 상태 | |
|||
| `POST` | `/api/network/ap/config` | `ap_config` 쓰기 | |
|||
| `POST` | `/api/network/ap/apply` | AP 설정 적용 (ap0 시작/중지) | |
|||
|
|||
### 펌웨어 OTA |
|||
|
|||
dpworldapp 펌웨어 채널을 통한 업로드 → 사전 검증 → 플래시 → 복원 확인 흐름. |
|||
전체 내용: **docs/firmware-ota-guide.md**. |
|||
|
|||
| 메서드 | 경로 | 설명 | |
|||
|---|---|---| |
|||
| `GET` | `/api/firmware/status` | 현재 OTA 상태 스냅샷 | |
|||
| `POST` | `/api/firmware/preflight` | 업로드 전 사전 검증 | |
|||
| `POST` | `/api/firmware/upload` | 펌웨어 ZIP 업로드 | |
|||
| `POST` | `/api/firmware/flash` | 플래시 실행 | |
|||
| `POST` | `/api/firmware/restore-check` | 플래시 후 복원 검증 | |
|||
|
|||
> 서브시스템 내부 구조 (상태 머신, byte-exact 렌더링, 부팅 강화)는 |
|||
> [docs/architecture.md](docs/architecture.md) 및 위에 링크된 서브시스템별 가이드에 문서화되어 있습니다. |
|||
|
|||
--- |
|||
|
|||
## 기술 스택 |
|||
|
|||
- Python 3.10 (stdlib만 사용 — 외부 의존성 없음) |
|||
- SQLite3 (기본 설정 저장소) |
|||
- 멀티스레드 HTTP 서버 (`ThreadingMixIn`) |
|||
- Vanilla ES-module SPA 프론트엔드 (빌드 단계 없음) — **Two Faces** 사용자/고급 보기 전환 기능을 포함한 ~18개 페이지 모듈 (`src/static/js/view-mode.js`) |
|||
- 주요 서브시스템: 네트워크 적용 엔진, Wi-Fi AP, 펌웨어 OTA (API 섹션 및 서브시스템별 가이드 참조) |
|||
@ -0,0 +1,101 @@ |
|||
# 릴리스 노트 — Web Configurator **v1.11.16** |
|||
|
|||
- **배포일**: 2026-06-23 |
|||
- **대상**: 디바이스 BSP 이미지 포함용 (DP World Smart Solutions · IoT 디바이스) |
|||
- **패키지**: 내부 git 태그 `v1.11.16` (커밋 히스토리가 없는 **클린 스냅샷**) |
|||
- **이전 버전**: v1.11.15 |
|||
|
|||
--- |
|||
|
|||
## 1. 한눈에 보기 |
|||
이번 릴리스는 **배포 패키지 정비 릴리스**입니다. 앱 기능·디바이스 계약·UI는 v1.11.15와 동일하며, |
|||
다음 두 가지를 정비했습니다. |
|||
|
|||
1. **MemoryMax 상향** (`48M → 128M`) — 운영 중 OOM-kill 위험 제거. |
|||
2. **BSP 통합 가이드 정비** — 네트워크-적용 하드닝 스크립트의 권장 통합 방식을 |
|||
**"펌웨어 원본 스크립트 직접 교체(BSP 베이킹)"** 를 1차 방식으로 재구성. |
|||
|
|||
- Python 3 **표준 라이브러리(stdlib)만** 사용 — 외부 pip 패키지 없음 |
|||
- systemd 유닛 포함, nginx 설정은 optional sample → **이미지에 바로 포함 가능** |
|||
- 기존 디바이스 DB는 **부팅 시 자동 변환**(1회, 멱등) — 수동 작업 불필요 |
|||
|
|||
--- |
|||
|
|||
## 2. 주요 변경 (v1.11.15 → v1.11.16) |
|||
|
|||
| 항목 | 변경 | |
|||
|---|---| |
|||
| **MemoryMax** | `web-configurator.service` 의 `MemoryMax` 를 **48M → 128M** 로 상향. 48M는 support-bundle(진단 zip, 메모리 빌드)과 UI 동시 사용 시 실측 피크 ~40-50MB로 OOM-kill 위험이 있었음. 128M = 실피크 ~2.5x 여유 + 누수 방지 캡 유지 (장비 RAM 3.4GB 대비 3.7%). | |
|||
| **BSP 통합 방식 1차 = 직접 교체** | 네트워크-적용 하드닝본을 **`/usr/bin/dpworld-network-apply.sh` 원본 이름으로 교체 설치**하는 것을 BSP 베이킹의 기본 방식으로 명시. `.service.d` override 드롭인은 **라이브 디바이스 후적용(over-the-top) fallback** 으로 강등. (자세히 `BSP-INTEGRATION.md` §7 + `docs/firmware-boot-hardening.md`) | |
|||
| 패키지 구성 정리 | 배포물을 repo 루트(flat)로 정리, 내부 전용 참조/링크 정돈. 런타임 코드 변경 없음. | |
|||
|
|||
> 앱 코드 자체는 **v1.11.15와 동일**합니다 (UI/기능 무변경). 본 릴리스는 배포 단위의 |
|||
> 메모리 정책 + 통합 가이드 정비입니다. |
|||
|
|||
--- |
|||
|
|||
## 3. 네트워크-적용 하드닝 — 통합 방식 (중요) |
|||
|
|||
디바이스 펌웨어는 다음 base 구성을 제공합니다(firmware-native): |
|||
- `dpworld-network-apply.service` / `dpworld-network-seed.service` (유닛) |
|||
- `/usr/bin/dpworld-network-apply.sh` (base 스크립트) |
|||
|
|||
이 base 스크립트에는 WiFi 국가 코드 변경 시 `modprobe -r wlan` 으로 모듈을 라이브 재로드하는 |
|||
경로가 있어, QCA6490(cnss_pci)에서 **PMU 워치독 재부팅 루프**를 유발한 이력이 있습니다. |
|||
본 패키지의 `dpworld-network-apply-hardened.sh` 가 이 문제를 제거한 **완전 대체본**입니다. |
|||
|
|||
**① 권장 — BSP 베이킹(직접 교체):** 펌웨어 이미지를 빌드하는 협력사는 |
|||
`deploy/dpworld-network-apply-hardened.sh` 의 내용을 **`/usr/bin/dpworld-network-apply.sh` |
|||
원본 이름으로 설치**(펌웨어 원본 교체)하면 됩니다. base 서비스/시드 유닛이 이미 그 경로를 |
|||
호출하므로 apply·`--boot` 모두 자동으로 하드닝 동작을 탑니다. **드롭인은 베이킹하지 않습니다.** |
|||
|
|||
**② Fallback — 라이브 후적용(override):** 이미 구워진 디바이스에 펌웨어 교체 없이 얹을 때만, |
|||
하드닝본을 별도 파일로 두고 `dpworld-network-apply.service.d/` + `dpworld-network-seed.service.d/` |
|||
드롭인으로 ExecStart를 교체합니다. (우리 개발용 `scripts/deploy.ps1` 가 이 방식을 사용) |
|||
|
|||
→ 단계별 절차는 `docs/firmware-boot-hardening.md` "펌웨어 베이킹 (BSP) — 원본 교체 방법" 참조. |
|||
|
|||
--- |
|||
|
|||
## 4. 호환성 · 주의사항 |
|||
- **디바이스 적용 시점**: dpworldapp 재시작 시 새 계약값으로 config가 재적용됩니다. |
|||
- **nginx optional**: `deploy/nginx.conf`는 BSP에서 `:80` reverse proxy를 제공할 때 쓰는 sample입니다. `scripts/deploy.ps1`는 nginx를 설치/수정하지 않으며, 디바이스가 앱을 `:9090`으로 직접 접근하면 없어도 됩니다. sample을 쓸 경우 `proxy_pass`를 앱 포트 `:9090`으로 맞추세요. |
|||
- **런타임 의존성**: python3 stdlib만 필요 (RDEPENDS 목록은 `BSP-INTEGRATION.md` 참조). 외부 패키지 없음. |
|||
- **dpworldapp**(텔레메트리 바이너리)는 firmware-native. |
|||
|
|||
--- |
|||
|
|||
## 5. 배포 방법 (BSP) |
|||
1. **패키지 입수**: 내부 git 태그 `v1.11.16` (또는 동봉 스냅샷) |
|||
2. **레시피 작성**: `BSP-INTEGRATION.md`의 샘플 bitbake recipe + 설치 경로표 사용 |
|||
- `src/` → `/opt/web-configurator/src/` |
|||
- `deploy/*.service` → systemd 유닛 디렉토리 |
|||
- `deploy/dpworld-network-apply-hardened.sh` → **`/usr/bin/dpworld-network-apply.sh`** (펌웨어 원본 교체, §3 ①) |
|||
- `deploy/nginx.conf` → optional nginx sample site config |
|||
- `web-configurator.service` enable |
|||
3. **검증**: 이미지 부팅 후 `systemctl status web-configurator` + `curl http://127.0.0.1:9090/` |
|||
|
|||
--- |
|||
|
|||
## 6. 품질/검증 |
|||
- v1.11.16 변경은 **앱 로직 무변경** — systemd 서비스 메모리 캡(MemoryMax) + 문서/주석/버전 문자열뿐입니다. |
|||
기능 동등 기준선 **v1.11.15**는 내부 테스트 스위트(Python + 프런트 `.mjs`)로 검증되었습니다. |
|||
- 테스트 스위트는 내부 저장소에서 관리되며 **본 배포물에는 포함되지 않습니다**. |
|||
- 패키지는 **커밋 히스토리 없음**(`git archive` 스냅샷) + 내부 분석/개발 자료 제외 (`DELIVERABLE-MANIFEST.txt` 참조). |
|||
|
|||
--- |
|||
|
|||
## 7. 롤백 |
|||
- BSP recipe의 버전 핀(태그/SRCREV)을 **v1.11.15**로 되돌리면 이전 버전 배포. |
|||
- 마이그레이션은 **멱등 + 변경 전 자동 백업**(config_safety)으로 보호됩니다. |
|||
|
|||
--- |
|||
|
|||
## 버전 이력 (요약) |
|||
| 버전 | 일자 | 요약 | |
|||
|---|---|---| |
|||
| **v1.11.16** | 2026-06-23 | MemoryMax 48M→128M + BSP 베이킹(직접 교체) 가이드 1차 정비 (앱 기능 무변경) | |
|||
| v1.11.15 | 2026-06-23 | 사이드바 System 그룹 monitor 아이콘 정리 | |
|||
| v1.11.14 | 2026-06-23 | 사이드바 부모 그룹(System=Dashboard+Log / Firmware) 재편 | |
|||
| v1.11.11 | 2026-06-23 | config 쓰기 경로를 디바이스 config-reader 계약에 정렬 + 자동 마이그레이션 | |
|||
| v1.11.10 | 2026-06-20 | 전체 코드리뷰 fix (37건) | |
|||
@ -0,0 +1 @@ |
|||
v1.11.16 |
|||
@ -0,0 +1,10 @@ |
|||
# enable 하지 않음 — web-configurator 엔진이 on-demand로 start (no [Install]) |
|||
[Unit] |
|||
Description=Apply DPWorld WiFi AP configuration (on-demand) |
|||
After=local-fs.target |
|||
RequiresMountsFor=/home/root /opt |
|||
|
|||
[Service] |
|||
Type=oneshot |
|||
ExecStart=/usr/bin/dpworld-ap-apply.sh |
|||
TimeoutStartSec=90 |
|||
@ -0,0 +1,89 @@ |
|||
#!/bin/sh |
|||
# dpworld-ap-apply.sh — WiFi AP bring-up/down (spec §8). STA(wlan0) 무접촉, ap0 전용. |
|||
# 호출: --boot(seed) | (무인자, on-demand). ap-enabled 마커로 enable/disable 판정. |
|||
set -u |
|||
AP_DIR="/home/root/network/ap" |
|||
HOSTAPD_CONF="${AP_DIR}/hostapd-ap0.conf" |
|||
UDHCPD_CONF="${AP_DIR}/udhcpd-ap0.conf" |
|||
ENABLED_MARK="${AP_DIR}/ap-enabled" |
|||
STATE_DIR="/opt/dpworld-network" |
|||
LOG="${STATE_DIR}/ap_apply.log" |
|||
WLAN_MAC_FILE="/sys/class/net/wlan0/address" |
|||
|
|||
log() { echo "[$(date '+%F %T' 2>/dev/null) up=$(cut -d' ' -f1 /proc/uptime 2>/dev/null)] $*" >&2 |
|||
[ -d "$STATE_DIR" ] && echo "$*" >> "$LOG" 2>/dev/null || true; } |
|||
run() { "$@"; rc=$?; [ "$rc" -ne 0 ] && log " cmd rc=$rc: $*"; return "$rc"; } |
|||
|
|||
ap_mac() { # wlan0 MAC의 첫 옥텟에 local-admin bit(0x02) 세팅 → 별도 MAC |
|||
base=$(cat "$WLAN_MAC_FILE" 2>/dev/null); [ -n "$base" ] || { echo ""; return; } |
|||
o1=$(echo "$base" | cut -d: -f1); rest=$(echo "$base" | cut -d: -f2-) |
|||
[ -n "$o1" ] || { echo ""; return; } # 드라이버 리로드 중 빈 옥텟 → 산술오류 방지 |
|||
new=$(printf '%02x' $(( 0x$o1 | 0x02 ))) |
|||
echo "${new}:${rest}" |
|||
} |
|||
|
|||
# §8.1 ap0 방화벽 — 전용 chain(cleanup=flush+delete로 잔재/중복 원천 차단). |
|||
# INPUT(장치 자체): v1.11.7 — ap0 클라이언트에 장치 서비스 전부 개방(SSH 22 포함). WPA2 PSK가 접근 게이트. |
|||
# (이전 default-deny[9090/67/icmp만 허용, 8989·8990·SSH 차단]에서 전환 — 현장 관리 접근성 우선, 비번 강화 전제.) |
|||
# FORWARD(경유): ip_forward=1일 때 AP 클라이언트가 PLC(eth1)·업링크로 라우팅하는 것을 전면 차단(provisioning 격리) — 유지. |
|||
# ★ INPUT은 열되 FORWARD 격리는 유지 → AP 클라이언트는 장치 자체엔 접근 가능하나 내부망/PLC로는 경유 불가. |
|||
AP_IN_CHAIN="DPWORLD_AP_IN" |
|||
AP_FWD_CHAIN="DPWORLD_AP_FWD" |
|||
|
|||
fw_clear() { # unhook(점프 전부 drain) → flush → delete chain. 멱등 + 중복 점프(비정상/크래시 잔재)까지 제거. |
|||
while iptables -D INPUT -i ap0 -j "$AP_IN_CHAIN" 2>/dev/null; do :; done |
|||
while iptables -D FORWARD -i ap0 -j "$AP_FWD_CHAIN" 2>/dev/null; do :; done |
|||
while iptables -D FORWARD -o ap0 -j "$AP_FWD_CHAIN" 2>/dev/null; do :; done |
|||
iptables -F "$AP_IN_CHAIN" 2>/dev/null |
|||
iptables -F "$AP_FWD_CHAIN" 2>/dev/null |
|||
iptables -X "$AP_IN_CHAIN" 2>/dev/null |
|||
iptables -X "$AP_FWD_CHAIN" 2>/dev/null |
|||
} |
|||
fw_apply() { # 전용 chain 재생성 + INPUT/FORWARD에서 ap0 트래픽을 chain으로 점프. ap0 한정 → STA/eth 무영향. |
|||
fw_clear |
|||
# INPUT(장치 자체): ap0 클라이언트에 전체 개방(SSH 포함) — WPA2 PSK가 접근 게이트(v1.11.7). |
|||
run iptables -N "$AP_IN_CHAIN" || { fw_clear; return 1; } |
|||
run iptables -A "$AP_IN_CHAIN" -j ACCEPT || { fw_clear; return 1; } |
|||
run iptables -A INPUT -i ap0 -j "$AP_IN_CHAIN" || { fw_clear; return 1; } |
|||
# FORWARD(경유): PLC(eth1)/업링크 라우팅 전면 차단 — provisioning 격리 유지(INPUT 개방과 독립). |
|||
run iptables -N "$AP_FWD_CHAIN" || { fw_clear; return 1; } |
|||
run iptables -A "$AP_FWD_CHAIN" -j DROP || { fw_clear; return 1; } |
|||
run iptables -A FORWARD -i ap0 -j "$AP_FWD_CHAIN" || { fw_clear; return 1; } |
|||
run iptables -A FORWARD -o ap0 -j "$AP_FWD_CHAIN" || { fw_clear; return 1; } |
|||
} |
|||
|
|||
ap_down() { # 멱등 정리 (stop은 미기동 시 routine 실패 → run 미사용, ap_up과 일치) |
|||
systemctl stop dpworld-udhcpd-ap0.service 2>/dev/null |
|||
systemctl stop dpworld-hostapd-ap0.service 2>/dev/null |
|||
fw_clear |
|||
ip addr flush dev ap0 2>/dev/null |
|||
iw dev ap0 del 2>/dev/null |
|||
log "AP down (cleaned)" |
|||
} |
|||
|
|||
ap_up() { |
|||
[ -f "$HOSTAPD_CONF" ] || { log "no hostapd conf — skip"; return 0; } |
|||
# RECONCILE: 기존 데몬/방화벽 정리, vif는 재사용(없을 때만 add) |
|||
systemctl stop dpworld-udhcpd-ap0.service 2>/dev/null |
|||
systemctl stop dpworld-hostapd-ap0.service 2>/dev/null |
|||
fw_clear |
|||
iw dev ap0 info >/dev/null 2>&1 || run iw dev wlan0 interface add ap0 type __ap || return 1 |
|||
mac=$(ap_mac); [ -n "$mac" ] && run ip link set ap0 address "$mac" |
|||
ap_ip=$(sed -n 's/^opt router \([0-9.]*\).*/\1/p' "$UDHCPD_CONF" 2>/dev/null) |
|||
if [ -n "$ap_ip" ]; then run ip addr replace "${ap_ip}/24" dev ap0 || return 1 # replace=멱등 |
|||
else log "no udhcpd conf/router — fail closed"; return 1; fi |
|||
run ip link set ap0 up || return 1 |
|||
if ! fw_apply; then |
|||
log "AP firewall setup failed" |
|||
ap_down |
|||
return 1 |
|||
fi |
|||
touch /run/udhcpd-ap0.leases 2>/dev/null |
|||
run systemctl restart dpworld-hostapd-ap0.service || return 1 |
|||
run systemctl restart dpworld-udhcpd-ap0.service || return 1 |
|||
log "AP up (ssid rendered, ap0 up)" |
|||
} |
|||
|
|||
mkdir -p "$STATE_DIR" 2>/dev/null || true |
|||
if [ -f "$ENABLED_MARK" ]; then ap_up; rc=$?; else ap_down; rc=$?; fi |
|||
exit "$rc" |
|||
@ -0,0 +1,12 @@ |
|||
[Unit] |
|||
Description=Bring up DPWorld WiFi AP at boot (reconcile) |
|||
After=local-fs.target wpa_supplicant@wlan0.service |
|||
RequiresMountsFor=/home/root /opt |
|||
|
|||
[Service] |
|||
Type=oneshot |
|||
ExecStart=/usr/bin/dpworld-ap-apply.sh --boot |
|||
TimeoutStartSec=120 |
|||
|
|||
[Install] |
|||
WantedBy=multi-user.target |
|||
@ -0,0 +1,10 @@ |
|||
[Unit] |
|||
Description=DPWorld hostapd on ap0 |
|||
StartLimitIntervalSec=60 |
|||
StartLimitBurst=5 |
|||
|
|||
[Service] |
|||
Type=simple |
|||
ExecStart=/usr/sbin/hostapd /home/root/network/ap/hostapd-ap0.conf -P /run/hostapd-ap0.pid |
|||
Restart=on-failure |
|||
RestartSec=3 |
|||
@ -0,0 +1,11 @@ |
|||
# deploy/dpworld-net-recover.service |
|||
# v1.6.0 (spec §7): apply.sh 의 wlan 모듈 완전 다운 사각지대 복구. |
|||
# 웹/watchdog 이 systemctl start 로만 호출 — web-configurator 샌드박스의 |
|||
# ProtectKernelModules=yes 를 유지하기 위해 modprobe 를 이 유닛에 위임. |
|||
# modprobe -r(제거) 는 의도적으로 미포함 — 과거 ExecStopPost=modprobe -r 사고 재발 방지. |
|||
[Unit] |
|||
Description=DP World network recovery (wlan module + wpa restart) |
|||
|
|||
[Service] |
|||
Type=oneshot |
|||
ExecStart=/bin/sh -c 'modprobe wlan_cnss_core_pcie; modprobe wlan; systemctl try-restart wpa_supplicant@wlan0.service' |
|||
@ -0,0 +1,365 @@ |
|||
#!/bin/sh |
|||
# dpworld-network-apply.sh — firmware-safe network apply script |
|||
# --------------------------------------------------------------------------- |
|||
# 목적: |
|||
# dpworldapp 또는 WebConfigurator가 /home/root/network/에 생성한 네트워크 |
|||
# 렌더 파일을 systemd-networkd, wpa_supplicant, WiFi country 설정에 |
|||
# 동기화한다. |
|||
# |
|||
# 배포 위치: |
|||
# firmware image: /usr/bin/dpworld-network-apply.sh |
|||
# file mode: root:root, 0755, LF, POSIX /bin/sh |
|||
# |
|||
# 호출: |
|||
# dpworld-network-apply.sh |
|||
# on-demand 적용. /run 동기화 후 networkctl reload/reconfigure, |
|||
# WPA 변경 시 wpa_cli reconfigure를 수행한다. |
|||
# |
|||
# dpworld-network-apply.sh --boot |
|||
# 부팅 seed. /home/root/network가 비어 있으면 |
|||
# /etc/dpworld/network-defaults를 복사하고, /run/systemd/network를 |
|||
# 먼저 동기화한다. networkd가 이미 active인 boot-order race에서는 |
|||
# live address reconcile까지 수행한다. |
|||
# |
|||
# dpworld-network-apply.sh --country-now |
|||
# 호환 인자. 이 빌드 제안본에서는 live module unload를 하지 않는다. |
|||
# country는 modprobe.d에 기록하고 reboot-required marker로 처리한다. |
|||
# |
|||
# 안전 정책: |
|||
# - 실행 가능한 modprobe -r 경로를 두지 않는다. QCA6490/cnss_pci에서 |
|||
# module unload가 hang과 watchdog reset으로 이어진 이력이 있기 때문이다. |
|||
# - country 변경은 reboot-deferred가 기본이다. |
|||
# - reboot-required 판단은 wpa_cli get country가 아니라 |
|||
# /sys/module/wlan/parameters/country_code 또는 iw reg get 기준으로 한다. |
|||
# - set -e를 쓰지 않는다. 실패한 외부 명령은 로그에 남기고 가능한 후속 |
|||
# 처리를 계속한다. |
|||
# - 기존 렌더 파일이 있으면 /etc/dpworld/network-defaults로 덮어쓰지 않는다. |
|||
# |
|||
# 상태 파일: |
|||
# /opt/dpworld-network/apply.log 적용 로그. 512KB 초과 시 apply.log.1로 자체 rotate |
|||
# /opt/dpworld-network/applied/ 마지막 적용 기준선 |
|||
# /opt/dpworld-network/reboot-required country 재부팅 필요 marker |
|||
# /run/dpworld-network/apply.lock 중복 실행 방지 lock |
|||
# /run/dpworld-network/country-now 호환 marker |
|||
# |
|||
# 한계: |
|||
# eth1 IP 변경 후 confirm/rollback 같은 세션 보존 로직은 이 셸 스크립트가 |
|||
# 담당하지 않는다. WebConfigurator apply engine이 있으면 그쪽에서 처리한다. |
|||
# standalone으로 eth1 주소를 바꿀 때는 콘솔 또는 대체 접속 경로를 확보한다. |
|||
# |
|||
# POSIX /bin/sh (BusyBox ash). bashism 금지. |
|||
set -u |
|||
|
|||
# ─────────────────────────────── 인자 ──────────────────────────────── |
|||
MODE_BOOT=0 |
|||
COUNTRY_NOW=0 |
|||
for a in "$@"; do |
|||
case "$a" in |
|||
--boot) MODE_BOOT=1 ;; |
|||
--country-now) COUNTRY_NOW=1 ;; |
|||
esac |
|||
done |
|||
MODE_NAME=$([ "$MODE_BOOT" -eq 1 ] && echo boot || echo ondemand) |
|||
|
|||
# ─────────────────────────────── 경로 ──────────────────────────────── |
|||
NETWORK_DIR="/home/root/network" |
|||
DEFAULT_DIR="/etc/dpworld/network-defaults" |
|||
RUNTIME_DIR="/run/systemd/network" |
|||
MODPROBE_CONF="/etc/modprobe.d/10-wlan.conf" |
|||
|
|||
STATE_DIR="/opt/dpworld-network" # 영속(/opt, After=local-fs.target 보장) — shadow/로그/마커 |
|||
SHADOW_DIR="${STATE_DIR}/applied" |
|||
LOG_FILE="${STATE_DIR}/apply.log" |
|||
REBOOT_MARK="${STATE_DIR}/reboot-required" |
|||
|
|||
RUN_STATE_DIR="/run/dpworld-network" # tmpfs — 락/일회성 마커 |
|||
LOCK_DIR="${RUN_STATE_DIR}/apply.lock" |
|||
LOCK_PID="${LOCK_DIR}/pid" |
|||
COUNTRY_NOW_MARK="${RUN_STATE_DIR}/country-now" # 엔진 호환: 있으면 country-now |
|||
|
|||
NET_RENDERS="10-wlan0.network 10-eth0.network 10-eth1.network" |
|||
WPA_RENDER="${NETWORK_DIR}/wpa_supplicant-wlan0.conf" |
|||
COUNTRY_RENDER="${NETWORK_DIR}/wifi-country-code" |
|||
PERSIST_JSON="${NETWORK_DIR}/network_config.json" |
|||
ROOTFS_MIN_KB=20480 |
|||
LOG_MAX_BYTES=524288 |
|||
|
|||
# 엔진 마커가 있으면 country-now 로 승격 (공존 호환) |
|||
[ -f "$COUNTRY_NOW_MARK" ] && COUNTRY_NOW=1 |
|||
|
|||
# ──────────────────────────── 로깅 / 실행 ───────────────────────────── |
|||
log() { |
|||
up=$(cut -d' ' -f1 /proc/uptime 2>/dev/null || echo '?') # RTC 불신뢰 → uptime 병기 |
|||
line="[$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null) up=${up}s ${MODE_NAME}] $*" |
|||
echo "$line" >&2 |
|||
if [ -d "$STATE_DIR" ]; then |
|||
if [ -f "$LOG_FILE" ] && [ "$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)" -gt "$LOG_MAX_BYTES" ]; then |
|||
mv -f "$LOG_FILE" "${LOG_FILE}.1" 2>/dev/null || true |
|||
fi |
|||
echo "$line" >> "$LOG_FILE" 2>/dev/null || true |
|||
fi |
|||
} |
|||
run() { # 외부 명령 + rc 로깅. 절대 스크립트를 중단시키지 않음. |
|||
"$@"; rc=$? |
|||
[ "$rc" -ne 0 ] && log " cmd rc=${rc}: $*" |
|||
return "$rc" |
|||
} |
|||
|
|||
seed_defaults_if_empty() { |
|||
if ! run mkdir -p "$NETWORK_DIR"; then |
|||
log " defaults: cannot create ${NETWORK_DIR} - skip" |
|||
return 0 |
|||
fi |
|||
|
|||
if find "$NETWORK_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | read -r _; then |
|||
return 0 |
|||
fi |
|||
|
|||
if [ ! -d "$DEFAULT_DIR" ]; then |
|||
log " defaults: ${NETWORK_DIR} empty but ${DEFAULT_DIR} missing - skip" |
|||
return 0 |
|||
fi |
|||
|
|||
if run cp -a "${DEFAULT_DIR}/." "$NETWORK_DIR/"; then |
|||
log " defaults: seeded ${NETWORK_DIR} from ${DEFAULT_DIR}" |
|||
else |
|||
log " defaults: failed to seed ${NETWORK_DIR} from ${DEFAULT_DIR}" |
|||
fi |
|||
} |
|||
|
|||
# ──────────────────────────── 헬퍼 ─────────────────────────────────── |
|||
runtime_target() { |
|||
case "$1" in |
|||
10-wlan0.network) echo "${RUNTIME_DIR}/05-dpworld-wlan0.network" ;; |
|||
10-eth0.network) echo "${RUNTIME_DIR}/05-dpworld-eth0.network" ;; |
|||
10-eth1.network) echo "${RUNTIME_DIR}/05-dpworld-eth1.network" ;; |
|||
esac |
|||
} |
|||
iface_of() { |
|||
case "$1" in |
|||
10-wlan0.network) echo wlan0 ;; |
|||
10-eth0.network) echo eth0 ;; |
|||
10-eth1.network) echo eth1 ;; |
|||
esac |
|||
} |
|||
render_addr() { # 렌더 파일의 static Address (없으면 빈값=DHCP) |
|||
sed -n 's#^[[:space:]]*Address[[:space:]]*=[[:space:]]*\([0-9][0-9.]*\)/.*#\1#p' "$1" 2>/dev/null | head -n1 |
|||
} |
|||
live_addr() { |
|||
ip -4 -o addr show dev "$1" 2>/dev/null | awk '{split($4,a,"/"); print a[1]}' | head -n1 |
|||
} |
|||
desired_country() { |
|||
cc="" |
|||
[ -r "$COUNTRY_RENDER" ] && cc=$(tr -d ' \r\n\t' < "$COUNTRY_RENDER" 2>/dev/null | tr 'a-z' 'A-Z') |
|||
if [ -z "$cc" ] && [ -r "$PERSIST_JSON" ]; then |
|||
cc=$(sed -n 's/.*"country_code"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PERSIST_JSON" 2>/dev/null \ |
|||
| head -n1 | tr -d ' \r\n\t' | tr 'a-z' 'A-Z') |
|||
fi |
|||
echo "$cc" |
|||
} |
|||
valid_cc() { case "$1" in [A-Z][A-Z]) return 0 ;; *) return 1 ;; esac; } |
|||
modprobe_country() { |
|||
[ -r "$MODPROBE_CONF" ] || return 0 |
|||
sed -n 's/^[[:space:]]*options[[:space:]]\+wlan[[:space:]]\+country_code=\([A-Za-z]\{2\}\).*$/\1/p' \ |
|||
"$MODPROBE_CONF" 2>/dev/null | head -n1 | tr 'a-z' 'A-Z' |
|||
} |
|||
live_country() { # 라디오 실효 country (ground truth). 모듈 로드 파라미터 우선, iw reg 폴백. |
|||
# ※ wpa_cli get country 는 wpa conf 렌더값일 뿐 라디오 실효값이 아님 → 사용 금지(거짓 양성: 오류②). |
|||
c="" |
|||
[ -r /sys/module/wlan/parameters/country_code ] && \ |
|||
c=$(tr -d ' \r\n\t' < /sys/module/wlan/parameters/country_code 2>/dev/null | tr 'a-z' 'A-Z') |
|||
if { [ -z "$c" ] || [ "$c" = "00" ] || [ "$c" = "(NULL)" ]; } && command -v iw >/dev/null 2>&1; then |
|||
c=$(iw reg get 2>/dev/null | sed -n 's/^country \([A-Z][A-Z]\):.*/\1/p' | grep -v '^00$' | head -n1) |
|||
fi |
|||
echo "$c" |
|||
} |
|||
|
|||
note_country_now_deferred() { |
|||
log " country-now requested, but live module unload is disabled on this hardware; reboot-required 유지" |
|||
rm -f "$COUNTRY_NOW_MARK" 2>/dev/null || true |
|||
} |
|||
|
|||
# ──────────────────────── 락 (mkdir 원자 + PID stale 회수) ───────────── |
|||
acquire_lock() { |
|||
i=0 |
|||
while [ "$i" -lt 12 ]; do |
|||
if mkdir "$LOCK_DIR" 2>/dev/null; then |
|||
echo "$$" > "$LOCK_PID" 2>/dev/null || true |
|||
return 0 |
|||
fi |
|||
holder=$(cat "$LOCK_PID" 2>/dev/null || echo "") |
|||
if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then |
|||
log "stale lock (pid ${holder} dead) — 회수" |
|||
rm -f "$LOCK_PID" 2>/dev/null; rmdir "$LOCK_DIR" 2>/dev/null |
|||
continue # 즉시 재시도 (i 증가 없음) |
|||
fi |
|||
i=$((i+1)); sleep 1 |
|||
done |
|||
return 1 |
|||
} |
|||
|
|||
mkdir -p "$RUN_STATE_DIR" 2>/dev/null || true |
|||
if ! acquire_lock; then |
|||
log "다른 apply 실행 중 — 락 획득 실패. 멱등하므로 종료(0)." |
|||
exit 0 |
|||
fi |
|||
cleanup() { rm -rf "$LOCK_DIR" 2>/dev/null || true; } |
|||
trap cleanup EXIT INT TERM |
|||
|
|||
mkdir -p "$SHADOW_DIR" 2>/dev/null || true |
|||
log "START (boot=${MODE_BOOT} country_now=${COUNTRY_NOW})" |
|||
|
|||
seed_defaults_if_empty |
|||
|
|||
# ──────── 1) /run 채우기 (양 모드) — tmpfs 는 부팅마다 비므로 항상 동기화 ──────── |
|||
NETWORKD_DIRTY=0 |
|||
CHANGED_IFACES="" |
|||
install -d "$RUNTIME_DIR" 2>/dev/null || true |
|||
|
|||
for stale in 10-wlan0.network 10-eth0.network 10-eth1.network; do # 펌웨어식 잔여 이름 제거 |
|||
[ -e "${RUNTIME_DIR}/${stale}" ] && { run rm -f "${RUNTIME_DIR}/${stale}"; NETWORKD_DIRTY=1; } |
|||
done |
|||
|
|||
for r in $NET_RENDERS; do |
|||
src="${NETWORK_DIR}/${r}"; tgt=$(runtime_target "$r"); ifc=$(iface_of "$r") |
|||
if [ -f "$src" ]; then |
|||
if [ ! -f "$tgt" ] || ! cmp -s "$src" "$tgt"; then |
|||
if run install -m 0644 "$src" "$tgt"; then |
|||
NETWORKD_DIRTY=1; CHANGED_IFACES="${CHANGED_IFACES} ${ifc}" |
|||
log " runtime updated: ${tgt}" |
|||
fi |
|||
fi |
|||
elif [ -e "$tgt" ]; then |
|||
run rm -f "$tgt"; NETWORKD_DIRTY=1; CHANGED_IFACES="${CHANGED_IFACES} ${ifc}" |
|||
log " runtime removed (source gone): ${tgt}" |
|||
fi |
|||
done |
|||
|
|||
# ──────── 2) wpa 변경 감지 (shadow 기준 — /run 사본이 없는 유일 파일) P1 ──────── |
|||
WPA_CHANGED=0 |
|||
if [ -f "$WPA_RENDER" ]; then |
|||
if [ ! -f "${SHADOW_DIR}/wpa_supplicant-wlan0.conf" ] || \ |
|||
! cmp -s "$WPA_RENDER" "${SHADOW_DIR}/wpa_supplicant-wlan0.conf"; then |
|||
WPA_CHANGED=1 |
|||
log " wpa render changed (SSID/PW/security/country header)" |
|||
fi |
|||
fi |
|||
|
|||
# ──────── 3) country: modprobe.d 동기화 + 라디오 실효 기준 reboot 판정 (P2 / 오류①② 修) ──────── |
|||
# 오류①: wlan 모듈은 부팅 초기(modules-load)에 로드되고 seed 는 그 뒤에 modprobe.d 를 갱신 → |
|||
# country 변경이 그 부팅엔 반영 안 됨(라디오는 옛값). 다음 부팅에야 적용. |
|||
# 오류②: 적용 검증을 wpa conf 값(wpa_cli)으로 하면 거짓 양성 → 반드시 라디오 실효값(live_country)으로. |
|||
DC=$(desired_country) |
|||
if [ -n "$DC" ] && valid_cc "$DC"; then |
|||
CUR=$(modprobe_country) |
|||
# (a) modprobe.d 를 목표로 동기화 (다를 때만). 런타임 modprobe -r 없음(deferred). |
|||
if [ "$DC" != "$CUR" ]; then |
|||
rootfs_free=$(df -k / 2>/dev/null | awk 'END{print $4}'); rootfs_free=${rootfs_free:-0} |
|||
if [ "$rootfs_free" -lt "$ROOTFS_MIN_KB" ]; then |
|||
log " country: rootfs 여유 ${rootfs_free}KB < ${ROOTFS_MIN_KB}KB — modprobe.d 쓰기 보류 (P6)" |
|||
elif printf 'options wlan country_code=%s\n' "$DC" > "${MODPROBE_CONF}.tmp.$$" 2>/dev/null \ |
|||
&& mv -f "${MODPROBE_CONF}.tmp.$$" "$MODPROBE_CONF" 2>/dev/null; then |
|||
log " country: modprobe.d ${CUR:-none} -> ${DC}" |
|||
else |
|||
rm -f "${MODPROBE_CONF}.tmp.$$" 2>/dev/null || true |
|||
log " country: modprobe.d 쓰기 실패 (disk?) — 변경 보류" |
|||
fi |
|||
fi |
|||
# (b) reboot-required 판정은 "라디오 실효 country" 기준 (모듈이 실제 무엇으로 떠있나). |
|||
# modprobe.d 가 목표와 같아도 라디오가 다르면(=아직 그 값으로 모듈이 안 올라옴) 재부팅 필요. |
|||
LC=$(live_country) |
|||
if [ -n "$LC" ] && [ "$LC" = "$DC" ]; then |
|||
[ -f "$REBOOT_MARK" ] && { rm -f "$REBOOT_MARK" 2>/dev/null; log " country: 라디오 실효=${LC} == 목표 — reboot-required 해제"; } |
|||
else |
|||
: > "$REBOOT_MARK" 2>/dev/null || true |
|||
log " country: 라디오 실효=${LC:-unknown} != 목표 ${DC} → 재부팅 필요 (reboot-required)" |
|||
if [ "$MODE_BOOT" -eq 0 ] && [ "$COUNTRY_NOW" -eq 1 ]; then |
|||
note_country_now_deferred |
|||
fi |
|||
fi |
|||
elif [ -n "$DC" ]; then |
|||
log " country: 잘못된 코드 '${DC}' 무시" |
|||
fi |
|||
|
|||
# ──────── 4) 라이브 적용 + 라이브-vs-의도 reconcile (P3 + L1) ──────── |
|||
# on-demand: 항상 수행. |
|||
# boot: 정상 순서면 networkd 미기동 → 자연 적용에 맡기고 생략. 단 boot-ordering race 로 |
|||
# networkd 가 이미 active 면(우리 /run 설치 전에 링크 구성) 라이브가 옛 값으로 굳으므로 reconcile. |
|||
DO_LIVE=0 |
|||
if [ "$MODE_BOOT" -eq 0 ]; then |
|||
DO_LIVE=1 |
|||
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then |
|||
DO_LIVE=1 |
|||
log " boot race 감지: networkd 가 이미 active → 라이브 reconcile 수행" |
|||
fi |
|||
|
|||
if [ "$DO_LIVE" -eq 1 ]; then |
|||
# L1: 라이브-vs-렌더 정합성 — /run 이 렌더와 같아도 "라이브 주소 != 렌더 주소"면 reconfigure |
|||
# 강제. 펌웨어 reconcile_live_networkd_state 복원 (이게 빠져서 cold-boot race 후 옛 IP 가 |
|||
# 그대로 남았음). /run-vs-렌더 비교만으로는 못 잡는 케이스를 닫는다. |
|||
RECON="" |
|||
for r in $NET_RENDERS; do |
|||
src="${NETWORK_DIR}/${r}"; ifc=$(iface_of "$r") |
|||
[ -f "$src" ] || continue |
|||
want=$(render_addr "$src"); [ -n "$want" ] || continue # DHCP — 비교 생략 |
|||
have=$(live_addr "$ifc") |
|||
if [ "$want" != "$have" ]; then |
|||
RECON="${RECON} ${ifc}" |
|||
log " reconcile: ${ifc} live=${have:-none} != desired=${want} → reconfigure" |
|||
fi |
|||
done |
|||
|
|||
# reconfigure 대상 = /run 변경 iface ∪ 라이브 불일치 iface |
|||
TARGETS=$(printf '%s\n' $CHANGED_IFACES $RECON | grep -v '^$' | sort -u) |
|||
if [ -n "$TARGETS" ]; then |
|||
if systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then |
|||
run networkctl reload # P3 |
|||
for ifc in $TARGETS; do |
|||
if command -v ip >/dev/null 2>&1 && ! ip link show dev "$ifc" >/dev/null 2>&1; then |
|||
log " ${ifc} 미존재 — reconfigure 생략"; continue |
|||
fi |
|||
run networkctl reconfigure "$ifc" |
|||
done |
|||
else |
|||
log " systemd-networkd 비활성 — reload/reconfigure 생략" |
|||
fi |
|||
fi |
|||
|
|||
if [ "$WPA_CHANGED" -eq 1 ]; then # P1 |
|||
if command -v wpa_cli >/dev/null 2>&1 && wpa_cli -i wlan0 ping >/dev/null 2>&1; then |
|||
run wpa_cli -i wlan0 reconfigure |
|||
else |
|||
run systemctl try-restart wpa_supplicant@wlan0.service |
|||
fi |
|||
fi |
|||
|
|||
# ── 4.5) 검증 요약 (관측만 — 롤백 안 함). 재테스트 판정용. ── |
|||
if [ -n "$TARGETS" ] || [ "$WPA_CHANGED" -eq 1 ]; then |
|||
sleep 2 # 주소 settle |
|||
for r in $NET_RENDERS; do |
|||
src="${NETWORK_DIR}/${r}"; ifc=$(iface_of "$r") |
|||
[ -f "$src" ] || continue |
|||
want=$(render_addr "$src") |
|||
[ -n "$want" ] || { log " verify ${ifc}: DHCP/no-addr"; continue; } |
|||
have=$(live_addr "$ifc") |
|||
if [ "$want" = "$have" ]; then |
|||
log " verify ${ifc}: OK (${have})" |
|||
else |
|||
log " verify ${ifc}: MISMATCH want=${want} live=${have:-none}" |
|||
fi |
|||
done |
|||
if [ "$WPA_CHANGED" -eq 1 ]; then |
|||
st=$(wpa_cli -i wlan0 status 2>/dev/null | sed -n 's/^wpa_state=//p') |
|||
ss=$(wpa_cli -i wlan0 status 2>/dev/null | sed -n 's/^ssid=//p') |
|||
log " verify wlan0: wpa_state=${st:-?} ssid=${ss:-?}" |
|||
fi |
|||
fi |
|||
fi |
|||
|
|||
# ──────── 5) shadow 갱신 (적용 기준선) ──────── |
|||
for f in $NET_RENDERS wpa_supplicant-wlan0.conf wifi-country-code network_config.json; do |
|||
[ -f "${NETWORK_DIR}/${f}" ] && cp -f "${NETWORK_DIR}/${f}" "${SHADOW_DIR}/${f}" 2>/dev/null || true |
|||
done |
|||
|
|||
log "DONE (networkd_dirty=${NETWORKD_DIRTY} wpa_changed=${WPA_CHANGED}$([ -f "$REBOOT_MARK" ] && echo ' reboot_required=1'))" |
|||
exit 0 |
|||
@ -0,0 +1,7 @@ |
|||
# v1.6.0: dpworld-network-apply.service 는 펌웨어 소유 oneshot 으로 Requires=dpworld-network-seed.service |
|||
# 를 걸어, 재부팅 후 web-configurator 가 systemctl start 로 온디맨드 호출하면 seed(boot-only |
|||
# oneshot)가 inactive → "Dependency failed" rc=1 (.56 실측, 수락 #6 C-2). seed 는 부팅 순서 |
|||
# 보장용일 뿐 apply.sh 자체는 자족적이므로 런타임 Requires 를 비워 온디맨드 호출을 가능케 한다. |
|||
# (After= 는 부팅 순서용으로 유지 — 부팅 이후엔 무해.) |
|||
[Unit] |
|||
Requires= |
|||
@ -0,0 +1,7 @@ |
|||
# Hardened apply.sh override (2026-06-15) — 펌웨어 /usr/bin/dpworld-network-apply.sh 무수정, |
|||
# ExecStart 만 하드닝본으로 교체. country reboot-deferred(모듈 리로드 hang 방지), |
|||
# /run 동기화 우선, wpa/networkctl 재구성. TimeoutStartSec 확대(country-now 여유). |
|||
[Service] |
|||
ExecStart= |
|||
ExecStart=/usr/bin/dpworld-network-apply-hardened.sh |
|||
TimeoutStartSec=180 |
|||
@ -0,0 +1,6 @@ |
|||
# Hardened seed override (2026-06-15) — 부팅 시 하드닝본 --boot 실행. |
|||
# /run 렌더 복사를 최우선 수행, 모듈 리로드 안 함(hang 방지) → IP 가 부팅 시 정상 반영. |
|||
[Service] |
|||
ExecStart= |
|||
ExecStart=/usr/bin/dpworld-network-apply-hardened.sh --boot |
|||
TimeoutStartSec=120 |
|||
@ -0,0 +1,12 @@ |
|||
[Unit] |
|||
Description=DPWorld udhcpd on ap0 |
|||
After=dpworld-hostapd-ap0.service |
|||
StartLimitIntervalSec=60 |
|||
StartLimitBurst=5 |
|||
|
|||
[Service] |
|||
Type=simple |
|||
ExecStartPre=/bin/sh -c 'touch /run/udhcpd-ap0.leases' |
|||
ExecStart=/usr/sbin/udhcpd -f -S /home/root/network/ap/udhcpd-ap0.conf |
|||
Restart=on-failure |
|||
RestartSec=3 |
|||
@ -0,0 +1,25 @@ |
|||
server { |
|||
listen 80; |
|||
server_name _; |
|||
|
|||
# Static files |
|||
location / { |
|||
root /opt/web-configurator/static; |
|||
try_files $uri $uri/ /index.html; |
|||
expires 1h; |
|||
add_header Cache-Control "public, must-revalidate"; |
|||
} |
|||
|
|||
location /configuration/ { |
|||
proxy_pass http://127.0.0.1:8080/; |
|||
proxy_set_header Host $host; |
|||
proxy_set_header X-Real-IP $remote_addr; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_set_header X-Forwarded-Proto $scheme; |
|||
proxy_read_timeout 30s; |
|||
} |
|||
|
|||
location = /configuration { |
|||
return 301 /configuration/; |
|||
} |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
# v1.3.1: synced to device .56 reality (then at /home/root/NEW_Web_Configurator). |
|||
# v1.11.1: app install dir moved to /opt/web-configurator (FHS /opt). The |
|||
# server from /opt/web-configurator/src on port 9090. |
|||
# v1.11.16: MemoryMax 48M→128M. 48M was tight — support-bundle(io.BytesIO)+UI 동시 |
|||
# 사용 시 실측 피크 ~40-50MB로 OOM-kill 위험. 128M = 실피크 ~2.5x 여유 + 누수 방지 |
|||
# 캡 유지(장비 RAM 3.4GB 대비 3.7%). 정적자산 최대 93KB·업로드는 디스크 스트리밍. |
|||
[Unit] |
|||
Description=IoT Web Configurator |
|||
# v1.6.0 C3: wpa_supplicant@wlan0 이후 기동 — watchdog 첫 틱에 /var/run/wpa_supplicant |
|||
# 제어 소켓이 보이도록 (부팅 순서 race → wpa 쿼리 실패 → production wpa flap 방지) |
|||
After=network.target wpa_supplicant@wlan0.service |
|||
Wants=network.target wpa_supplicant@wlan0.service |
|||
StartLimitIntervalSec=60 |
|||
StartLimitBurst=5 |
|||
|
|||
[Service] |
|||
Type=simple |
|||
ExecStart=/usr/bin/python3 /opt/web-configurator/src/server.py |
|||
WorkingDirectory=/opt/web-configurator/src |
|||
Restart=always |
|||
RestartSec=5 |
|||
Environment=DB_PATH=/home/root/db/dynamic_data.db |
|||
Environment=LOG_DIR=/opt/log/dpworldapp |
|||
Environment=PORT=9090 |
|||
MemoryMax=128M |
|||
StandardOutput=journal |
|||
StandardError=journal |
|||
# v1.4.0: security hardening — minimum-cost OS-level sandboxing |
|||
NoNewPrivileges=yes |
|||
ProtectSystem=strict |
|||
ProtectHome=read-only |
|||
# v1.6.0: PrivateTmp yes→no (.56 strace 실측). wpa_cli 는 응답 수신용 클라이언트 소켓을 |
|||
# /tmp/wpa_ctrl_<pid> 에 bind 하는데(컴파일 고정), PrivateTmp 사설 tmpfs 는 호스트의 |
|||
# wpa_supplicant 에게 보이지 않아 회신 불가 → 영구 무응답. 공유 /tmp 로 전환하고 |
|||
# ReadWritePaths 에 /tmp 명시 (ProtectSystem=strict 는 /tmp 도 RO 로 만들므로 필요 — |
|||
# v1.4.6.6 log-download 의 /tmp staging 도 이 라인으로 유지). 보안 트레이드오프: |
|||
# /tmp symlink 공격면 일부 복원 — 폐쇄 LAN 단일 운영자 장비라 수용 (CHANGELOG 기록). |
|||
PrivateTmp=no |
|||
PrivateDevices=yes |
|||
ReadWritePaths=/home/root/db /opt/log/dpworldapp /opt/web-configurator /opt/fw_staging /opt/fw_upload -/opt/config_backups -/home/root/network /tmp |
|||
# /var/run 은 /run 의 symlink — namespace 는 실경로 기준이므로 둘 다 지정 (.56 실측) |
|||
ReadWritePaths=-/var/run/wpa_supplicant -/run/wpa_supplicant |
|||
ProtectKernelTunables=yes |
|||
ProtectKernelModules=yes |
|||
ProtectControlGroups=yes |
|||
RestrictSUIDSGID=yes |
|||
LockPersonality=yes |
|||
|
|||
[Install] |
|||
WantedBy=multi-user.target |
|||
@ -0,0 +1,203 @@ |
|||
# 배포 및 버전 관리 운영 절차서 |
|||
|
|||
이 문서는 NEW Web Configurator의 버전 관리 정책, 릴리즈 절차, 배포 워크플로우, 검증, 롤백, 디바이스 아키텍처, 트러블슈팅을 다룹니다. 신규 버전을 디바이스에서 실행하는 방법에 대한 단일 참조 문서입니다. |
|||
|
|||
--- |
|||
|
|||
## 1. 개요 |
|||
|
|||
NEW Web Configurator는 여러 현장의 IoT 디바이스에 배포되는 Python stdlib HTTP 서버입니다. 배포는 `scripts/deploy.ps1` — PowerShell 스크립트를 사용합니다. 이 스크립트는 커밋된 `src/` 트리를 아카이브로 묶어 SSH로 전송하고, 디바이스 상의 기존 트리를 백업 및 교체한 뒤, 배포된 버전을 기록하고 systemd 서비스를 재시작합니다. 모든 프로덕션 배포는 반드시 `vMAJOR.MINOR.PATCH` 릴리즈 태그와 연결되어야 하므로, 어떤 디바이스에서든 실행 중인 정확한 버전을 언제나 식별할 수 있습니다. |
|||
|
|||
--- |
|||
|
|||
## 2. 버전 관리 정책 |
|||
|
|||
### 2.1 버전 체계 |
|||
|
|||
세 부분으로 구성된 시맨틱 버전 관리: **`vMAJOR.MINOR.PATCH`**. |
|||
|
|||
| 구분 | 올려야 하는 경우 | |
|||
|---|---| |
|||
| **MAJOR** | 하위 호환이 깨지는 변경 — 예: 기존 디바이스를 망가뜨리는 `board_config` 스키마 변경, 또는 API 삭제/이름 변경. | |
|||
| **MINOR** | 새로운 하위 호환 기능 추가 — 예: 새 설정 페이지, 랜딩 대시보드, WiFi 국가 코드 드롭다운. | |
|||
| **PATCH** | 신규 기능 없는 버그 수정 또는 소규모 수정 — 예: `db_manager` 재시도 수정, 표시 버그 수정. | |
|||
|
|||
### 2.2 단일 진실 소스(Single Source of Truth) |
|||
|
|||
`src/static/js/constants.js`의 `APP_VERSION`이 애플리케이션 버전의 단일 진실 소스입니다. 사이드바에 표시되며, 릴리즈 시점에 git 태그와 반드시 일치해야 합니다. |
|||
|
|||
--- |
|||
|
|||
## 3. 릴리즈 절차 |
|||
|
|||
새 릴리즈를 만들려면: |
|||
|
|||
1. **버전 올림 결정** — 변경 내용이 MAJOR, MINOR, PATCH 중 어느 것에 해당하는지 결정합니다(§2.1 표 참조). |
|||
2. **`APP_VERSION` 업데이트** — `src/static/js/constants.js`에서 `APP_VERSION`을 새 버전 문자열로 설정합니다(예: `'v1.0.1'`). |
|||
3. **`CHANGELOG.md` 항목 추가** — `CHANGELOG.md` 상단에 `## [vX.Y.Z] — YYYY-MM-DD` 형식으로 변경 내용을 기술하는 섹션을 추가합니다. |
|||
4. **커밋** — `constants.js`와 `CHANGELOG.md`를 함께 커밋합니다. |
|||
5. **태그** — annotated 태그를 생성합니다: |
|||
``` |
|||
git tag -a vX.Y.Z -m "Release vX.Y.Z — <one-line summary>" |
|||
``` |
|||
|
|||
태그는 릴리즈 시점에만 생성합니다. 개발 중간에 태그를 붙이지 마십시오. 태그는 배포되는 커밋을 정확히 표시합니다. |
|||
|
|||
--- |
|||
|
|||
## 4. 배포 |
|||
|
|||
### 4.1 사전 요구사항 |
|||
|
|||
- 다음이 갖춰진 **Windows 개발 호스트**: |
|||
- OpenSSH `ssh` 및 `scp` (Windows 10/11 기본 포함 또는 Git for Windows). |
|||
- `PATH`에 등록된 `git`. |
|||
- `root@<device>`에 인증된 SSH 키. |
|||
- **클린(clean)** 상태의 로컬 워킹 트리 (`git status`에 변경 사항 없음). |
|||
- **`vX.Y.Z` 태그에 위치한 HEAD** (플래그 처리된 개발 빌드에는 `-AllowUntagged` 사용). |
|||
|
|||
### 4.2 사용법 |
|||
|
|||
```powershell |
|||
.\scripts\deploy.ps1 <device-ip> |
|||
.\scripts\deploy.ps1 <device-ip> -AllowUntagged |
|||
.\scripts\deploy.ps1 <device-ip> -Rollback |
|||
``` |
|||
|
|||
예시: |
|||
|
|||
```powershell |
|||
.\scripts\deploy.ps1 192.168.55.56 |
|||
.\scripts\deploy.ps1 192.168.55.56 -AllowUntagged |
|||
``` |
|||
|
|||
### 4.3 배포 흐름 (스크립트 동작 상세) |
|||
|
|||
1. **사전 점검(Pre-flight)** — SSH 도달 가능 여부 확인(`ssh … echo ok`), 로컬 워킹 트리가 클린 상태인지 확인, HEAD가 버전 태그에 위치하는지 확인(버전 태그가 아니면 중단, `-AllowUntagged`가 전달된 경우 제외). |
|||
2. **클린 아카이브 빌드** — `git archive --output=<temp>.tar HEAD src`를 실행하여 `src/` 하위의 커밋된 파일만 캡처 — `__pycache__`, `*.bak.*`, 미추적 파일은 제외. |
|||
3. **전송** — `scp`로 임시 tar 파일을 디바이스로 복사하고, 디바이스에서 스테이징 디렉터리에 압축 해제. |
|||
4. **백업** — 현재 디바이스의 `src/`를 `backups/src-<timestamp>/`로 복사하고, 최근 3개 백업만 유지하도록 정리. |
|||
5. **systemd 유닛 및 헬퍼 스크립트 설치** (멱등성 보장 — 해시 비교 후 변경된 경우에만 재복사; src 교체 이전에 수행하므로 실패 시 기존 src가 활성 상태를 유지). 모든 유닛은 영구 경로 `/lib/systemd/system/`에 설치됩니다(`/etc/systemd/system/` 오버레이는 tmpfs라 재부팅 시 초기화됩니다). 설치 항목: |
|||
- `web-configurator.service` (메인 유닛; 첫 배포 시 `systemctl enable`도 수행). |
|||
- `dpworld-net-recover.service` — 네트워크 적용 엔진이 사용하는 `modprobe` 전용 복구 유닛. |
|||
- `dpworld-network-apply.service.d/10-ondemand.conf` — 펌웨어 적용 유닛의 부팅 전용 `Requires`를 온디맨드로 재정의하는 드롭인. |
|||
- **펌웨어 부팅 하드닝(v1.7.1+)**: 하드닝된 부팅 스크립트 `deploy/dpworld-network-apply-hardened.sh` → `/usr/bin/`으로 복사, 그리고 두 개의 드롭인(`dpworld-network-apply.service.d/20-hardened.conf` 및 `dpworld-network-seed.service.d/20-hardened.conf`)이 펌웨어 seed/apply 유닛을 하드닝된 스크립트로 연결(라이브 `modprobe -r` 없음 — 국가 코드는 재부팅 시 적용됨). 펌웨어 원본 `/usr/bin/dpworld-network-apply.sh`는 수정하지 않습니다. [firmware-boot-hardening.md](firmware-boot-hardening.md) 참조. |
|||
- **Wi-Fi AP 유닛**: `deploy/dpworld-ap-apply.sh` → `/usr/bin/`으로 복사, 그리고 네 개의 유닛(`dpworld-ap-seed`, `dpworld-ap-apply`, `dpworld-hostapd-ap0`, `dpworld-udhcpd-ap0`). `dpworld-ap-seed.service`만 enable하며, 나머지는 apply 스크립트에서 온디맨드로 시작됩니다. |
|||
- 엔진/OTA 디렉터리도 사전 생성합니다(`/opt/fw_staging`, `/opt/fw_upload`, `/opt/config_backups`, `/home/root/network`, `/opt/config_backups/network`). |
|||
6. **교체(Swap)** — 디바이스의 `src/`를 새로 압축 해제된 트리로 교체(`rm -rf src && mv <staging>/src src`); 스테이징 tar와 디렉터리를 삭제. |
|||
7. **버전 기록** — `DEPLOYED_VERSION`을 기록하고 `deploy-history.log`에 한 줄을 추가합니다(§5 참조). |
|||
8. **재시작** — `systemctl restart web-configurator`를 실행합니다. |
|||
9. **검증** — 최대 약 15초 동안 폴링: `systemctl is-active web-configurator`가 `active`인지, `:9090`으로 HTTP 요청 시 200이 반환되는지 확인합니다. 성공 또는 실패를 보고합니다. 재시작/검증이 실패하면 스크립트가 4단계에서 생성한 `backups/src-<timestamp>/`로 자동 롤백합니다. |
|||
|
|||
> **참고:** HEAD가 정확히 `vX.Y.Z` 태그에 있지 않으면 배포가 거부됩니다. 미태그 커밋을 플래그 처리된 개발 빌드로 배포하려면 `-AllowUntagged`를 전달하십시오(버전은 `vX.Y.Z-dev+<short-sha>`로 기록됩니다). |
|||
|
|||
이 스크립트는 nginx, Java app-runner, SQLite 데이터베이스, dpworldapp 바이너리를 **절대 건드리지 않습니다**. 단, 5단계에 나열된 systemd 유닛과 헬퍼 스크립트를 설치/갱신하며(멱등성 보장, 해시가 다를 경우에만), 펌웨어 소유의 `/usr/bin/dpworld-network-apply.sh`는 수정하지 않습니다 — 하드닝은 원본을 수정하지 않고 드롭인을 통해 적용됩니다. |
|||
|
|||
--- |
|||
|
|||
## 5. 배포 검증 |
|||
|
|||
배포 성공 후, 디바이스에서 실행 중인 내용을 확인합니다: |
|||
|
|||
```bash |
|||
ssh root@<ip> cat /opt/web-configurator/DEPLOYED_VERSION |
|||
``` |
|||
|
|||
이 파일은 매 배포 시 덮어쓰이며, 다음 내용을 포함합니다: |
|||
|
|||
``` |
|||
version=v1.0.0 |
|||
tag=v1.0.0 |
|||
commit=<full-sha> |
|||
deployed_at=<ISO-8601 timestamp> |
|||
deployed_by=<user>@<host> |
|||
``` |
|||
|
|||
배포 이력을 확인하려면: |
|||
|
|||
```bash |
|||
ssh root@<ip> cat /opt/web-configurator/deploy-history.log |
|||
``` |
|||
|
|||
각 줄의 형식: `<timestamp> <version> <commit-short> <deployed_by>`. |
|||
|
|||
--- |
|||
|
|||
## 6. 롤백 |
|||
|
|||
디바이스의 가장 최근 백업으로 복원하려면: |
|||
|
|||
```powershell |
|||
.\scripts\deploy.ps1 <ip> -Rollback |
|||
``` |
|||
|
|||
이 명령은 가장 최근의 `backups/src-*` 스냅샷을 `src/`에 복원하고, 서비스를 재시작하여 정상 여부를 검증한 뒤, 롤백이 발생했음을 기록하도록 `DEPLOYED_VERSION`을 다시 기록합니다(복원된 백업 타임스탬프 포함). 백업 스냅샷은 롤백 후에도 삭제되지 않습니다. |
|||
|
|||
--- |
|||
|
|||
## 7. 디바이스 아키텍처 |
|||
|
|||
| 항목 | 값 | |
|||
|---|---| |
|||
| 앱 디렉터리 | `/opt/web-configurator/` | |
|||
| systemd 서비스 | `web-configurator.service`가 `src/server.py` 실행 | |
|||
| Python 설정 서버 포트 | **9090** | |
|||
| Nginx (리버스 프록시) | 포트 **80** | |
|||
| Java app-runner | 포트 **8080** | |
|||
| 디바이스 — 프로덕션(메인) | `192.168.55.56` (유선 `eth1`) | |
|||
| 디바이스 — 개발/검증(보조) | `192.168.55.54` | |
|||
| Python 런타임 | Python 3.10 | |
|||
| SSH 사용자 | `root` | |
|||
|
|||
디바이스 주요 경로: |
|||
|
|||
| 경로 | 용도 | |
|||
|---|---| |
|||
| `/opt/web-configurator/src/` | 실행 중인 애플리케이션 소스 | |
|||
| `/opt/web-configurator/backups/` | 디바이스 내 백업 스냅샷(최근 3개) | |
|||
| `/opt/web-configurator/DEPLOYED_VERSION` | 현재 버전 기록 파일 | |
|||
| `/opt/web-configurator/deploy-history.log` | 배포별 감사 로그 | |
|||
| `/home/root/db/dynamic_data.db` | SQLite 설정 데이터베이스(`board_config` 테이블) | |
|||
| `/opt/log/dpworldapp/` | 애플리케이션 로그 디렉터리 | |
|||
|
|||
--- |
|||
|
|||
## 8. 트러블슈팅 |
|||
|
|||
### SSH 접속 불가 |
|||
|
|||
**증상:** `Cannot reach <ip> over SSH.` |
|||
|
|||
**조치:** |
|||
- 디바이스 전원이 켜져 있고 네트워크에 연결되어 있는지 확인합니다: `ping <ip>`. |
|||
- SSH 키가 `root@<ip>:~/.ssh/authorized_keys`에 등록되어 있는지 확인합니다. |
|||
- 수동으로 테스트합니다: `ssh -o StrictHostKeyChecking=no root@<ip> echo ok`. |
|||
|
|||
### 배포 거부 — HEAD에 태그 없음 |
|||
|
|||
**증상:** `HEAD is not at a version tag. Tag a release, or pass -AllowUntagged for a dev build.` |
|||
|
|||
**조치:** 커밋에 태그를 붙이거나(`git tag -a vX.Y.Z -m "..."`) 다시 실행하거나, `-AllowUntagged`를 전달하여 플래그 처리된 개발 빌드를 배포합니다. 프로덕션 배포에 `-AllowUntagged`를 절대 사용하지 마십시오. |
|||
|
|||
### 배포 거부 — 더티(dirty) 워킹 트리 |
|||
|
|||
**증상:** `Local working tree is not clean.` |
|||
|
|||
**조치:** 배포 전에 모든 로컬 변경 사항을 커밋하거나 stash합니다. `git status`로 미처리 항목을 확인하십시오. |
|||
|
|||
### 배포 후 서비스 비정상 |
|||
|
|||
**증상:** 스크립트가 `Verification failed: web-configurator not healthy on <ip> after restart`를 보고합니다. |
|||
|
|||
**조치:** |
|||
```bash |
|||
ssh root@<ip> journalctl -u web-configurator -n 50 --no-pager |
|||
``` |
|||
Python import 오류, 파일 누락, 포트 충돌 등을 확인합니다. 새 버전에 문제가 있으면 즉시 롤백합니다: |
|||
```powershell |
|||
.\scripts\deploy.ps1 <ip> -Rollback |
|||
``` |
|||
|
|||
### 롤백 방법 |
|||
|
|||
§6을 참조합니다. 롤백 자체가 실패한 경우(백업 없음), 알려진 정상 상태의 `.tar` 파일에서 수동 복원하거나 이전 릴리즈 태그를 다시 배포합니다. |
|||
@ -0,0 +1,22 @@ |
|||
# 문서 인덱스 |
|||
|
|||
이 디렉터리는 파트너 전달용으로 정리되었습니다. 현재 Web Configurator 제품, |
|||
배포 흐름, `dpworldapp`과의 설정 계약을 설명하는 문서들이 포함되어 있습니다. |
|||
|
|||
## 시작 안내 |
|||
|
|||
| 문서 | 설명 | |
|||
|---|---| |
|||
| [DEPLOY.md](DEPLOY.md) | 디바이스 배포 및 롤백 운영 가이드. | |
|||
| [architecture.md](architecture.md) | 고수준 아키텍처 및 런타임 모델. | |
|||
| [architecture/v1.5.4.2-system-overview.md](architecture/v1.5.4.2-system-overview.md) | 현행 Python Web Configurator 상세 시스템 개요. | |
|||
|
|||
## 설정 계약 |
|||
|
|||
| 문서 | 설명 | |
|||
|---|---| |
|||
| [dpworldapp_compatibility.md](dpworldapp_compatibility.md) | 공유 SQLite `board_config`에 대한 호환성 안내. | |
|||
| [config-spec/config-contract-proposal.md](config-spec/config-contract-proposal.md) | Web Configurator와 `dpworldapp`을 위한 정규화된 설정 계약 제안. | |
|||
| [config-spec/proposed/config_device.v2.proposed.json](config-spec/proposed/config_device.v2.proposed.json) | 제안된 디바이스 설정 JSON 예시. | |
|||
| [config-spec/proposed/config_protocol.v2.proposed.json](config-spec/proposed/config_protocol.v2.proposed.json) | 제안된 프로토콜 설정 JSON 예시. | |
|||
| [dpworldapp_schema_handoff/](dpworldapp_schema_handoff/) | `dpworldapp` 논의용 후보 v2/v3 스키마 및 마이그레이션 예시. | |
|||
@ -0,0 +1,424 @@ |
|||
# NEW Web Configurator — Architecture Document |
|||
|
|||
> **Version 3.0 | 2026-06-20** — reflects app **v1.11.10** (Network Apply Engine + Wi-Fi AP + Firmware OTA) |
|||
> Supersedes: v2.2 (2026-05-28), v2.1 (2026-05-26), v2.0 (2026-05-22), v1.1 (2026-02-19), v1.0 (2026-02-12) |
|||
|
|||
--- |
|||
|
|||
## 1. Overview |
|||
|
|||
The **NEW Web Configurator** is a browser-based configuration tool for the |
|||
Telechips TCC8030 IoT device. It is a **Python-stdlib HTTP server** plus a |
|||
**vanilla ES-module SPA** (no build step), and it replaced a heavier legacy |
|||
Java/Spring configurator (~436 MB → ~15–30 MB resident). |
|||
|
|||
It reads and writes the device's `board_config` SQLite store. That store is |
|||
also used by two other programs on the device — the legacy Java app and the |
|||
`dpworldapp` data-processing binary — which makes **storage ownership** a |
|||
central concern of this document (see §6). |
|||
|
|||
--- |
|||
|
|||
## 2. System Context |
|||
|
|||
Three independent programs run on the device and **all touch the same SQLite |
|||
DB**: |
|||
|
|||
```mermaid |
|||
graph LR |
|||
Operator["운영자<br/>(브라우저)"] |
|||
Operator -->|":9090 직접"| WebCfg |
|||
Operator -->|":80 → nginx"| JavaApp |
|||
|
|||
subgraph Device["IoT Device — TCC8030 (192.168.55.x)"] |
|||
WebCfg["Python Web Configurator<br/>systemd: web-configurator.service<br/>:9090"] |
|||
JavaApp["레거시 Java app-runner<br/>:8080 (nginx :80 프록시)"] |
|||
dpworld["dpworldapp<br/>CAN/Modbus/OPC-UA 데이터 처리"] |
|||
DB[("SQLite<br/>~/db/dynamic_data.db")] |
|||
WebCfg -->|R/W| DB |
|||
JavaApp -->|R/W| DB |
|||
dpworld -->|R/W| DB |
|||
end |
|||
``` |
|||
|
|||
- **Python Web Configurator** — this project. systemd-managed service on port |
|||
**9090**, reached directly (no reverse proxy in front of it). |
|||
- **Legacy Java app-runner** — a separate, still-running legacy app on 8080, |
|||
fronted by nginx on :80. Not part of this project, but it **shares |
|||
`board_config`** — see §6. |
|||
- **dpworldapp** — the core data-processing binary. Reads config from |
|||
`board_config`, writes events to `event_history`. |
|||
|
|||
> The original design docs assumed "nginx :80 → Python :8080". The actual |
|||
> device deployment is the layout above (Python direct on :9090, systemd- |
|||
> managed). See [`DEPLOY.md`](DEPLOY.md) for the deployment runbook. |
|||
|
|||
> 위 3개 프로그램 외에 사용자의 **별도 프로젝트 Super_Relay**도 같은 |
|||
> `board_config`에 일부 키(`security_config`·`transport_config`)를 기록한다 |
|||
> — 소유권 상세는 §6.2. |
|||
|
|||
--- |
|||
|
|||
## 3. Component Architecture |
|||
|
|||
```mermaid |
|||
graph TB |
|||
subgraph Frontend["프론트엔드 — Vanilla ES-module SPA (빌드 없음)"] |
|||
AppJS["app.js — 라우팅·초기화·Save/Import/Export"] |
|||
ApiJS["api.js — REST 클라이언트"] |
|||
StateJS["state.js — 상태관리, flat↔nested 변환"] |
|||
ViewMode["view-mode.js — Two Faces (User/Advanced 토글)"] |
|||
Pages["pages/ — ~18 페이지<br/>(home·wifi·wifi-ap·ethernet·io·<br/>can·modbus·opcua·register·log·<br/>firmware·net-apply·…)"] |
|||
Shared["validator·utils·toast·constants·<br/>country-codes·components/"] |
|||
AppJS --> Pages --> ApiJS |
|||
AppJS --> ViewMode |
|||
Pages --> StateJS |
|||
Pages --> Shared |
|||
end |
|||
|
|||
subgraph Backend["백엔드 — Python stdlib"] |
|||
Server["server.py — HTTP 라우팅·CORS·정적파일·진입점"] |
|||
DBMgr["db_manager.py — 3-tier SQLite 접근 레이어"] |
|||
Validator["config_validator.py — 서버측 검증·정규화"] |
|||
LogMgr["log_manager.py — 로그 관리·자동압축 데몬"] |
|||
KernelLog["kernel_log.py — 커널 로그 번들 (.tar.gz)"] |
|||
Status["system_status.py — Home 대시보드 상태 집계"] |
|||
Bundle["support_bundle.py — 진단 zip 생성"] |
|||
NetPkg["network/ — Network Apply Engine + Wi-Fi AP"] |
|||
FwPkg["firmware/ — Firmware OTA (TCP 8990)"] |
|||
Server --> DBMgr |
|||
Server --> Validator |
|||
Server --> LogMgr |
|||
Server --> KernelLog |
|||
Server --> Status |
|||
Server --> Bundle |
|||
Server --> NetPkg |
|||
Server --> FwPkg |
|||
end |
|||
|
|||
ApiJS -->|HTTP REST| Server |
|||
``` |
|||
|
|||
### 3.1 Backend modules (`src/*.py`) |
|||
|
|||
| 모듈 | 책임 | |
|||
|------|------| |
|||
| `server.py` | stdlib `ThreadedHTTPServer` + `ConfigHandler`. 모든 GET/POST 라우팅, 정적파일 서빙, 앱 진입점(`main()`) | |
|||
| `db_manager.py` | 3-tier 설정 저장 레이어 — `board_config` 읽기/쓰기는 전부 이곳을 거침 | |
|||
| `config_validator.py` | device/protocol config 검증·정규화 (`None` 제거, 비활성 프로토콜 매핑 처리) | |
|||
| `log_manager.py` | dpworldapp 앱 로그(`/opt/log/dpworldapp`) 목록·다운로드 아카이브·수동/자동 압축·정리·통계, 그리고 5분 자동압축 데몬 | |
|||
| `kernel_log.py` | **(v1.1.0)** 커널 로그 번들 빌더 — systemd 저널을 `journalctl`로 텍스트 export + `/opt/log` 평문 커널 로그(`kernel-follow.log`/`wifi-focus.log`/`boot-history`/`pstore`) 사본 + 매니페스트 → `.tar.gz` 한 파일. journald 디렉터리(`/opt/log/journal/`)는 읽기 전용 (텍스트 export 방식). 시작 시 `sweep_stale_temp_dirs()`로 잔여 임시디렉터리 정리 | |
|||
| `system_status.py` | Home 대시보드용 디바이스 헬스 스냅샷 집계. v1.0.x 기본(core_app·communication·network·system) + **(v1.2.0)** `hardware_modules` (GNSS FW + WiFi FW, `/lib/firmware/amss.bin`의 `QC_IMAGE_VERSION_STRING` 토큰 + dpworldapp 로그의 `[GNSS FW VER : ...]`) + **(v1.2.1)** chunk-boundary 안전 line-iter 스캐너로 교체 (FWVE 16KB tail 누락 버그 fix) + **(v1.3.0)** `dpworldapp_status` (7개 startup phase tracker + 5개 result 상태 [healthy/starting/hung/failed/unknown] + 6개 runtime config 필드 `equipment`·`protocol+endpoint`·`can_input/type/speed`·`speed_data`·`odo_speed.source`·`odo_dir.source`). 모든 신규 필드는 module-level cache + `threading.Lock`로 lazy-load (서비스 재시작 전까지 영구 캐시) | |
|||
| `support_bundle.py` | 진단 zip 생성 (상태 + 마스킹된 config + 최신 로그 + OS 진단) — "빠른 스냅샷" 용도. 전체 커널 저널이 필요하면 `kernel_log.py`의 별도 번들 사용 | |
|||
| `config_validator.py` | device/protocol config 검증·정규화 (`None` 제거, 비활성 프로토콜 매핑 처리) — dpworldapp 가 기대하는 JSON 형식 보장 | |
|||
| `enum_normalizer.py` | enum 값의 case-only 입력 정규화(`"ON"→"on"`). 의미 추측은 의도적 제외 | |
|||
| `dpworldapp_enums.py` | device/protocol enum 허용집합 — 배포 바이너리 검증 계약과 일치. validator 가 reject 판정에 사용 | |
|||
| `dpworldapp_telemetry.py` | **(v1.5.5)** dpworldapp telemetry 스트림(TCP **8989**) 파서 — connect→헤더 frame parse→disconnect 의 stateless query 로 정적 정보(firmware version/MAC/IP) 수집 | |
|||
| `migrations.py` | 기동 시 1회 실행 마이그레이션 — `schema_meta` 테이블로 게이트(`board_config` 경합 회피). CanSpeed Integer 정합 등 | |
|||
|
|||
### 3.1.1 `src/network/` — Network Apply Engine + Wi-Fi AP (v1.6.0~) |
|||
|
|||
웹에서 입력한 네트워크 22항목을 **dpworldapp 파일 계약과 byte-exact 로 렌더**해 즉시 적용·검증·롤백하고, 상시 watchdog 로 자가복구한다. Wi-Fi AP(소프트 AP, ap0) 서브시스템도 같은 패키지에 있다. |
|||
|
|||
| 모듈 | 책임 | |
|||
|------|------| |
|||
| `apply_engine.py` | apply 상태머신(STAGING→APPLYING→VERIFYING→CONFIRM_WAIT→COMMITTED). 단일 in-flight, DB-first 쓰기, confirm TTL(90s) 타이머, 크래시 복구, country split-apply(비country 즉시 / country deferred) | |
|||
| `netmodel.py` | 22항목 집합 — DB `device_config` ↔ intent ↔ persist JSON 매핑 + dpworldapp 네트워크-필드 비교 정합 의미론 | |
|||
| `renderer.py` | intent → dpworldapp byte-호환 파일 렌더(캡처 golden 과 1바이트도 안 틀리게) | |
|||
| `validator.py` | §5.2 hard rule — SSID/PSK 바이트 길이·security·IPv4 등 dpworldapp 파서 한계 위반 차단 | |
|||
| `snapshot.py` | apply 전 백업 + manifest 해시 검증 + 롤백 복원(last-known-good) | |
|||
| `verifier.py` | 사후 검증(존재→carrier→주소·라우트→wpa_state→gateway ping). carrier 없음 = "config staged" WARN | |
|||
| `watchdog.py` | 상시 감시·자가복구(30s 틱, 히스테리시스·cooldown·시간당 한도). 적용본(`network_config.json`) 기준 | |
|||
| `journal.py` | JSONL 네트워크 이벤트 저널(5MB×3 로테이션, psk/password 마스킹) + forensic 번들 | |
|||
| `net_routes.py` | `/api/network/*` 라우트 글루(서버 독립적·dict in/out) | |
|||
| `ap_engine.py` | **Wi-Fi AP** apply 오케스트레이션 — hostapd/udhcpd conf 렌더 + `dpworld-ap-apply.service` 트리거 + 상태 persist. SCC(STA 채널 추종)·marker kill-switch | |
|||
| `ap_model.py` | `ap_config` DB 키 ↔ 정규화 intent (별도 board_config 키, `device_config` 무관) | |
|||
| `ap_renderer.py` | intent → `hostapd-ap0.conf` / `udhcpd-ap0.conf` 렌더(WPA2-PSK) | |
|||
| `ap_validator.py` | AP 필드 hard rule(SSID/PSK/채널/country) | |
|||
| `ap_routes.py` | `/api/network/ap/*` 라우트 글루. status 응답에서 `ap_passphrase` 제거(미인증 API) | |
|||
|
|||
> 상세는 §5 API 표(Network Apply) 및 Wi-Fi AP는 [`wifi-ap-guide.md`](wifi-ap-guide.md) 참조. |
|||
|
|||
### 3.1.2 `src/firmware/` — Firmware OTA (v1.5.0 Phase 4a~) |
|||
|
|||
dpworldapp 의 FW-MMI 채널(TCP **8990**)로 펌웨어 컴포넌트를 staging→flash 하는 OTA 서브시스템. 플래시 전 config 백업, 재부팅 후 복원 검사까지 오케스트레이션한다. |
|||
|
|||
| 모듈 | 책임 | |
|||
|------|------| |
|||
| `fw_controller.py` | 상태머신·8단계 phase stepper(upload·verify·preflight·backup·flash·commit·reboot·config) + 컴포넌트별 진행률. `FirmwareController` 단일 인스턴스, thread-safe | |
|||
| `staging.py` | 업로드 ZIP 추출·컴포넌트 식별(.rom/.img/.ext4/.dtb)·sha256·free-space 확인 | |
|||
| `protocol.py` | dpworldapp FW-MMI 와이어 프로토콜(3바이트 signature + 8바이트 LE size + payload, ACK `SUCCESS`/`FW_FAIL`) | |
|||
| `fw_client.py` | TCP 8990 wire client — 컴포넌트 전송 + ACK 분류 + 진행 콜백 | |
|||
| `config_safety.py` | 플래시 전 `device_config`/`protocol_config` 백업, 재부팅 후 factory-default reseed 감지 → 원자적 복원(단일 BEGIN IMMEDIATE 트랜잭션) | |
|||
| `fw_routes.py` | `/api/firmware/*` 라우트 글루 — 스트리밍 ZIP 업로드(디스크 `/opt` 버퍼, tmpfs 금지) | |
|||
|
|||
> 상세는 [`firmware-ota-guide.md`](firmware-ota-guide.md) 참조. |
|||
|
|||
### 3.2 Frontend (`src/static/js/`) |
|||
|
|||
- **Core**: `app.js`(라우팅·초기화·저장), `api.js`(fetch 래퍼), `state.js`(상태·flat↔nested 변환), `view-mode.js`(**Two Faces** — User 기본 / Advanced 토글, body 클래스로 `.user-only`/`.advanced-only` 구동) |
|||
- **Shared**: `validator.js`, `utils.js`(`escapeHtml`), `toast.js`, `constants.js`(기본값 + `APP_VERSION`), `country-codes.js`(WiFi 국가코드) |
|||
- **`components/`**: `crud-table.js`, `ip-input.js` |
|||
- **`pages/`** (~18개): `home`(Dashboard), `wifi`, `wifi-ap`, `ssid`, `ethernet`, `network`/`server-setting`, `general-settings`, `io`/`sensor-io`, `can`/`can-bus`, `modbus`, `opcua`, `register`, `log`, `firmware`(OTA), `net-apply`(Apply & Status — **Advanced 전용**) |
|||
- **Two Faces (v1.8.0)**: 기본 **User** 보기(운영자용 간소 동선)와 **Advanced** 보기(네트워크 Apply & Status 등 고급 기능)를 토글한다. `net-apply` 같은 Advanced 전용 페이지는 User 모드에서 nav 숨김 + 라우트 가드로 이중 차단 |
|||
|
|||
--- |
|||
|
|||
## 4. Data Flow |
|||
|
|||
### 4.1 설정 조회 / 저장 |
|||
|
|||
```mermaid |
|||
sequenceDiagram |
|||
participant B as Browser |
|||
participant P as Python Web Configurator |
|||
participant D as SQLite DB |
|||
|
|||
B->>P: GET /setting/get-device |
|||
P->>D: SELECT device_config, log_config |
|||
P-->>B: 200 — 두 키를 병합한 단일 JSON |
|||
|
|||
B->>P: POST /setting/device {JSON} |
|||
P->>P: 검증 · None 제거 · 로그설정 분리 |
|||
P->>D: device_config 저장 (로그설정 제외) |
|||
P->>D: log_config 저장 (로그설정 4개) |
|||
P-->>B: 200 success |
|||
``` |
|||
|
|||
> **로그 설정 분리(v1.0.2)**: 클라이언트는 변함없이 하나의 device config를 |
|||
> 주고받지만, `server.py`가 저장 시 로그 자동압축/정리 설정 4개를 `log_config` |
|||
> 키로 떼어내고, 조회 시 다시 합쳐서 내려준다. 이유는 §6.3. |
|||
|
|||
### 4.2 로그 자동압축 데몬 |
|||
|
|||
`main()`이 백그라운드 데몬 스레드를 띄운다. 5분마다: |
|||
1. `log_config`에서 `log_auto_compress` / `log_auto_cleanup` 확인 |
|||
2. 켜져 있으면 — 비활성(active 아님) 로그를 `<로그명>.<mtime>.tar.gz`로 압축, |
|||
오래된 압축 아카이브를 임계치까지 정리 |
|||
|
|||
--- |
|||
|
|||
## 5. API Contract |
|||
|
|||
기존 Java 앱과 호환되는 `/setting/*` 엔드포인트 + 신규 `/api/*` 엔드포인트. |
|||
|
|||
| Method | Path | 핸들러 | 용도 | |
|||
|--------|------|--------|------| |
|||
| `GET` | `/setting/get-device` | `_handle_get_device` | device + log config 조회(병합) | |
|||
| `GET` | `/setting/get-protocol` | `_handle_get_protocol` | protocol config 조회 | |
|||
| `GET` | `/setting/log-files` | `_handle_get_log_files` | 로그 파일 목록 | |
|||
| `GET` | `/setting/log-stats` | `_handle_get_log_stats` | 로그 디스크 사용량 통계 | |
|||
| `GET` | `/setting/kernel-bundle` | `_handle_get_kernel_bundle` | **(v1.1.0)** 커널 로그 번들 `.tar.gz` 즉석 생성·스트리밍 (journalctl 텍스트 export + 평문 커널 로그 + boot-history + pstore + manifest) | |
|||
| `GET` | `/api/health` | `_handle_health` | 서버 헬스(uptime·DB 백엔드) | |
|||
| `GET` | `/api/mac` | `_handle_get_mac` | WiFi MAC (장비 식별) | |
|||
| `GET` | `/api/system-status` | `_handle_system_status` | Home 대시보드 종합 상태. 응답: `core_app`·`communication`·`network`·`system` (v1.0.x) + `hardware_modules` (v1.2.0, GNSS FW + WiFi FW) + `dpworldapp_status` (v1.3.0, 7 phases + result + runtime config). 모두 top-level key, 후방 호환 | |
|||
| `GET` | `/api/support-bundle` | `_handle_support_bundle` | 진단 zip 다운로드 | |
|||
| `POST` | `/setting/device` | `_handle_post_device` | device config 저장(로그설정 분리) | |
|||
| `POST` | `/setting/protocol` | `_handle_post_protocol` | protocol config 저장 | |
|||
| `POST` | `/setting/log-download` | `_handle_post_log_download` | 선택 로그 tar.gz 다운로드 | |
|||
| `POST` | `/setting/log-compress` | `_handle_post_log_compress` | 수동 압축 | |
|||
| `POST` | `/setting/log-delete` | `_handle_post_log_delete` | 선택 로그 삭제 | |
|||
| `POST` | `/api/action/test-connections` | `_handle_test_connections` | 설정된 서버 TCP 연결 테스트 | |
|||
| `POST` | `/api/action/restart-dpworldapp` | `_handle_restart_dpworldapp` | dpworldapp 재시작 | |
|||
|
|||
#### Firmware OTA (v1.5.0 Phase 4a) — 상세 [`firmware-ota-guide.md`](firmware-ota-guide.md) |
|||
|
|||
| Method | Path | 용도 | |
|||
|--------|------|------| |
|||
| `GET` | `/api/firmware/status` | OTA 상태 스냅샷(phase stepper + 컴포넌트 진행률 + slot) | |
|||
| `POST` | `/api/firmware/preflight` | 플래시 전 게이트 체크리스트(staging·여유공간·FW 포트·DB·slot) | |
|||
| `POST` | `/api/firmware/upload` | 펌웨어 ZIP 스트리밍 업로드 → staging(`/opt` 버퍼) | |
|||
| `POST` | `/api/firmware/flash` | staged 컴포넌트 플래시 시작(백그라운드 워커) | |
|||
| `POST` | `/api/firmware/restore-check` | 재부팅 후 config reseed 감지 → 복원 + dpworldapp 재시작 | |
|||
|
|||
#### Network Apply (v1.6.0) — 상세 per-subsystem 가이드 |
|||
|
|||
| Method | Path | 용도 | |
|||
|--------|------|------| |
|||
| `GET` | `/api/network/state` | 현재 적용 상태머신 스냅샷 | |
|||
| `GET` | `/api/network/drift` | DB ↔ `network_config.json` 22항목 미적용 drift 카운트(경량) | |
|||
| `GET` | `/api/network/apply/status` (`?id=`) | 특정 apply 진행 상태 | |
|||
| `GET` | `/api/network/journal` (`?limit=`) | 네트워크 이벤트 저널 tail | |
|||
| `GET` | `/api/network/config` | watchdog 등 효과적 `net_config` 조회 | |
|||
| `POST` | `/api/network/apply` | 22항목 적용 시작(즉시 적용 + confirm 대기) | |
|||
| `POST` | `/api/network/apply/confirm` | 적용 확정(미확정 시 TTL 만료 → 자동 롤백) | |
|||
| `POST` | `/api/network/rollback` | last-known-good 롤백 | |
|||
| `POST` | `/api/network/config` | watchdog kill-switch 등 `net_config` 쓰기 | |
|||
|
|||
#### Wi-Fi AP (v1.11.x) — 상세 [`wifi-ap-guide.md`](wifi-ap-guide.md) |
|||
|
|||
| Method | Path | 용도 | |
|||
|--------|------|------| |
|||
| `GET` | `/api/network/ap/status` | AP 라이브 상태(`ap_passphrase` 제거됨) | |
|||
| `POST` | `/api/network/ap/config` | `ap_config` 저장(필드 화이트리스트 merge) | |
|||
| `POST` | `/api/network/ap/apply` | AP bring-up/down 적용(또는 `dry_run`) | |
|||
|
|||
> 네트워크/AP 서브시스템 import 실패 시(예: Windows dev box) 해당 라우트는 503 으로 fail-soft. |
|||
|
|||
`OPTIONS`는 CORS preflight(204). 알 수 없는 `/api/`·`/setting/` 경로는 404(SPA 폴백 마스킹 방지, v1.5.0). 그 외 GET은 SPA 정적파일 폴백. |
|||
|
|||
--- |
|||
|
|||
## 6. Database — `board_config` 소유권 ⭐ |
|||
|
|||
`~/db/dynamic_data.db`는 **세 프로그램이 공유**한다. 이 절은 **무엇이 누구 |
|||
소유인지** 명확히 한다 — 잘못 건드리면 다른 앱의 데이터가 깨진다. |
|||
|
|||
### 6.1 테이블 |
|||
|
|||
```sql |
|||
CREATE TABLE board_config (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); |
|||
CREATE TABLE event_history (id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL); |
|||
``` |
|||
|
|||
- `board_config` — Key-Value 설정 저장소. Python 이 쓰는 키는 **5개** |
|||
(`db_manager.ALLOWED_KEYS`), 그 외 다른 프로그램 소유 키도 같은 테이블에 공존 |
|||
- `event_history` — 이벤트 큐. **dpworldapp이 WRITE**, Web Configurator는 건드리지 않음 |
|||
|
|||
### 6.2 `board_config` 키별 소유권 — 우리 / Java / dpworldapp |
|||
|
|||
| key | 쓰기 주체 | 소유 구분 | |
|||
|-----|----------|----------| |
|||
| `device_config` | **Python Web Configurator** + **레거시 Java 앱** | ⚠️ **공유(경합)** | |
|||
| `protocol_config` | **Python Web Configurator** + **레거시 Java 앱** | ⚠️ **공유 계약** | |
|||
| `log_config` | **Python Web Configurator** | 🟦 우리 (**v1.0.2 신설**) | |
|||
| `net_config` | **Python Web Configurator** | 🟦 우리 (**v1.6.0** — watchdog 등 Network Apply 설정) | |
|||
| `ap_config` | **Python Web Configurator** | 🟦 우리 (**v1.11.x** — Wi-Fi AP 설정) | |
|||
| `security_config` | **Super_Relay 프로젝트** | 🟧 타 프로젝트 | |
|||
| `transport_config` | **Super_Relay 프로젝트** | 🟧 타 프로젝트 | |
|||
|
|||
- 🟦 **우리(Python Web Configurator)가 쓰는 것** — Python의 `db_manager.ALLOWED_KEYS`는 |
|||
`{device_config, protocol_config, log_config, net_config, ap_config}` (**5개**). Python 은 |
|||
이 5개만 쓴다. `device_config`·`protocol_config`는 dpworldapp/Java 와의 **공유 계약** |
|||
(Java schema 그대로 — [board_config 키 ownership 정책]), `log_config`·`net_config`·`ap_config`는 |
|||
**Python 전용**(Java/dpworldapp 가 읽지 않음). Python 전용 키를 별도로 둔 이유는 §6.3. |
|||
- 🟧 **다른 프로젝트(Super_Relay)가 쓰는 것** — `security_config`, `transport_config`. |
|||
사용자의 별도 프로젝트 **Super_Relay** (`c:/Development/Super_Relay`)가 자체 |
|||
`config_bridge.py`로 같은 `board_config` 테이블에 기록한다 — 릴레이 전송 설정 |
|||
(`transport_config`: `relay_enabled`/`type`/`base_url`; `security_config`: |
|||
Bearer 인증 토큰). NEW Web Configurator는 이 두 키를 읽지도 쓰지도 않으며, |
|||
레거시 Java 앱과도 무관하다. (2026-05-22 검증: DB의 JSON 내용 + |
|||
Super_Relay 프로젝트 grep으로 확인.) |
|||
- `event_history` — **dpworldapp** 소유. |
|||
|
|||
### 6.3 ⚠️ `device_config`는 공유 행 — 그래서 `log_config`가 생겼다 |
|||
|
|||
`device_config`는 Python Web Configurator와 레거시 Java 앱이 **둘 다 쓴다**. |
|||
Java 앱이 자기 데이터 모델로 `device_config`를 저장하면 **Java가 모르는 |
|||
Python 전용 필드가 통째로 사라진다.** |
|||
|
|||
이 때문에 로그 자동압축/정리 설정(`log_auto_compress`, `log_auto_cleanup`, |
|||
`log_cleanup_max_files`, `log_cleanup_max_size_mb`)이 실제로 날아갔고 |
|||
자동압축 데몬이 조용히 멈췄다. **해결(v1.0.2)**: 이 4개를 Java가 안 건드리는 |
|||
별도 `log_config` 키로 분리. board_config 키 소유권은 `db_manager.ALLOWED_KEYS`를 참고. |
|||
|
|||
> **원칙**: 앞으로 추가하는 **Python 전용 설정은 `device_config`/`protocol_config`에 |
|||
> 넣지 말 것.** Java/dpworldapp 가 덮어쓴다. 별도 키를 만들고 `db_manager.ALLOWED_KEYS`에 |
|||
> 등록한다 — `log_config`(v1.0.2)·`net_config`(v1.6.0)·`ap_config`(v1.11.x)가 이 패턴이다. |
|||
|
|||
### 6.4 db_manager 3-tier 백엔드 |
|||
|
|||
`db_manager.py`는 환경에 따라 백엔드를 자동 선택한다: ① Python `sqlite3` |
|||
모듈(기본), ② `sqlite3` CLI(subprocess), ③ JSON 파일 폴백(임시용). `save_config`은 |
|||
"database is locked" 시 신선한 커넥션으로 재시도한다(v1.0.1). |
|||
|
|||
--- |
|||
|
|||
## 7. Directory Structure |
|||
|
|||
``` |
|||
NEW_Web_Configurator/ |
|||
├── src/ ← 애플리케이션 (배포 단위) |
|||
│ ├── server.py ← 진입점, HTTP 서버 |
|||
│ ├── db_manager.py ← SQLite 3-tier 접근 |
|||
│ ├── config_validator.py ← 서버 검증 |
|||
│ ├── enum_normalizer.py ← enum case-only 정규화 |
|||
│ ├── dpworldapp_enums.py ← enum 허용집합 (device 계약) |
|||
│ ├── dpworldapp_telemetry.py ← TCP 8989 telemetry 파서 |
|||
│ ├── migrations.py ← 기동 시 1회 마이그레이션 |
|||
│ ├── log_manager.py ← 로그 관리 + 자동압축 데몬 |
|||
│ ├── kernel_log.py ← 커널 로그 번들 |
|||
│ ├── system_status.py ← 대시보드 상태 집계 |
|||
│ ├── support_bundle.py ← 진단 zip |
|||
│ ├── network/ ← Network Apply Engine + Wi-Fi AP |
|||
│ │ ├── apply_engine·netmodel·renderer·validator |
|||
│ │ ├── snapshot·verifier·watchdog·journal·net_routes |
|||
│ │ └── ap_engine·ap_model·ap_renderer·ap_validator·ap_routes |
|||
│ ├── firmware/ ← Firmware OTA (TCP 8990) |
|||
│ │ └── fw_controller·fw_client·protocol·staging·config_safety·fw_routes |
|||
│ └── static/ |
|||
│ ├── index.html |
|||
│ ├── css/style.css |
|||
│ ├── img/dp-world-logo.svg |
|||
│ └── js/ app·api·state·view-mode·validator·utils·toast·constants·country-codes |
|||
│ ├── components/ crud-table·ip-input |
|||
│ └── pages/ home·wifi·wifi-ap·ssid·ethernet·io·can·modbus· |
|||
│ opcua·register·log·firmware·net-apply· … (~18) |
|||
├── deploy/ ← systemd 유닛 + AP/network apply 셸 스크립트 |
|||
├── tests/ ← pytest(~1557) + node/jsdom(~392) 스위트 |
|||
├── scripts/deploy.ps1 ← 버전 태그 기반 디바이스 배포 스크립트 |
|||
├── docs/ ← 문서 (본 문서, wifi-ap-guide, firmware-ota-guide, specs/, …) |
|||
├── CHANGELOG.md README.md .gitignore |
|||
``` |
|||
|
|||
> 디바이스 배포 위치: `/opt/web-configurator/src/` (systemd가 구동, port 9090). |
|||
> `deploy/`의 systemd 유닛·셸 스크립트는 rootfs `/lib/systemd` 등에 설치되며 **flash 마다 wipe** |
|||
> 된다(§10 caveat, [`wifi-ap-guide.md`](wifi-ap-guide.md) 참조). |
|||
|
|||
--- |
|||
|
|||
## 8. Versioning & Deployment |
|||
|
|||
- **버전 체계**: `vMAJOR.MINOR.PATCH` 시맨틱 버전. 단일 소스 = `APP_VERSION` |
|||
(`src/static/js/constants.js`), 사이드바 표시. 현재 **v1.11.10**. |
|||
- **릴리스**: `APP_VERSION` 갱신 → `CHANGELOG.md` 항목 → 커밋 → `git tag vX.Y.Z`. |
|||
- **배포**: `scripts/deploy.ps1 <device-ip>` — 버전 태그 검사 → `src/` 아카이브 |
|||
전송 → 장비 백업 → 스왑 → `DEPLOYED_VERSION` 기록 → systemd 재시작 → 검증. |
|||
절차·롤백 상세는 [`DEPLOY.md`](DEPLOY.md). |
|||
|
|||
--- |
|||
|
|||
## 9. 기술 제약사항 |
|||
|
|||
| 항목 | 제약 | 대응 | |
|||
|------|------|------| |
|||
| Python | stdlib만 (pip 없음) | `http.server`·`sqlite3`·`json`·`tarfile` 등 | |
|||
| 메모리 | 목표 경량 | 레거시 Java ~436MB → Python ~15–30MB | |
|||
| 빌드 도구 | Node/npm 없음 | Vanilla ES-module JS (빌드 불필요) | |
|||
| 공유 DB | Java 앱·dpworldapp과 SQLite 공유 | §6 소유권 규칙 준수, 별도 키 분리 | |
|||
| 디바이스 구동 | systemd `web-configurator.service` :9090 | `DEPLOY.md` 참고 | |
|||
|
|||
--- |
|||
|
|||
## 10. 보안 고려사항 |
|||
|
|||
| 현재 상태 | 비고 | |
|||
|-----------|------| |
|||
| **인증 없음** | HTTP API(:9090)는 현재 **완전 미인증** — 17개 mutation endpoint + wildcard CORS. 이것이 **문서화된 threat boundary**(폐쇄 LAN 단일 운영자 전제). 운영자 로그인은 설계 완료·구현 백로그 상태. | |
|||
| CORS 전체 허용 | 의도된 설계 (IoT LAN 전용) | |
|||
| HTTP only | TLS 미구현. 운영자 로그인 spec 의 known-limitation(평문) 참조 | |
|||
| SQL Injection | `db_manager`가 키 화이트리스트(`ALLOWED_KEYS`)로 방어 | |
|||
| XSS | 출력은 `escapeHtml` 경유 | |
|||
| Support bundle | config 내 WiFi 비밀번호 마스킹 후 포함 | |
|||
| API 응답 마스킹 | AP `status`/`apply(dry_run)` 응답에서 `ap_passphrase` 제거. `/api/health` 는 db path/pid 미노출 | |
|||
| Wi-Fi AP 접근 게이트 | v1.11.7 이후 AP INPUT 전면 개방(SSH 포함) — **WPA2 PSK 가 게이트**. FORWARD 차단으로 PLC/업링크 격리([`wifi-ap-guide.md`](wifi-ap-guide.md)) | |
|||
|
|||
> **이전 메커니즘 제거됨**: 과거 문서가 언급하던 systemd `WEB_AUTH_USER/PASS`(옵션 Basic 인증) |
|||
> 및 `WEB_SSL_CERT/KEY`(옵션 HTTPS) 환경변수 인증은 **현재 코드/유닛에 존재하지 않는다**. |
|||
> 인증 방향은 위의 operator-login 설계로 대체되었다(아직 미구현). |
|||
|
|||
--- |
|||
|
|||
## 11. 변경 이력 |
|||
|
|||
| 일자 | 버전 | 변경 | |
|||
|------|------|------| |
|||
| 2026-02-12 | doc v1.0 | 초기 작성 | |
|||
| 2026-02-19 | doc v1.1 | 디렉터리·보안·건강상태 섹션 | |
|||
| 2026-05-22 | doc v2.0 | 앱 v1.0.2 기준 전면 갱신 — 실제 배포 구조(systemd :9090), 6개 백엔드 모듈·9개 페이지, 전체 API 라우트, §6 board_config 소유권(우리/Java/Super_Relay/dpworldapp 구분), `log_config` 분리, 버전관리·배포 섹션 | |
|||
| 2026-05-26 | doc v2.1 | 앱 v1.1.0 — Kernel Log Bundle 기능 — 신규 모듈 `kernel_log.py` (§3.1), 신규 엔드포인트 `GET /setting/kernel-bundle` (§5). `/opt/log/journal/`은 journald 소유로 읽기 전용 처리(텍스트 export). 임시작업 디렉터리는 `/opt` 디스크에 (메모리 제약 장비에서 `/tmp` tmpfs 회피). DB 스키마·소유권 변경 없음 | |
|||
| 2026-05-28 | **doc v2.2** | **앱 v1.2.0 → v1.2.1 → v1.3.0 누적 반영**. v1.2.0 Hardware Modules 카드 — Home 대시보드에 GNSS·WiFi 펌웨어 버전 표시 (`amss.bin` `QC_IMAGE_VERSION_STRING` 추출 + dpworldapp 로그 `[GNSS FW VER]` 스캔), `/api/system-status`에 `hardware_modules` 키 추가. v1.2.1 scanner 견고화 — 64KB chunk-boundary silent miss + 10MB cap 두 결함을 line-iter 기반으로 일괄 해결, locale-independent ISO date 파싱. v1.3.0 dpworldapp Status Tracker — 7 startup phase (FRAM→Steady) + 5 result 상태 + 6 runtime config 필드, `/api/system-status`에 `dpworldapp_status` 키 추가. 모두 module-level cache + `threading.Lock`. §3.1 `system_status.py` 책임 갱신, §5 payload key 목록 갱신. DB 스키마·소유권 변경 없음 | |
|||
| 2026-06-20 | **doc v3.0** | **앱 v1.11.10 전면 동기화** (v1.4~v1.11 누적). 신규 백엔드 서브시스템 2종 — `src/network/`(Network Apply Engine v1.6.0 + Wi-Fi AP v1.11.x)·`src/firmware/`(Firmware OTA v1.5.0 Phase 4a) 및 신규 `src/*.py`(enum_normalizer·dpworldapp_enums·dpworldapp_telemetry·migrations) §3.1 추가. §5 API 표에 firmware/network/AP 엔드포인트 추가. §6 `ALLOWED_KEYS` 5키로 정정(`net_config`·`ap_config` Python 전용). §3.2 프론트 ~18 페이지 + Two Faces(User/Advanced). §7 디렉터리 트리 갱신. §10 보안 — 제거된 `WEB_AUTH_USER/PASS`/`WEB_SSL` 언급 삭제, API 미인증=문서화된 threat boundary 명시 + operator-login 설계(백로그) 링크. 신규 가이드 [`wifi-ap-guide.md`]·[`firmware-ota-guide.md`] 분리 | |
|||
|
|||
> 관련 문서: [`DEPLOY.md`](DEPLOY.md) (배포·버전 런북) · |
|||
> [`CHANGELOG.md`](../CHANGELOG.md) (릴리스 변경 이력) |
|||
@ -0,0 +1,850 @@ |
|||
# NEW Web Configurator v1.5.4.2 — 시스템 아키텍처 |
|||
|
|||
**문서 버전**: 1.0 |
|||
**앱 버전**: v1.5.4.2 (SHA `fe5cd25`, 태그 `v1.5.4.2`) |
|||
**브랜치**: `feature/v1.5-ia-redesign` (origin 푸시 완료) |
|||
**작성일**: 2026-06-08 |
|||
**상태**: dev/verify 환경 .54 (192.168.55.54), 운영 .56은 v1.4.6.10 유지 |
|||
**상위 문서**: `docs/architecture.md` (v2.2, v1.3.0 시대까지 커버) — 이 문서가 해당 문서를 대체함 |
|||
|
|||
--- |
|||
|
|||
## 1. 요약 |
|||
|
|||
**NEW Web Configurator**는 Telechips TCC8030 IoT 디바이스를 위한 브라우저 기반 설정 도구다. **Python stdlib(표준 라이브러리) 전용 HTTP 서버** (pip 없음, 프레임워크 없음)와 **바닐라 ES 모듈 SPA** (빌드 단계 없음, 번들러 없음)로 구성된다. 기존 Java/Spring Boot 기반 설정 도구를 대체한 것으로, 해당 도구는 TCC8030의 700 MHz ARM에서 메모리를 436 MB 이상 점유했다 — 각 백그라운드 서비스에 48 MB MemoryMax 예산이 배정된 디바이스에서. Python 프로세스는 RSS 기준 10–30 MB 수준으로 동작한다. |
|||
|
|||
핵심 설계 원칙: |
|||
|
|||
1. **LAN 전용 IoT 환경** — 외부 노출 없음, 인증 없음. 모든 클라이언트는 192.168.55.x (디바이스 LAN 세그먼트) 내부에 있다. CORS는 설계상 전체 개방. |
|||
2. **SQLite 공유 소유권** — 세 개의 독립 프로그램(이 서비스, 레거시 Java app-runner, `dpworldapp` 데이터 처리 바이너리)이 동일한 `~/db/dynamic_data.db`를 읽고 쓴다. 키 수준 소유권 정책(`board_config_key_ownership_policy`)이 주된 안전 메커니즘이다. |
|||
3. **config-reader 계약 준수** — `device_config`와 `protocol_config`는 디바이스 config-reader 계약과 정확히 일치해야 한다. 이를 통해 레거시 Java 앱이 .56에서 읽고 쓰는 동작이 예기치 않게 깨지지 않도록 보장한다. 자동화된 드리프트 감지가 이를 강제한다. |
|||
4. **Partial-merge(부분 병합)만 허용** — 전체 행 REPLACE 없음. `DBManager.update_config` (BEGIN IMMEDIATE RMW)가 모든 POST에서 미설정 키를 보존하도록 보장한다. |
|||
5. **v1.5.x IA 재설계** — 사이드바 중첩 5그룹 네비게이션, Firmware OTA 실제 통합, Lucide SVG 아이콘, Inter/JetBrains Mono 셀프 호스팅 폰트, gzip + ETag/304 성능 최적화, behavioral(jsdom) 테스트 인프라. |
|||
|
|||
--- |
|||
|
|||
## 2. 하드웨어 컨텍스트 (Telechips TCC8030) |
|||
|
|||
| 항목 | 값 | |
|||
|---|---| |
|||
| CPU | 700 MHz ARM (싱글코어 주, 멀티코어 가능) | |
|||
| OS | Yocto/Poky 4.0.17 (`uname -r`: Telechips) | |
|||
| 펌웨어 업데이트 | A/B 파티션 슬롯 (`slot A` / `slot B`, dpw-fw-update-tool 경유) | |
|||
| 영속 파티션 | `/home/root/db` (mmcblk0p11) — SQLite DB; `/opt/log` (mmcblk0p12) — dpworldapp 로그 | |
|||
| 임시 파티션 | `/tmp` — tmpfs, 서비스별 격리 슬라이스 PrivateTmp=yes | |
|||
| `/etc/systemd/system` | **tmpfs** — 여기에 작성된 유닛 파일은 **재부팅 시 소실됨** | |
|||
| systemd 유닛 위치 | `/lib/systemd/system/web-configurator.service` (영속, v1.5.2 H17 수정) | |
|||
| MemoryMax | 48 MB (서비스 유닛에 설정) | |
|||
| Web Configurator 포트 | **9090** (직접 연결, 앞단 nginx 없음) | |
|||
| Java app-runner 포트 | **8080** (.56에서 nginx :80 → Java :8080) | |
|||
| WiFi 칩셋 | Qualcomm QCA6490 (PCI 17CB:1103, 드라이버 cnss_pci) | |
|||
| 펌웨어 바이너리 | `/lib/firmware/amss.bin` (WiFi FW 버전은 `QC_IMAGE_VERSION_STRING=`으로 추출) | |
|||
| dpworldapp 바이너리 | `/usr/bin/dpworldapp` — CAN/Modbus/OPC-UA 데이터 처리기 | |
|||
| GNSS | u-blox GPS (버전은 dpworldapp 로그 `[GNSS FW VER : ...]`에서 확인) | |
|||
| 앱 로그 경로 | `/opt/log/dpworldapp/dpworldapp_YYYY-MM-DD.log` | |
|||
|
|||
**tmpfs 인시던트** (2026-05-29): Poky 4.0.17에서 `/etc/systemd/system/`은 tmpfs다. v1.4.0 배포 시 유닛을 `/etc/`에 작성했고, 다음 재부팅 전까지는 유지되었다. v1.5.2 H17에서 deploy.ps1이 `/lib/systemd/system/`(영속)에 쓰도록 수정했다. |
|||
|
|||
**ReadWritePaths**: systemd 유닛은 `ProtectSystem=strict`를 사용한다. 서비스가 쓰기 접근해야 하는 경로는 `ReadWritePaths`에 명시적으로 나열해야 한다: |
|||
- `/opt/log/dpworldapp` — 로그 다운로드 임시 부모 경로 (v1.4.6.6에서 `/tmp`로 수정) |
|||
- `/opt/fw_staging` — 펌웨어 스테이징 디렉토리 (v1.5.2 C1) |
|||
- `/opt/config_backups` — 플래시 전 설정 백업 (v1.5.2 C1) |
|||
- `/home/root/db` — SQLite DB 경로 |
|||
|
|||
--- |
|||
|
|||
## 3. 기술 스택 |
|||
|
|||
### 백엔드 |
|||
|
|||
| 컴포넌트 | 기술 | |
|||
|---|---| |
|||
| HTTP 서버 | Python 3.10+ `http.server.BaseHTTPRequestHandler` + `socketserver.ThreadingMixIn` | |
|||
| 데이터베이스 | `sqlite3` stdlib | |
|||
| 압축 | `gzip` stdlib (응답 압축) | |
|||
| 설정 | `os.environ` + 상수 | |
|||
| 의존성 | **Python stdlib 전용** (pip 없음, 서드파티 패키지 없음) | |
|||
| 진입점 | `src/server.py:main()` | |
|||
|
|||
### 프론트엔드 |
|||
|
|||
| 컴포넌트 | 기술 | |
|||
|---|---| |
|||
| JS 모듈 시스템 | 바닐라 ES 모듈 (`type="module"`, 번들러 없음) | |
|||
| 아이콘 | Lucide v0.460+ SVG 인라인 (`src/static/js/icons.js`, 49개 항목) | |
|||
| 타이포그래피 | Inter (5 웨이트) + JetBrains Mono (2 웨이트) — `.woff2` 셀프 호스팅 (`src/static/fonts/`) | |
|||
| 스타일시트 | 단일 `src/static/css/style.css` (~3000줄 이상) | |
|||
| XSS 이스케이프 | `src/static/js/utils.js`의 `escapeHtml` (5문자 명시 치환: `& < > " '`) | |
|||
|
|||
### 테스트 |
|||
|
|||
| 컴포넌트 | 기술 | |
|||
|---|---| |
|||
| Python 테스트 | `pytest` (v1.5.4.2 기준 644개 테스트) | |
|||
| JS behavioral 테스트 | Node 18+ `node:test` + `jsdom` (4개 `.mjs` 파일에 35개 테스트) | |
|||
| 듀얼 러너 | `python -m pytest` + `npm test` | |
|||
| 디바이스 배포 격리 | `deploy.ps1`이 `git archive HEAD src` 사용 — tests/node_modules/package.json은 배포 대상 아님 | |
|||
|
|||
### 배포 |
|||
|
|||
- PowerShell `scripts/deploy.ps1` — git archive → scp → tar → atomic mv → systemctl restart |
|||
- 헬스 체크 + 실패 시 자동 롤백 (v1.5.2 H18) |
|||
- `/lib/systemd/system/` 유닛 설치 (v1.5.2 H17 영속화) |
|||
|
|||
--- |
|||
|
|||
## 4. 프로세스 모델 |
|||
|
|||
``` |
|||
systemd web-configurator.service |
|||
└── Python process (server.py main()) |
|||
├── ThreadedHTTPServer (ThreadingMixIn + HTTPServer) |
|||
│ └── ConfigHandler per thread (BaseHTTPRequestHandler) |
|||
├── Log auto-compress daemon thread (5-min interval) |
|||
└── _recover_staging background daemon thread (firmware, v1.5.2 M10/M15) |
|||
``` |
|||
|
|||
- `socket.setdefaulttimeout(30.0)` — 글로벌 slowloris 방어 (v1.5.1 H4). |
|||
비활성 상태 30초 후 모든 연결이 타임아웃된다. |
|||
- `/api/firmware/upload` 라우트는 대용량 펌웨어 파일 업로드 완료를 허용하기 위해 연결별로 `connection.timeout = None`으로 재설정한다 (v1.5.2 H20). |
|||
- `ThreadedHTTPServer.timeout = 30.0` — 서버 수준 accept 타임아웃. |
|||
- 서버 프로세스당 `DBManager` 인스턴스 하나 (`server.py:89`의 모듈 수준 `db = DBManager()`). 각 public 메서드는 SQLite 연결을 건드리기 전에 `self._lock` (threading.Lock)을 획득한다. |
|||
|
|||
--- |
|||
|
|||
## 5. 데이터 레이어 |
|||
|
|||
### 데이터베이스 파일 |
|||
|
|||
``` |
|||
/home/root/db/dynamic_data.db (mmcblk0p11, 영속 파티션) |
|||
``` |
|||
|
|||
### 테이블 |
|||
|
|||
| 테이블 | 소유자 | 목적 | |
|||
|---|---|---| |
|||
| `board_config` | 공유 (Python + Java + dpworldapp + Super_Relay) | 키-값 설정 저장소 | |
|||
| `event_history` | dpworldapp | 이벤트 큐 — Web Configurator는 쓰기 안 함 | |
|||
| `schema_meta` | Python Web Configurator | 마이그레이션 플래그 (멱등 시작) | |
|||
| `sqlite_sequence` | SQLite 내부 | AUTOINCREMENT 추적 | |
|||
| `board_config_audit` | Python Web Configurator | 설정 변경 감사 로그 | |
|||
|
|||
### board_config 키 — 소유권 |
|||
|
|||
| 키 | 소유자 | Java 접근 여부 | dpworldapp 읽기 여부 | 비고 | |
|||
|---|---|---|---|---| |
|||
| `device_config` | Python + Java **공유** | 예, 전체 행 REPLACE | 예 | device config-reader 계약 적용 — 37개 필드 | |
|||
| `protocol_config` | Python 주 | 예 (.56 구버전) | 예 | device register-mapping 계약 — ~21개 필드 | |
|||
| `log_config` | Python 전용 | 아니오 | 아니오 | Web Configurator 로그 관리 — v1.0.2 | |
|||
| `security_config` | Super_Relay 프로젝트 | 아니오 | 아니오 | 외부 프로젝트 — 건드리지 말 것 | |
|||
| `transport_config` | Super_Relay 프로젝트 | 아니오 | 아니오 | 외부 프로젝트 — 건드리지 말 것 | |
|||
|
|||
**핵심 원칙**: Python 전용 설정은 반드시 별도 키에 넣어야 한다(`device_config`/`protocol_config` 금지). Java의 전체 행 REPLACE가 모든 Java POST 시 Python 전용 필드를 조용히 삭제하기 때문이다. |
|||
|
|||
### DBManager 백엔드 (db_manager.py) |
|||
|
|||
우선순위 체인: |
|||
1. `python` — `sqlite3` stdlib 모듈 (권장; 운영 환경에서 사용) |
|||
2. `cli` — subprocess 경유 `sqlite3` CLI (import 실패 시 폴백) |
|||
3. `json` — JSON 파일 폴백 (테스트/긴급 용도만; dpworldapp 호환 없음) |
|||
|
|||
**Atomic RMW** (`db_manager.py:226` `update_config`): |
|||
- `python` 백엔드: 단일 연결 내에서 `BEGIN IMMEDIATE` + SELECT + `INSERT OR REPLACE` + COMMIT 수행. 동시 쓰기 차단. 지수 백오프로 잠금 재시도 (3회, 100 ms 기본). |
|||
- `cli` + `json` 백엔드: `self._lock`으로 직렬화. |
|||
- 결과: N개 동시 스레드 간 업데이트 유실 없음 (10스레드 + 20스레드 동시 증분 테스트 통과 확인 — `tests/test_v1_5_3_multi_instance.py`). |
|||
|
|||
### 마이그레이션 (src/migrations.py) |
|||
|
|||
시작 시 `apply_all_migrations(db)` (`server.py:728`)를 통해 적용됨. |
|||
Fail-soft: `(OperationalError, DatabaseError, JSONDecodeError, ValueError)` 예외를 잡아서 처리. |
|||
|
|||
| 마이그레이션 | schema_meta 키 | 수행 내용 | |
|||
|---|---|---| |
|||
| `migrate_can_baudrate_units` | `can_baudrate_units_v2` | 구 baudrate 값 2500/5000/10000 → 250/500/1000으로 재매핑 | |
|||
| `migrate_log_compress_split` | `log_compress_split_v1` | `log_compress_size_mb`/`log_compress_age_days`를 `device_config` → `log_config`로 이동 | |
|||
| `migrate_port_types` | `port_types_int_v1` | 문자열 타입 서버 포트를 Integer로 정규화 | |
|||
|
|||
--- |
|||
|
|||
## 6. 요청 라우팅 (server.py) |
|||
|
|||
### do_GET 라우트 |
|||
|
|||
| 경로 | 핸들러 | 설명 | |
|||
|---|---|---| |
|||
| `/setting/get-device` | `_handle_get_device` | `device_config`와 `log_config` 병합 반환 (log 필드 삽입) | |
|||
| `/setting/get-protocol` | `_handle_get_protocol` | `protocol_config` 반환 | |
|||
| `/setting/log-files` | `_handle_get_log_files` | dpworldapp 로그 파일 목록 | |
|||
| `/setting/kernel-bundle` | `_handle_get_kernel_bundle` | journalctl + 커널 로그 `.tar.gz` (v1.1.0) | |
|||
| `/setting/log-stats` | `_handle_get_log_stats` | 로그 디스크 사용량 통계 | |
|||
| `/api/mac` | `_handle_get_mac` | WiFi MAC 주소 (디바이스 식별) | |
|||
| `/api/health` | `_handle_health` | 서버 헬스 (uptime, DB 백엔드) | |
|||
| `/api/system-status` | `_handle_system_status` | 홈 대시보드: core_app / communication / network / system / hardware_modules / dpworldapp_status | |
|||
| `/api/support-bundle` | `_handle_support_bundle` | 진단 ZIP 다운로드 | |
|||
| `/api/firmware/status` | `_FW.status()` | Firmware OTA 상태 스냅샷 (v1.5.0 P4a) | |
|||
| 정적 파일 | `_serve_static_file` | Realpath 경로 순회 방어 + gzip + ETag + Cache-Control | |
|||
|
|||
### do_POST 라우트 |
|||
|
|||
| 경로 | 핸들러 | 설명 | |
|||
|---|---|---| |
|||
| `/setting/device` | `_handle_post_device` | device config partial-merge (atomic RMW) | |
|||
| `/setting/protocol` | `_handle_post_protocol` | protocol config partial-merge + `process_protocol_config` | |
|||
| `/setting/log-download` | `_handle_post_log_download` | 선택한 로그 파일 → `.tar.gz` | |
|||
| `/api/test-connections` | `_handle_test_connections` | 설정된 서버 엔드포인트 TCP 프로브 | |
|||
| `/api/restart-dpworldapp` | `_handle_restart_dpworldapp` | `systemctl restart dpworldapp` | |
|||
| `/api/firmware/upload` | `_FW.upload()` | 펌웨어 ZIP을 스테이징 디렉토리로 스트리밍 | |
|||
| `/api/firmware/preflight` | `_FW.preflight()` | 플래시 전 사전 점검 | |
|||
| `/api/firmware/flash` | `_FW.flash()` | 8단계 OTA 플래시 실행 | |
|||
| `/api/firmware/restore-check` | `_FW.restore_check()` | 재부팅 후 설정 무결성 검사 | |
|||
|
|||
### 라우팅 가드 |
|||
|
|||
- `/setting/` 접두사: 알 수 없는 경로 → 404 (SPA로 폴스루 없음) |
|||
- `/api/` 접두사: 알 수 없는 경로 → 404 (v1.5.0.1 수정, `server.py` do_GET) |
|||
- 정적 파일: `os.realpath()` + 접두사 검사 (경로 순회 차단, `server.py:237-239`) |
|||
- SPA 폴백: 나머지 모든 GET 경로에 `index.html` 반환 |
|||
|
|||
### 정적 파일 서빙 (v1.5.4) |
|||
|
|||
```python |
|||
# server.py:224 _serve_static_file() |
|||
|
|||
ETag = W/"mtime-size" # weak ETag (저비용, 해시 없음) |
|||
If-None-Match == ETag → 304 # 0바이트 body |
|||
|
|||
Cache-Control: |
|||
text/html → no-cache, must-revalidate |
|||
JS/CSS → no-cache, must-revalidate (v1.5.4.2 H1+M2: 즉시 배포 전파) |
|||
font/image → public, max-age=86400 |
|||
|
|||
gzip: |
|||
Accept-Encoding q값 파싱 (v1.5.4.2 L1) |
|||
압축 대상: text/*, application/javascript, application/json, image/svg+xml |
|||
제외: .woff2, 이미지, 256바이트 미만 |
|||
레벨: 6, Vary: Accept-Encoding 헤더 추가 |
|||
``` |
|||
|
|||
Body 크기 제한: `MAX_BODY_SIZE = 1 MB` (펌웨어 업로드는 예외 — 소켓 타임아웃 None). |
|||
|
|||
--- |
|||
|
|||
## 7. 프론트엔드 아키텍처 (v1.5.0 IA 재설계) |
|||
|
|||
### 진입점 |
|||
|
|||
`src/static/index.html` — lang="en", title="IoT Web Configurator", 9개 `modulepreload` 힌트 |
|||
(app / state / api / utils / icons / constants / page-dirty / nav-guard / home). |
|||
|
|||
### 사이드바 레이아웃 (5그룹, 11개 활성 리프) |
|||
|
|||
``` |
|||
sidebar (aside[role=navigation]) |
|||
├── Dashboard |
|||
│ └── home |
|||
├── Network |
|||
│ ├── wifi (Wi-Fi SSID + 연결 + 지역) |
|||
│ ├── ethernet (이더넷 IP + LTE 인터페이스) |
|||
│ └── server-setting (TIOT/Update/RTCM/LTE 서버 엔드포인트) |
|||
├── Interface & Protocol |
|||
│ ├── general-settings (Equipment + Protocol 선택 + Odometer) |
|||
│ ├── sensor-io (RS485 + Analog/Digital 포트) |
|||
│ ├── can-bus (CAN 설정 + 프레임 매핑) |
|||
│ ├── opcua (OPC UA 엔드포인트 + 필드 매핑) |
|||
│ └── modbus (Modbus 엔드포인트 + 바이트 순서 + 필드 매핑) |
|||
├── Log |
|||
│ └── log (3탭: Configuration / Files / Kernel) |
|||
└── Firmware |
|||
└── firmware (8단계 OTA 스테퍼) |
|||
``` |
|||
|
|||
그룹 펼침 상태는 `localStorage("wc.sidebar.groups")`에 유지된다. |
|||
|
|||
`app.js PAGES`에 레거시 별칭 보존 (구 북마크 하위 호환): |
|||
- `ssid` → `wifi` |
|||
- `io` → `ethernet` |
|||
- `network` → `server-setting` |
|||
- `register` → `general` (= `general-settings` 페이지) |
|||
- `can` → `can-bus` |
|||
|
|||
### 페이지 객체 패턴 |
|||
|
|||
모든 페이지 모듈은 기본 페이지 객체를 export한다: |
|||
|
|||
```js |
|||
export default { |
|||
render(container) { ... }, // HTML을 container에 주입 |
|||
mount(container) { ... }, // 이벤트 리스너 연결, collector 등록 |
|||
destroy() { ... }, // 리스너 정리 (선택) |
|||
validate() { ... }, // 클라이언트 측 유효성 검사 (선택) |
|||
} |
|||
``` |
|||
|
|||
구현 대상: `app.js`, `state.js`, 11개 리프 페이지 전체. |
|||
|
|||
### 상태 관리 (state.js) |
|||
|
|||
- `state.device` — 평탄화된 device config 객체 (JS 인메모리) |
|||
- `state.protocol` — 평탄화된 protocol config 객체 |
|||
- `state.pageDirty` — 페이지별 dirty 매트릭스 (10개 항목): |
|||
`{home, wifi, ethernet, server-setting, general, sensor-io, can-bus, opcua, modbus, log, firmware: false}` |
|||
- `state.isDirty` — 불리언 하위 호환 별칭 (모든 pageDirty 값의 OR) |
|||
- `convertFlatToNested(flat)` / `convertNestedToFlat(nested)` — 레거시 앱의 평탄화 표현과 일치하는 중첩 ↔ 평탄 키 변환 |
|||
|
|||
### Page Dirty + Nav Guard |
|||
|
|||
`src/static/js/page-dirty.js`: |
|||
- `markDirty(pageId)` / `clearDirty(pageId)` / `hasDirty()` / `getDirtyPages()` / `clearAllDirty()` |
|||
- 사이드바 점 (`.nav-item__dirty`)은 모든 호출 시 자동 동기화. |
|||
|
|||
`src/static/js/nav-guard.js`: |
|||
- `confirmNavigation(currentPageId)` → Promise 모달 (Save / Discard / Cancel) |
|||
- 포커스 트랩 (모달 내부 Tab/Shift+Tab 순환) — v1.5.2 H12 + H13 |
|||
- Esc 키 = Cancel |
|||
- 닫을 때 트리거 포커스 복원 (WCAG 2.4.3) — v1.5.2 H13 |
|||
|
|||
### 아이콘 (icons.js) |
|||
|
|||
- 49개 Lucide v0.460+ SVG body 문자열 (ISC 라이선스) |
|||
- `icon(name, opts)` 헬퍼: `opts.cls` (클래스, `<>"'` 제거), `opts.aria` (aria-label, `escapeAttr`) |
|||
- `escapeAttr`는 모듈 수준에서 정의 (성능 최적화, 호출별 생성 아님) |
|||
- 모든 아이콘에 `role="img"` + `<title>` 적용 (WCAG 4.1.2) |
|||
- 장식용 아이콘에는 `aria-hidden="true"` 적용 |
|||
|
|||
### 보안 (프론트엔드) |
|||
|
|||
- `utils.js`의 `escapeHtml`: `& → &` / `< → <` / `> → >` / `" → "` / `' → '` |
|||
— 5문자 명시 치환 (v1.5.2 H1 루트 XSS 수정) |
|||
- 운영자 입력값(레지스터 테이블, odometer 필드, import 데이터) 모두 DOM 삽입 전 `escapeHtml` 통과 — v1.4.6.2 F1 + C5 + C6 |
|||
- icons.js `icon()`의 cls/aria: `escapeAttr`로 `<>"'` 제거 |
|||
- nav-guard.js `_escape`는 `escapeHtml`을 미러링 |
|||
- DEBUG console.log: `state.device` 내용 마스킹 (값 대신 키만 표시) — v1.4.6.9 M6 |
|||
|
|||
--- |
|||
|
|||
## 8. Firmware OTA (v1.5.0 Phase 4a + v1.5.2 하드닝) |
|||
|
|||
### 소스 |
|||
|
|||
`C:\Users\F1304\Downloads\dpw-fw-update-tool\webconfig_fw\`에서 `MERGE.md` + `DESIGN.md` (2026-06-06)에 따라 병합. |
|||
|
|||
### 백엔드 패키지 (`src/firmware/`) |
|||
|
|||
| 모듈 | 라인 수 | 역할 | |
|||
|---|---|---| |
|||
| `fw_client.py` | ~110 | dpw-fw-update-tool 데몬용 Wire 클라이언트 (host:port) | |
|||
| `staging.py` | ~47 | ZIP 스테이징: 압축 해제, 유효성 검사, 슬롯 감지 | |
|||
| `config_safety.py` | ~172 | 플래시 전 설정 백업, 재부팅 후 복원 검증 | |
|||
| `protocol.py` | ~64 | Wire 프로토콜 저수준 프레이밍 | |
|||
| `fw_controller.py` | ~480 | 상태 기반 OTA 오케스트레이션, 상태 스키마, 컴포넌트별 진행 바, 재부팅 watchdog | |
|||
| `fw_routes.py` | ~110 | 전송 계층 독립적 HTTP 라우트 핸들러, `RouteError` 예외 타입 | |
|||
|
|||
`server.py:94`에서 초기화: |
|||
```python |
|||
_FW = FirmwareRoutes(FirmwareController( |
|||
fw_host=os.environ.get("FW_HOST", "127.0.0.1"), |
|||
fw_port=int(os.environ.get("FW_PORT", "8990")), |
|||
staging_dir=os.environ.get("FW_STAGING_DIR", "/opt/fw_staging"), |
|||
backups_dir=os.environ.get("FW_BACKUPS_DIR", "/opt/config_backups"), |
|||
db_path=DB_PATH, |
|||
)) |
|||
``` |
|||
|
|||
### 8단계 스테퍼 |
|||
|
|||
``` |
|||
Upload → Verify → Pre-flight → Backup → Flash → Commit → Reboot → Config check |
|||
``` |
|||
|
|||
### 상태 스키마 (UI의 단일 진실 공급원) |
|||
|
|||
```json |
|||
{ |
|||
"state": "idle|staged|flashing|rebooting|verifying|done|failed", |
|||
"phases": [{ "key", "label", "state": "pending|active|done|error", "tier", "detail" }], |
|||
"components": [{ "role", "signature", "name", "size", "sha256", |
|||
"sent", "pct", "state": "pending|active|done|error" }], |
|||
"overall": { "sent", "total", "pct", "elapsed_s" }, |
|||
"active_signature": "RTF|null", |
|||
"backup": { "path", "when" } , |
|||
"slot": { "before": "A|B|?", "after": "A|B|?|null" }, |
|||
"message": "human line", |
|||
"error": null |
|||
} |
|||
``` |
|||
|
|||
### v1.5.2 Firmware 하드닝 |
|||
|
|||
| 항목 | 수정 내용 | |
|||
|---|---| |
|||
| C1 ProtectSystem | 서비스 유닛 + deploy.ps1에 `/opt/fw_staging` + `/opt/config_backups` → `ReadWritePaths` 추가, 디렉토리 사전 생성 | |
|||
| H4 stage_zip TOCTOU | `fw_controller.stage_zip` — `self._lock` 하에서 'staging' 상태 검사 | |
|||
| H19 default_file | `FirmwareController` default_file 오버라이드를 `server.py`에서 제거 | |
|||
| H20 upload timeout | `/api/firmware/upload` 소켓 타임아웃을 연결별로 `None`으로 재설정 | |
|||
| M10/M15 recover | `_recover_staging`을 백그라운드 데몬 스레드로 실행 (시작 비차단) | |
|||
|
|||
--- |
|||
|
|||
## 9. 보안 모델 |
|||
|
|||
보안 모델은 레거시 Java 앱 기준선에 맞춰 인증 없는 LAN 전용으로 명시적으로 설계되었다. |
|||
|
|||
| 제어 항목 | 구현 방식 | |
|||
|---|---| |
|||
| 인증 없음 | 의도적 설계 (LAN 전용 IoT, Java 앱과 동일) | |
|||
| CORS | `Access-Control-Allow-Origin: *` (전체 origin 허용, 의도적) | |
|||
| HTTP만 사용 | TLS 미적용 (LAN 환경) | |
|||
| XSS — 출력 | `utils.js`의 `escapeHtml` 5문자 치환 (v1.5.2 H1 루트 수정) | |
|||
| XSS — 속성 | `icons.js`의 `escapeAttr`, 모든 숫자 속성에 숫자 강제 변환 `Number()` (v1.4.6.9 C5/C6) | |
|||
| 경로 순회 | `os.realpath()` + `STATIC_DIR` 접두사 검사 (`server.py:237-239`) | |
|||
| SQL 인젝션 | `DBManager.ALLOWED_KEYS` 화이트리스트 (`db_manager.py:42`) — CLI 백엔드는 키를 파라미터화 | |
|||
| Body 크기 | `MAX_BODY_SIZE = 1 MB` (`server.py:161`) | |
|||
| Slowloris | `socket.setdefaulttimeout(30.0)` (`server.py`, v1.5.1) | |
|||
| enum 인젝션 | 유효하지 않은 enum 값 시 HTTP 400 하드 거부 (`config_validator.py`) | |
|||
| Super_Relay 키 | POST에 `security_config`/`transport_config` 포함 시 HTTP 400 거부 (`v1.4.4`) | |
|||
| systemd 하드닝 | `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp`, `ReadWritePaths` 화이트리스트 | |
|||
| 지원 번들 | WiFi 비밀번호는 포함 전 마스킹 처리 | |
|||
|
|||
--- |
|||
|
|||
## 10. 성능 (v1.5.4 + v1.5.4.2) |
|||
|
|||
### 정적 파일 전달 (LAN, .54 측정값) |
|||
|
|||
| 지표 | 값 | |
|||
|---|---| |
|||
| 최초 로드 전송량 | ~575 KB → ~200 KB (gzip으로 ~65% 감소) | |
|||
| 반복 방문 | ETag/304, 0바이트 body — 거의 즉각 렌더링 | |
|||
| 배포 전파 | 즉시 (JS/CSS no-cache, v1.5.4.2 H1+M2 수정) | |
|||
| 정적 파일 레이턴시 | 20–32 ms (LAN 왕복) | |
|||
| system-status 콜드 | ~6.09 s (journalctl + dpworldapp 로그 스캔) | |
|||
| system-status 웜 | ~1.15 s (모듈 캐시) | |
|||
|
|||
### 적용된 기법 |
|||
|
|||
- **gzip**: 레벨 6, text/JS/CSS/JSON/SVG, `≥256B`, q값 인식 Accept-Encoding (v1.5.4.2 L1) |
|||
- **ETag**: `W/"mtime-size"` 약한 ETag, 파일별, `If-None-Match` → 304 |
|||
- **Cache-Control**: HTML/JS/CSS `no-cache, must-revalidate`; 폰트/이미지 `max-age=86400` |
|||
- **modulepreload**: 9개 핵심 모듈 (`index.html` 링크 힌트) — app.js 파싱 전 병렬 fetch |
|||
- **셀프 호스팅 폰트**: 7개 `.woff2` 파일 (Inter 5웨이트 + JetBrains Mono 2웨이트, latin 서브셋, 총 ~200 KB) |
|||
— 외부 CDN 의존 없음 (LAN 전용 IoT에서 필수, v1.5.1.1) |
|||
- **amss.bin에 mmap 사용**: 전체 읽기 대신 `mmap.find()` — 피크 RSS 1 MB 미만 유지 (v1.4.0) |
|||
|
|||
--- |
|||
|
|||
## 11. 동시성 모델 |
|||
|
|||
``` |
|||
Thread A (request) Thread B (request) Daemon (auto-compress) |
|||
│ │ │ |
|||
_lock.acquire() blocks _lock.acquire() (after A) |
|||
BEGIN IMMEDIATE ──────── SQLite WAL ────────── (reads log_config) |
|||
SELECT + mutator() |
|||
INSERT OR REPLACE |
|||
COMMIT |
|||
_lock.release() ─────── unblocks B |
|||
``` |
|||
|
|||
- `DBManager._lock`은 `threading.Lock` — 하나의 Python 객체, 프로세스 로컬. |
|||
- SQLite 측의 `BEGIN IMMEDIATE`는 DB 레벨에서 동시 쓰기를 차단한다 |
|||
(WAL 모드는 동시 읽기 허용). |
|||
- `fw_controller` 상태 머신은 별도 인스턴스의 `self._lock`으로 보호. |
|||
- Nav-guard 이중 모달 레이스: 빠른 클릭 시 시각적 문제만 발생 (기록됨, 허용 수준). |
|||
|
|||
--- |
|||
|
|||
## 12. 테스트 인프라 (v1.5.3 듀얼 러너) |
|||
|
|||
### Python pytest (644개 테스트) |
|||
|
|||
`tests/`에 버전/기능별로 구성: |
|||
|
|||
| 패턴 | 설명 | |
|||
|---|---| |
|||
| Grep 테스트 | 코드 패턴 존재/부재 검증 (대다수 테스트) | |
|||
| Behavioral 테스트 | server.py 통합 (FakeDB), config_validator 하드 거부 | |
|||
| Multi-instance | `test_v1_5_3_multi_instance.py` — DBManager BEGIN IMMEDIATE 레이스 (10+20 스레드) | |
|||
| Schema drift | `test_schema_drift.py` — `scripts/audit_schema_drift.py` 대 `tests/fixtures/java_schema_expected.json` | |
|||
|
|||
실행: `python -m pytest tests/ -v` |
|||
|
|||
### Node 18+ jsdom (35개 테스트, 4개 파일) |
|||
|
|||
| 파일 | 테스트 수 | 검증 내용 | |
|||
|---|---|---| |
|||
| `tests/test_nav_guard_behavior.mjs` | 9 | 포커스 트랩 Tab/Shift+Tab, Esc, Save/Discard/Cancel, pageId XSS | |
|||
| `tests/test_icons_svg.mjs` | 10 | SVG 정합성, viewBox/width/height, aria role=img + title, XSS 저항성 | |
|||
| `tests/test_log_tabs_switching.mjs` | 4 | 탭 클릭 → panel.hidden 토글, aria-selected, 기본 상태 | |
|||
| `tests/test_save_all_modal.mjs` | 9 | 모달 role/aria, dirty 페이지 목록, Cancel/Confirm promise, escapeHtml XSS | |
|||
| `tests/test_firmware_logic.mjs` | 3 | (기존) firmware-logic.js 헬퍼 | |
|||
|
|||
실행: `npm test` (Node 18+ 필요, 최초 1회 `npm install`) |
|||
|
|||
**디바이스 배포 격리**: `deploy.ps1`이 `git archive HEAD src` 사용 — `src/`만 배포됨. `tests/`, `node_modules/`, `package.json`, `package-lock.json`은 디바이스로 전송되지 않는다. |
|||
|
|||
--- |
|||
|
|||
## 13. 배포 파이프라인 (scripts/deploy.ps1) |
|||
|
|||
### 단계 |
|||
|
|||
``` |
|||
1. git archive HEAD src → 로컬 tar |
|||
2. SSH: mkdir -p $AppDir /opt/fw_staging /opt/config_backups |
|||
3. scp tar → 디바이스 |
|||
4. SSH: tar --warning=no-timestamp -xf tar → _deploy_tmp/ |
|||
5. SSH: 기존 src/ 백업 → backups/src-$ts (최근 3개 유지) |
|||
6. SSH: atomic mv _deploy_tmp/ → src/ |
|||
7. SSH: /lib/systemd/system/web-configurator.service 설치 (영속) |
|||
8. SSH: systemctl daemon-reload + enable + restart web-configurator |
|||
9. Confirm-Health: /api/health 최대 30초 폴링 |
|||
10. 실패 시 자동 롤백: backups/src-$ts 복원 + restart |
|||
``` |
|||
|
|||
### 주요 플래그 |
|||
|
|||
| 플래그 | 효과 | |
|||
|---|---| |
|||
| (기본값) | 클린 git 워킹 트리 + 버전 태그 필요 | |
|||
| `-AllowUntagged` | 태그 요건 생략 (dev/test 배포) | |
|||
| `-Rollback` | 가장 최근 backup/src-* 복원 | |
|||
|
|||
### 배포 후 디바이스 상태 |
|||
|
|||
- `$AppDir` 내 `DEPLOYED_VERSION` 파일 (버전 문자열) |
|||
- `$AppDir` 내 `deploy-history.log` (배포마다 타임스탬프 + 버전) |
|||
- `/lib/systemd/system/web-configurator.service`의 systemd 유닛 |
|||
|
|||
### 버전 확인 (배포 후) |
|||
|
|||
```powershell |
|||
ssh root@192.168.55.54 "curl -s http://localhost:9090/api/health" |
|||
# → {"status":"ok","version":"v1.5.4.2","uptime_s":...} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 14. 운영 리뷰 커버리지 |
|||
|
|||
### v1.5.2 멀티에이전트 리뷰 (wf_f4786747-995) |
|||
|
|||
- 12개 차원, 318개 에이전트, 약 102건 제기 → **41건 확정** |
|||
- 수정: Critical 1 + High 20 + Medium 7 = 28개 항목 |
|||
- 이연: High 5 (behavioral, jsdom) → v1.5.3 |
|||
- 방법: 발견 사항별 3-lens 적대 검증 (정확성 / 보안 / 재현성) |
|||
— 다수 반박 (2/3) = false positive, 폐기 |
|||
|
|||
### v1.5.4.2 집중 리뷰 (wf_a4efa2e3-2cb) |
|||
|
|||
- 6개 차원, 21건 제기 → **5건 확정** |
|||
- 수정: H1+M2 Cache-Control JS/CSS, M1 sensor-io placeholder, L1 gzip q-value, L2 focus-visible |
|||
- 잔여: Medium 9 + Low 4 (사용자 체감 영향 0) |
|||
|
|||
--- |
|||
|
|||
## 15. v1.5.x 릴리즈 타임라인 |
|||
|
|||
| 태그 | 커밋 | 핵심 변경 | 테스트 수 | |
|||
|---|---|---|---| |
|||
| v1.5.0-phase-1 | `c6ec698` | 인프라: 사이드바 5그룹 + icons.js + page-dirty + nav-guard + Firmware placeholder | 481 | |
|||
| v1.5.0-phase-2 | `1effda8` | Network 3-리프: wifi + ethernet + server-setting | 500 | |
|||
| v1.5.0-phase-3 | `6bdc2a7` | Interface & Protocol 5-리프: general + sensor-io + can-bus + opcua + modbus | 523 | |
|||
| v1.5.0-phase-4a | (sha) | Firmware OTA 실제 병합 (webconfig_fw → src/firmware/ + 5개 라우트 + firmware.js 902줄) | 557 | |
|||
| v1.5.0 | `b3776a1` | Phase 4b: 이모지 → Lucide SVG ~80개 인스턴스 + log 3탭 + Save All 확인 모달 | 562 | |
|||
| v1.5.0.1 | `2b74ad2` | 핫픽스: deploy.ps1 최초 배포 mkdir + /api/ 404 가드 | 564 | |
|||
| v1.5.1 | `1c3ffc2` | DBManager.update_config BEGIN IMMEDIATE RMW + slowloris 30초 타임아웃 | 569 | |
|||
| v1.5.1.1 | `73e188d` | Inter + JetBrains Mono .woff2 7개 폰트 셀프 호스팅 | 572 | |
|||
| v1.5.2 | `6b7047d` | 운영 리뷰 번들: 41건 확정 수정 (C1 ProtectSystem + H1 XSS + H17 systemd + H18 rollback + ...) | 599 | |
|||
| v1.5.3 | `3b04e36` | Behavioral 테스트 인프라: jsdom + node:test, 4개 .mjs 파일, 35개 Node 테스트 | Python 615 + Node 35 | |
|||
| v1.5.3.1 | (sha) | 핫픽스: analog_input_level enum 5V/10V/20mA → 2/4/6 (Java 회귀) + sensor-io UI 카드 | 621 | |
|||
| v1.5.3.2 | (sha) | 핫픽스: modbus.js two_byte_order 누락 (Java TwoByte 회귀) | 625 | |
|||
| v1.5.4 | (sha) | 성능: gzip + ETag/304 + Cache-Control + modulepreload (13개 테스트) | 638 | |
|||
| v1.5.4.1 | (sha) | 핫픽스: 잔여 이모지 → SVG (1개 테스트) | 639 | |
|||
| v1.5.4.2 | `fe5cd25` | 핫픽스 집중 리뷰: Cache-Control no-cache JS/CSS + sensor-io placeholder + gzip q-value + focus-visible (5개 테스트) | 644 | |
|||
|
|||
--- |
|||
|
|||
## 16. 컴포넌트 다이어그램 (ASCII) |
|||
|
|||
### 최상위 시스템 구성 |
|||
|
|||
``` |
|||
Operator Browser (LAN) |
|||
│ |
|||
├──:9090 (direct)──► Python web-configurator.service |
|||
│ │ |
|||
│ server.py (ThreadedHTTPServer) |
|||
│ │ |
|||
│ ┌─────┼──────────────────────────────┐ |
|||
│ │ │ │ |
|||
│ DBManager firmware/ system_status.py |
|||
│ (sqlite3) fw_controller.py kernel_log.py |
|||
│ │ │ log_manager.py |
|||
│ │ /opt/fw_staging support_bundle.py |
|||
│ │ /opt/config_backups config_validator.py |
|||
│ │ dpworldapp_enums.py |
|||
│ │ enum_normalizer.py |
|||
│ ~/db/dynamic_data.db migrations.py |
|||
│ │ |
|||
│ ┌────────┼────────────┐ |
|||
│ board_config schema_meta event_history |
|||
│ │ |
|||
│ ┌──────┼──────────┐ |
|||
│ device_ protocol_ log_ |
|||
│ config config config |
|||
│ |
|||
├──:80──► nginx ──:8080──► Java app-runner (legacy, .56 only) |
|||
│ │ |
|||
│ ~/db/dynamic_data.db (same file!) |
|||
│ |
|||
└── (no browser)──► dpworldapp (/usr/bin/dpworldapp) |
|||
│ |
|||
~/db/dynamic_data.db (reads device_config + protocol_config) |
|||
/opt/log/dpworldapp/*.log (writes) |
|||
``` |
|||
|
|||
### 프론트엔드 모듈 그래프 |
|||
|
|||
``` |
|||
index.html |
|||
└── app.js (type=module, entry point) |
|||
├── state.js (device/protocol 상태 + flat↔nested) |
|||
├── api.js (모든 REST 엔드포인트 fetch 래퍼) |
|||
├── utils.js (escapeHtml, debounce) |
|||
├── icons.js (49개 Lucide SVG + icon() 헬퍼) |
|||
├── constants.js (APP_VERSION, DEFAULTS, enum 집합) |
|||
├── page-dirty.js (markDirty/clearDirty/사이드바 점) |
|||
├── nav-guard.js (confirmNavigation 모달 + 포커스 트랩) |
|||
├── toast.js (토스트 알림) |
|||
├── validator.js (클라이언트 측 필드 유효성 검사) |
|||
├── components/ |
|||
│ ├── crud-table.js (레지스터 필드 매핑 테이블) |
|||
│ └── ip-input.js (IP 주소 입력 위젯) |
|||
└── pages/ |
|||
├── home.js (Dashboard) |
|||
├── wifi.js (Wi-Fi 설정) |
|||
├── ethernet.js (이더넷 + LTE 인터페이스) |
|||
├── server-setting.js (서버 엔드포인트) |
|||
├── general-settings.js (Equipment + Protocol + Odometer) |
|||
├── sensor-io.js (RS485 + Analog/Digital 포트) |
|||
├── can-bus.js (CAN 설정 + 프레임 매핑) |
|||
├── opcua.js (OPC UA 엔드포인트 + 매핑) |
|||
├── modbus.js (Modbus 엔드포인트 + 바이트 순서 + 매핑) |
|||
├── log.js (로그 관리 3탭) |
|||
└── firmware.js (Firmware OTA 8단계 스테퍼) |
|||
[legacy aliases: ssid.js / io.js / network.js / register.js / can.js] |
|||
``` |
|||
|
|||
### Partial-Merge 데이터 흐름 (POST /setting/device) |
|||
|
|||
``` |
|||
Browser POST {partial JSON} |
|||
│ |
|||
server.py:_handle_post_device |
|||
│ |
|||
_read_request_body() ← 1 MB 제한; dict 가드 (배열/스칼라 거부) |
|||
│ |
|||
normalize_device_input() ← enum_normalizer.py (case alias: ON→on 등) |
|||
│ |
|||
validate_wifi_country_code() ← 유효하지 않은 2자 코드 시 하드 거부 400 |
|||
validate_rs485_integers() ← bool/string 타입 시 하드 거부 400 |
|||
validate_device_port_types_hard() ← 유효하지 않은 포트 값 시 하드 거부 400 |
|||
validate_super_relay_keys() ← security/transport_config 포함 시 하드 거부 400 |
|||
│ |
|||
db.update_config("device_config", mutator) |
|||
│ |
|||
├── BEGIN IMMEDIATE (SQLite 쓰기 잠금) |
|||
├── SELECT 기존 데이터 |
|||
├── 기존 데이터 + POST body 병합 (partial-merge) |
|||
├── log_config 키 분리 → db.update_config("log_config", ...) |
|||
├── INSERT OR REPLACE device_config |
|||
└── COMMIT |
|||
│ |
|||
응답: {success, merged_keys, preserved_keys_count} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 17. 운영 런북 |
|||
|
|||
### .54 배포 (dev/verify) |
|||
|
|||
```powershell |
|||
cd C:\Development\NEW_Web_Configurator |
|||
.\scripts\deploy.ps1 192.168.55.54 -AllowUntagged |
|||
``` |
|||
|
|||
### .54 태그 배포 (릴리즈) |
|||
|
|||
```powershell |
|||
git tag v1.5.4.2 -m "v1.5.4.2" |
|||
.\scripts\deploy.ps1 192.168.55.54 |
|||
``` |
|||
|
|||
### 롤백 |
|||
|
|||
```powershell |
|||
.\scripts\deploy.ps1 192.168.55.54 -Rollback |
|||
``` |
|||
|
|||
### 수동 서비스 재시작 (deploy.ps1 restart가 조용히 실패할 때) |
|||
|
|||
```powershell |
|||
ssh root@192.168.55.54 "systemctl restart web-configurator" |
|||
``` |
|||
|
|||
### 배포 헬스 확인 |
|||
|
|||
```powershell |
|||
ssh root@192.168.55.54 "curl -s http://localhost:9090/api/health" |
|||
# 예상 결과: {"status":"ok","version":"v1.5.4.2",...} |
|||
``` |
|||
|
|||
### DB 점검 |
|||
|
|||
```powershell |
|||
ssh root@192.168.55.54 "sqlite3 /home/root/db/dynamic_data.db 'SELECT key, length(value) FROM board_config;'" |
|||
# schema_meta 마이그레이션 점검 |
|||
ssh root@192.168.55.54 "sqlite3 /home/root/db/dynamic_data.db 'SELECT key, value, applied_at FROM schema_meta;'" |
|||
``` |
|||
|
|||
### 펌웨어 업로드 (OTA) |
|||
|
|||
```powershell |
|||
curl -F "file=@firmware.zip" http://192.168.55.54:9090/api/firmware/upload |
|||
curl -s http://192.168.55.54:9090/api/firmware/preflight |
|||
curl -X POST http://192.168.55.54:9090/api/firmware/flash |
|||
``` |
|||
|
|||
### 스키마 드리프트 확인 (로컬) |
|||
|
|||
```powershell |
|||
python scripts/audit_schema_drift.py |
|||
# Exit 0 = 정상, 2 = 드리프트 감지, 1 = 오류 |
|||
``` |
|||
|
|||
### 전체 테스트 실행 |
|||
|
|||
```powershell |
|||
python -m pytest tests/ -v # Python: 644개 테스트 |
|||
npm test # Node: 35개 jsdom 테스트 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 18. 알려진 제한 사항 및 백로그 |
|||
|
|||
| 항목 | 상태 | 비고 | |
|||
|---|---|---| |
|||
| system-status 콜드 레이턴시 ~6초 | v1.5.5 후보 | 모든 콜드 호출 시 journalctl + 전체 로그 스캔 | |
|||
| Java 측 wipe 경로 | 미해결 (Java는 수정 불가) | 레거시 앱이 알 수 없는 enum 값을 조용히 null 처리하고, null 필드를 쓰기 시 삭제 — Python 하드 거부가 1차 방어선 | |
|||
| main 브랜치 FF 머지 보류 | 사용자 결정 사항 | .56 운영 환경은 명시적 사용자 승인 전까지 v1.4.6.10 유지 | |
|||
| GNSS 스캐너 최신 로그만 처리 | 낮은 우선순위 | 로그 회전 후 dpworldapp 재시작 시 GNSS 티어가 'na' 표시 — 다음 dpworldapp 시작 시 자가 복구 | |
|||
| collectAllPagesData 비대칭 | v1.5.5 후보 | Device Save → 3행 쓰기, Register Save → 1행 — 구조적 재설계 필요 | |
|||
| 리뷰에서 Medium 9 + Low 4 | 이연 | 사용자 체감 영향 0 — 기각 또는 v1.5.5+ 처리 | |
|||
| 16개 페이지 동적 import | v1.6+ 후보 | 현재 모든 페이지 즉시 로드; 동적 import 적용 시 초기 파싱 감소 가능 | |
|||
|
|||
--- |
|||
|
|||
## 부록 A — 파일 트리 (src/) |
|||
|
|||
``` |
|||
src/ |
|||
├── server.py ← HTTP 서버, 라우팅, 진입점 |
|||
├── db_manager.py ← SQLite 3티어 + update_config atomic RMW |
|||
├── config_validator.py ← 서버 측 유효성 검사 + 정규화 |
|||
├── dpworldapp_enums.py ← device 계약 enum 값 (단일 진실 공급원) |
|||
├── enum_normalizer.py ← Case-only alias 정규화 (ON→on 등) |
|||
├── migrations.py ← 시작 마이그레이션 (3개 적용) |
|||
├── log_manager.py ← 로그 목록/다운로드/압축/삭제/통계 + auto-compress 데몬 |
|||
├── kernel_log.py ← journalctl + 커널 로그 번들 빌더 |
|||
├── system_status.py ← 홈 대시보드 상태 집계 |
|||
├── support_bundle.py ← 진단 ZIP 생성기 |
|||
├── firmware/ |
|||
│ ├── __init__.py |
|||
│ ├── fw_client.py ← dpw-fw-update-tool 데몬용 Wire 클라이언트 |
|||
│ ├── staging.py ← ZIP 스테이징 및 유효성 검사 |
|||
│ ├── config_safety.py ← 설정 백업/복원 |
|||
│ ├── protocol.py ← Wire 프로토콜 프레이밍 |
|||
│ ├── fw_controller.py ← 상태 기반 OTA 오케스트레이션 |
|||
│ └── fw_routes.py ← HTTP 라우트 핸들러 |
|||
└── static/ |
|||
├── index.html ← SPA 진입점, 5그룹 사이드바, modulepreload 힌트 |
|||
├── css/style.css ← 디자인 토큰 + 컴포넌트 스타일 + firmware CSS |
|||
├── fonts/ ← Inter (.woff2 ×5) + JetBrains Mono (.woff2 ×2) |
|||
├── img/ ← dp-world-logo.svg |
|||
└── js/ |
|||
├── app.js ← 라우터, Save All, Import/Export, 사이드바 JS |
|||
├── api.js ← REST 클라이언트 (모든 fetch 래퍼) |
|||
├── state.js ← 인메모리 상태 + flat↔nested 변환 |
|||
├── page-dirty.js ← 페이지별 dirty 매트릭스 + 사이드바 점 |
|||
├── nav-guard.js ← 네비게이션 확인 모달 + 포커스 트랩 |
|||
├── icons.js ← Lucide v0.460+ 49개 SVG + icon() 헬퍼 |
|||
├── constants.js ← APP_VERSION + DEFAULTS + enum 집합 |
|||
├── utils.js ← escapeHtml + 헬퍼 |
|||
├── toast.js ← 토스트 알림 |
|||
├── validator.js ← 클라이언트 측 필드 유효성 검사 |
|||
├── country-codes.js ← Wi-Fi 국가 코드 205개 드롭다운용 |
|||
├── firmware-logic.js ← Firmware OTA 순수 로직 헬퍼 |
|||
├── components/ |
|||
│ ├── crud-table.js |
|||
│ └── ip-input.js |
|||
└── pages/ |
|||
├── home.js (활성) |
|||
├── wifi.js (활성) |
|||
├── ethernet.js (활성) |
|||
├── server-setting.js (활성) |
|||
├── general-settings.js (활성) |
|||
├── sensor-io.js (활성) |
|||
├── can-bus.js (활성) |
|||
├── opcua.js (활성) |
|||
├── modbus.js (활성) |
|||
├── log.js (활성) |
|||
├── firmware.js (활성) |
|||
├── ssid.js (레거시 별칭 → wifi) |
|||
├── io.js (레거시 별칭 → ethernet, RS485/CAN 잔재) |
|||
├── network.js (레거시 별칭 → server-setting) |
|||
├── register.js (레거시 별칭 → general-settings) |
|||
└── can.js (레거시 별칭 → can-bus) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 부록 B — 주요 코드 위치 |
|||
|
|||
| 내용 | 파일 | 라인/함수 | |
|||
|---|---|---| |
|||
| 진입점 / main() | `src/server.py` | `main()` (파일 하단) | |
|||
| HTTP 핸들러 클래스 | `src/server.py` | `class ConfigHandler` | |
|||
| 정적 파일 서빙 | `src/server.py` | `_serve_static_file()` ~L224 | |
|||
| gzip + ETag 로직 | `src/server.py` | `_serve_static_file()` ~L254-299 | |
|||
| Partial-merge POST device | `src/server.py` | `_handle_post_device()` | |
|||
| Partial-merge POST protocol | `src/server.py` | `_handle_post_protocol()` | |
|||
| Firmware 라우트 | `src/server.py` | do_GET + do_POST firmware 분기 | |
|||
| Atomic RMW | `src/db_manager.py` | `update_config()` L226 | |
|||
| BEGIN IMMEDIATE | `src/db_manager.py` | `update_config()` L258 | |
|||
| Java enum 값 | `src/dpworldapp_enums.py` | `DEVICE_ENUM_VALUES` L16, `PROTOCOL_ENUM_VALUES` L39 | |
|||
| Case alias 정규화 | `src/enum_normalizer.py` | `normalize_device_input()`, `normalize_protocol_input()` | |
|||
| 마이그레이션 | `src/migrations.py` | `apply_all_migrations()` | |
|||
| escapeHtml (5문자) | `src/static/js/utils.js` | `escapeHtml()` | |
|||
| icon() 헬퍼 | `src/static/js/icons.js` | `icon()` | |
|||
| page-dirty 매트릭스 | `src/static/js/state.js` | `state.pageDirty` | |
|||
| nav-guard 모달 | `src/static/js/nav-guard.js` | `confirmNavigation()` | |
|||
| flat↔nested 변환 | `src/static/js/state.js` | `convertFlatToNested()`, `convertNestedToFlat()` | |
|||
| FirmwareController | `src/firmware/fw_controller.py` | `class FirmwareController` | |
|||
| Schema drift 테스트 | `tests/test_schema_drift.py` | 파일 전체 | |
|||
| Config 계약 스냅샷 | `tests/fixtures/java_schema_expected.json` | 파일 전체 | |
|||
| 감사 스크립트 | `scripts/audit_schema_drift.py` | 파일 전체 | |
|||
| jsdom nav-guard 테스트 | `tests/test_nav_guard_behavior.mjs` | 파일 전체 | |
|||
|
|||
--- |
|||
|
|||
## 부록 C — 관련 문서 |
|||
|
|||
| 문서 | 위치 | 내용 | |
|||
|---|---|---| |
|||
| 구 아키텍처 (v2.2) | `docs/architecture.md` | v1.3.0 시대, 현재 이 문서로 대체됨 | |
|||
| 배포 런북 | `docs/DEPLOY.md` | 배포 절차, 버전 관리 | |
|||
| 2026-05-29 인시던트 | 내부 인시던트 기록(이 배포물에 미포함) | 전체 행 replace wipe 인시던트 | |
|||
| 변경 이력 | `CHANGELOG.md` | v1.4.0 → v1.5.4.2 전체 릴리즈 이력 | |
|||
@ -0,0 +1,131 @@ |
|||
# WebConfigurator / dpworldapp Config Contract Proposal |
|||
|
|||
작성일: 2026-06-10 |
|||
|
|||
## 목적 |
|||
|
|||
이 문서는 WebConfigurator와 dpworldapp이 공유하는 `device_config`, `protocol_config`의 계약을 정리하기 위한 협의안이다. 현재 장비의 `/var/www/html/config_device.json`, `/var/www/html/config_protocol.json`는 dpworldapp 구동 중 DB read/parse 문제가 생겼을 때 fallback seed 또는 restore source로 쓰이는 것으로 이해한다. |
|||
|
|||
핵심 원칙은 두 가지다. |
|||
|
|||
1. 지금 dpworldapp이 실제로 읽는 `Legacy v1` 포맷은 즉시 깨지 않는다. |
|||
2. 사람이 유지보수하고 검증하기 쉬운 `Canonical v2` 포맷을 별도 계약안으로 만들고, WebConfigurator와 dpworldapp 사이에는 명시적인 `adapter`를 둔다. |
|||
|
|||
## 현재 관찰 |
|||
|
|||
입력 기본 파일 위치: |
|||
|
|||
- 장비 원본 경로: `/var/www/html/config_device.json` |
|||
- 장비 원본 경로: `/var/www/html/config_protocol.json` |
|||
- 분석에 사용한 복사본: `C:\Users\F1304\Downloads\default www` |
|||
|
|||
현재 WebConfigurator는 SQLite `board_config` 테이블에 다음 key를 사용한다. |
|||
|
|||
- `device_config`: dpworldapp config-reader 계약과 맞춘 flat config |
|||
- `protocol_config`: dpworldapp register 매핑 계약과 맞춘 flat config |
|||
- `log_config`: WebConfigurator/Python 전용 log-management 확장 영역 |
|||
|
|||
이미 코드에 들어 있는 중요한 정책은 `src/dpworldapp_enums.py`의 주석과 일치한다. `device_config`와 `protocol_config`는 dpworldapp config-reader 계약 그대로 유지하고, Python 자체 기능 데이터는 별도 `board_config` key로 분리해야 한다. |
|||
|
|||
## Legacy v1 이슈 목록 |
|||
|
|||
| 위치 | 현재 값/형태 | 문제 | Canonical v2 제안 | |
|||
| --- | --- | --- | --- | |
|||
| `device_config.wifi_static` | `"on"` / `"off"` string | boolean 의미가 문자열 enum으로 저장됨 | `network.wifi.static: true/false` | |
|||
| `device_config.log_save` | `"on"` / `"off"` string | boolean 의미가 문자열 enum으로 저장됨 | `logging.enabled: true/false` | |
|||
| `device_config.WIFI_SSID` | 대문자 key + `wifi_passwd` | JSON naming이 불균일하고 password 명칭이 UI와 다름 | `network.wifi.ssid_profiles[].password` | |
|||
| `device_config.lte_server_port` | `"20111"` string | server port는 정수로 검증/저장되어야 함 | `servers.lte.port: 20111` | |
|||
| `device_config.lte_port` | `"534"` string | 의미가 network port인지 device interface id인지 불명확 | 의미 확정 전까지 `network.lte.interface_port: "534"` | |
|||
| `protocol_config.protocol` | `"OPC-UA"` | 현재 canonical enum은 `OPC_UA`; hyphen alias는 adapter 책임 | `interface.protocol: "opc_ua"` | |
|||
| `protocol_config.MEID` | `7000` number | 식별자는 산술 대상이 아니며 leading zero 가능성을 고려해야 함 | `device.meid: "7000"` | |
|||
| `protocol_config.dr_on`, `odo_on`, `heading_on` | `"on"` / `"off"` string | boolean 의미가 문자열 enum으로 저장됨 | `positioning.*: true/false` | |
|||
| `protocol_config.odo_speed.source` | `"CAN"` | 현재 Python validator의 source enum은 lower-case `can`/`hw` | `source: "can"` | |
|||
| `protocol_config.odo_speed.shift` | `"8"` string | 현재 WebConfigurator validator는 integer를 기대함 | `shift_bits: 8` | |
|||
| `protocol_config.ai0..ai3`, `di0..di3` | `"0"` / `"1"` string | channel count와 타입이 불명확함. 현재 validator는 0/1번만 엄격 관리 | `io.analog_inputs[]`, `io.digital_inputs[]` boolean array | |
|||
| mapping arrays `OPC_UA`, `CAN` | 대문자 array key + 숫자 문자열 | 저장 관례와 타입 의미가 섞여 있음 | `mappings.opc_ua[]`, `mappings.can[]` | |
|||
|
|||
`odo_speed.shift`는 특히 협의가 필요하다. 현재 default fallback 파일은 string을 쓰지만 WebConfigurator backend는 nested odometer field에서 integer를 기대한다. 따라서 adapter에서 numeric string을 integer로 흡수할지, fallback 파일 자체를 integer로 고칠지 결정해야 한다. |
|||
|
|||
## 제안 아키텍처 |
|||
|
|||
### 1. Legacy v1 |
|||
|
|||
dpworldapp이 지금 읽는 장비 운용 포맷이다. 이 포맷은 dpworldapp config-reader 호환성을 위해 유지한다. |
|||
|
|||
특징: |
|||
|
|||
- flat top-level key 유지: `wifi_static`, `protocol_server_ip`, `WIFI_SSID`, `OPC_UA`, `CAN` |
|||
- legacy boolean은 `"on"` / `"off"` 유지 |
|||
- legacy enum은 dpworldapp 계약 값 유지: `OPC_UA`, `MODBUS`, `CAN`, `NONE` |
|||
- Java가 integer로 기대하는 server port는 integer로 저장 |
|||
- dpworldapp이 string으로 기대하거나 의미가 불명확한 field는 합의 전까지 string 유지 |
|||
|
|||
### 2. Canonical v2 |
|||
|
|||
사람과 WebConfigurator 내부 모델이 기준으로 삼을 typed domain model이다. `docs/config-spec/proposed/` 아래 JSON 파일은 이 안을 default 파일에서 변환한 예시다. |
|||
|
|||
공통 규칙: |
|||
|
|||
- 모든 파일은 `schema_version`을 가진다. |
|||
- boolean 의미는 JSON boolean만 쓴다. |
|||
- port, baudrate, size, duration, namespace, shift 같은 수량은 number를 쓴다. |
|||
- 식별자, CAN id, mask, expression, password, equipment id는 string을 쓴다. |
|||
- enum은 한 가지 표기만 허용한다. v2에서는 lower snake case를 기본으로 한다. |
|||
- 단위가 있는 수량은 key에 단위를 넣는다. 예: `baudrate_kbps`, `max_size_mb`, `max_duration_days`, `shift_bits`. |
|||
- 장비별 차이는 `equipment` code와 protocol-specific mapping array로 표현하고, 공통 positioning/network/server/interface 영역은 유지한다. |
|||
|
|||
### 3. adapter |
|||
|
|||
adapter는 `Canonical v2`와 `Legacy v1` 사이의 유일한 변환 지점이다. |
|||
|
|||
adapter 책임: |
|||
|
|||
- `true/false` <-> `"on"/"off"` |
|||
- `opc_ua` <-> `OPC_UA`, legacy alias `OPC-UA` 흡수 |
|||
- `device.meid` string <-> legacy `MEID` |
|||
- `io.analog_inputs[]` <-> legacy `ai0`, `ai1`, `ai2`, `ai3` |
|||
- `io.digital_inputs[]` <-> legacy `di0`, `di1`, `di2`, `di3` |
|||
- `mappings.opc_ua[]` <-> legacy `OPC_UA` |
|||
- `mappings.can[]` <-> legacy `CAN` |
|||
- default/fallback JSON과 DB row를 같은 golden file 테스트로 검증 |
|||
|
|||
중요: dpworldapp이 v2를 직접 지원하기 전에는 WebConfigurator가 DB의 `device_config`, `protocol_config`를 v2로 직접 저장하지 않는다. v2는 내부 모델 또는 별도 row/파일로만 관리하고, dpworldapp 경계에는 adapter가 만든 Legacy v1을 저장한다. |
|||
|
|||
## 제안 파일 |
|||
|
|||
- `docs/config-spec/proposed/config_device.v2.proposed.json` |
|||
- `docs/config-spec/proposed/config_protocol.v2.proposed.json` |
|||
|
|||
이 두 파일은 현재 default JSON을 사람이 이해하기 쉬운 typed 구조로 옮긴 협의안이다. 장비에 바로 복사하는 배포 파일이 아니다. |
|||
|
|||
## Future Addition Policy |
|||
|
|||
새 config field를 추가할 때는 다음 항목을 spec에 먼저 추가한 뒤 구현한다. |
|||
|
|||
1. `owner`: `device_config`, `protocol_config`, `log_config`, 또는 새 전용 key 중 어디가 소유하는지 명시한다. |
|||
2. type: JSON type을 하나로 정한다. boolean 의미에 string enum을 쓰지 않는다. |
|||
3. default: 장비 factory default, WebConfigurator UI default, migration default를 구분한다. |
|||
4. required/optional: 누락 시 동작과 null 저장 여부를 명시한다. |
|||
5. enum: 허용 값과 alias 흡수 위치를 정한다. alias는 adapter에서만 처리한다. |
|||
6. unit: 수량 field에는 단위를 key에 포함한다. |
|||
7. security: password, token, certificate 등 masking/export policy를 함께 정의한다. |
|||
8. migration: 기존 DB row와 fallback file을 어떻게 변환할지 적고 idempotent test를 만든다. |
|||
9. compatibility: dpworldapp 구버전이 unknown field를 어떻게 처리하는지 확인한다. |
|||
10. golden file: default legacy 파일, v2 proposal, v1 round-trip 결과를 테스트 fixture로 고정한다. |
|||
|
|||
## 변경 절차 제안 |
|||
|
|||
1. 현재 장비 fallback 파일을 Legacy v1 기준으로 먼저 정돈한다. 단, dpworldapp이 아직 string을 기대하는 field는 임의로 v2 타입으로 바꾸지 않는다. |
|||
2. WebConfigurator에 `legacy <-> canonical` adapter 테스트를 추가한다. |
|||
3. default fallback 파일을 사람이 직접 수정하지 않고 canonical source에서 생성하는 스크립트를 둔다. |
|||
4. dpworldapp이 v2를 직접 읽을 준비가 되면 `schema_version` 기반 feature flag 또는 별도 `device_config_v2`/`protocol_config_v2` row를 협의한다. |
|||
5. 전환 전까지 운영 DB의 `device_config`, `protocol_config`는 Legacy v1로 유지한다. |
|||
|
|||
## 즉시 결정할 협의 항목 |
|||
|
|||
1. `odo_speed.shift`: legacy fallback 파일을 integer로 고칠지, WebConfigurator adapter가 numeric string을 integer로 normalize할지 결정. |
|||
2. `ai2/ai3/di2/di3`: dpworldapp config-reader 계약이 4 channel을 실제 지원하는지 확인. 지원한다면 WebConfigurator enum/validator도 확장. |
|||
3. `lte_port`: network port인지 serial/interface id인지 owner와 type 확정. |
|||
4. mapping row의 `dv`: output type에 맞춰 typed value로 갈지, fallback/default sentinel string으로 유지할지 결정. |
|||
5. log 관련 field: dpworldapp 소유 field와 WebConfigurator 전용 `log_config` field를 명확히 분리. |
|||
|
|||
@ -0,0 +1,118 @@ |
|||
{ |
|||
"schema_version": 2, |
|||
"compat_profile": "dpworldapp-config-contract-v2-proposal", |
|||
"generated_from": { |
|||
"legacy_device_file": "/var/www/html/config_device.json", |
|||
"captured_path": "C:\\Users\\F1304\\Downloads\\default www\\config_device.json" |
|||
}, |
|||
"network": { |
|||
"wifi": { |
|||
"static": false, |
|||
"ipv4": { |
|||
"address": "192.168.78.74", |
|||
"netmask": "255.255.255.0", |
|||
"gateway": "192.168.78.1", |
|||
"dns": [ |
|||
"8.8.8.8", |
|||
"8.8.8.4" |
|||
] |
|||
}, |
|||
"country_code": "KR", |
|||
"ssid_profiles": [ |
|||
{ |
|||
"ssid": "mobidigm", |
|||
"password": "mobidigm", |
|||
"security": "wpa_wpa2" |
|||
}, |
|||
{ |
|||
"ssid": "dhautoware", |
|||
"password": "dhautoware", |
|||
"security": "wpa_wpa2" |
|||
}, |
|||
{ |
|||
"ssid": "dpworld", |
|||
"password": "dpworlddpworld", |
|||
"security": "wpa_wpa2" |
|||
}, |
|||
{ |
|||
"ssid": "test", |
|||
"password": "", |
|||
"security": "none" |
|||
} |
|||
] |
|||
}, |
|||
"ethernet": { |
|||
"ipv4": { |
|||
"address": "192.168.55.55", |
|||
"netmask": "255.255.255.0", |
|||
"gateway": "192.168.55.1" |
|||
} |
|||
}, |
|||
"lte": { |
|||
"ipv4": { |
|||
"address": "192.168.40.20", |
|||
"netmask": "255.255.255.0", |
|||
"gateway": "192.168.40.1" |
|||
}, |
|||
"interface_port": "534" |
|||
} |
|||
}, |
|||
"servers": { |
|||
"telemetry": { |
|||
"host": "192.168.78.2", |
|||
"port": 8080, |
|||
"transport": "http" |
|||
}, |
|||
"update": { |
|||
"host": "192.168.78.2", |
|||
"port": 8080, |
|||
"transport": "http" |
|||
}, |
|||
"rtcm": { |
|||
"host": "192.168.78.2", |
|||
"port": 4000 |
|||
}, |
|||
"lte": { |
|||
"host": "104.208.105.62", |
|||
"port": 20111 |
|||
}, |
|||
"opc_ua": { |
|||
"host": "192.168.55.2", |
|||
"port": 53530 |
|||
}, |
|||
"modbus": { |
|||
"host": "192.168.55.2", |
|||
"port": 502 |
|||
} |
|||
}, |
|||
"interfaces": { |
|||
"can": { |
|||
"id_format": "extended", |
|||
"baudrate_kbps": 1000 |
|||
}, |
|||
"rs485": { |
|||
"duplex": "half", |
|||
"baudrate": 9600, |
|||
"data_bits": 8, |
|||
"parity": "none", |
|||
"stop_bits": 0 |
|||
} |
|||
}, |
|||
"imu": { |
|||
"axis_remap": { |
|||
"x": "x", |
|||
"y": "y", |
|||
"z": "z" |
|||
}, |
|||
"axis_sign": { |
|||
"x": 1, |
|||
"y": -1, |
|||
"z": 1 |
|||
} |
|||
}, |
|||
"logging": { |
|||
"enabled": true, |
|||
"max_size_mb": 100, |
|||
"max_duration_days": 30 |
|||
} |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
{ |
|||
"schema_version": 2, |
|||
"compat_profile": "dpworldapp-config-contract-v2-proposal", |
|||
"generated_from": { |
|||
"legacy_protocol_file": "/var/www/html/config_protocol.json", |
|||
"captured_path": "C:\\Users\\F1304\\Downloads\\default www\\config_protocol.json" |
|||
}, |
|||
"device": { |
|||
"type": "rtls", |
|||
"version": "v1.0", |
|||
"equipment": "ITV", |
|||
"equipment_id": "01", |
|||
"meid": "7000" |
|||
}, |
|||
"positioning": { |
|||
"dead_reckoning": true, |
|||
"heading": true, |
|||
"fix_mode": true, |
|||
"heading_imu": true, |
|||
"speed_source": "can", |
|||
"odometer": { |
|||
"enabled": true, |
|||
"speed": { |
|||
"source": "can", |
|||
"can_id": "0x18FEFC28", |
|||
"shift_bits": 8, |
|||
"mask": "0xffff", |
|||
"expression": "x*0.05+10.0" |
|||
}, |
|||
"direction": { |
|||
"source": "hw" |
|||
} |
|||
} |
|||
}, |
|||
"interface": { |
|||
"protocol": "opc_ua", |
|||
"can_input": true |
|||
}, |
|||
"byte_order": { |
|||
"two_byte": "big", |
|||
"four_byte": "big" |
|||
}, |
|||
"io": { |
|||
"analog_input_level": 6, |
|||
"analog_inputs": [ |
|||
true, |
|||
true, |
|||
true, |
|||
false |
|||
], |
|||
"digital_inputs": [ |
|||
true, |
|||
true, |
|||
false, |
|||
false |
|||
] |
|||
}, |
|||
"mappings": { |
|||
"opc_ua": [ |
|||
{ |
|||
"field": "TMP1", |
|||
"namespace": 3, |
|||
"address": "1001", |
|||
"output_type": "integer", |
|||
"default_value": "-9", |
|||
"expression": "x*10.0" |
|||
}, |
|||
{ |
|||
"field": "TMP2", |
|||
"namespace": 3, |
|||
"address": "1002", |
|||
"output_type": "float", |
|||
"default_value": "-9", |
|||
"expression": "x/10.0" |
|||
}, |
|||
{ |
|||
"field": "TMP3", |
|||
"namespace": 3, |
|||
"address": "1003", |
|||
"output_type": "boolean", |
|||
"default_value": "-9", |
|||
"expression": "x+10.0" |
|||
}, |
|||
{ |
|||
"field": "TMP4", |
|||
"namespace": 3, |
|||
"address": "1004", |
|||
"output_type": "string", |
|||
"default_value": "0", |
|||
"expression": "x-10.0" |
|||
} |
|||
], |
|||
"can": [ |
|||
{ |
|||
"field": "CAN1", |
|||
"can_id": "0x18FEFC28", |
|||
"shift_bits": 8, |
|||
"mask": "0xffff", |
|||
"expression": "x*0.05+10.0", |
|||
"output_type": "integer", |
|||
"default_value": "-9" |
|||
}, |
|||
{ |
|||
"field": "CAN3", |
|||
"can_id": "0x18FEFC24", |
|||
"shift_bits": 4, |
|||
"mask": "0xffff00", |
|||
"expression": "x/10.0-1.0", |
|||
"output_type": "integer", |
|||
"default_value": "-9" |
|||
}, |
|||
{ |
|||
"field": "CAN2", |
|||
"can_id": "0x18FEEE00", |
|||
"shift_bits": 16, |
|||
"mask": "0xff", |
|||
"expression": "x*0.05+10.0", |
|||
"output_type": "float", |
|||
"default_value": "-9" |
|||
} |
|||
], |
|||
"modbus": [] |
|||
} |
|||
} |
|||
@ -0,0 +1,243 @@ |
|||
# dpworldapp 호환성 명세서 |
|||
|
|||
> Web Configurator 교체 시 dpworldapp과의 호환성을 보장하기 위한 계약(Contract) 문서. |
|||
|
|||
--- |
|||
|
|||
## 1. 개요 |
|||
|
|||
dpworldapp(`/usr/bin/dpworldapp`)은 IoT 장비의 메인 애플리케이션으로, Web Configurator가 저장한 설정을 SQLite DB에서 직접 읽어 사용합니다. |
|||
|
|||
**핵심 원칙**: dpworldapp은 **일체 변경하지 않으므로**, 새 Web Configurator는 기존 Java 앱이 생성하는 것과 **비트 단위까지 호환되는** 데이터를 생성해야 합니다. |
|||
|
|||
--- |
|||
|
|||
## 2. 공유 인터페이스 |
|||
|
|||
### 2.1 SQLite DB 파일 경로 |
|||
|
|||
``` |
|||
~/db/dynamic_data.db (= /home/root/db/dynamic_data.db) |
|||
``` |
|||
|
|||
- **절대 변경 불가**: dpworldapp이 이 경로를 하드코딩하고 있을 가능성 높음 |
|||
- busy_timeout: `5000ms` (동시 접근 시 잠금 대기) |
|||
|
|||
### 2.2 board_config 테이블 |
|||
|
|||
```sql |
|||
-- 테이블 구조 (변경 불가) |
|||
CREATE TABLE board_config ( |
|||
key TEXT PRIMARY KEY NOT NULL, |
|||
value TEXT NOT NULL |
|||
); |
|||
``` |
|||
|
|||
| key 값 | 역할 | 쓰기 주체 | 읽기 주체 | |
|||
|--------|------|----------|----------| |
|||
| `device_config` | 디바이스 네트워크/하드웨어 설정 | Web Configurator | dpworldapp | |
|||
| `protocol_config` | 프로토콜/매핑 설정 | Web Configurator | dpworldapp | |
|||
| `log_config` | 로그 관리(auto-compress/cleanup 등) — **Python 전용** | Web Configurator | (dpworldapp 미사용) | |
|||
| `net_config` | Network Apply Engine watchdog 설정(kill-switch 등) — **Python 전용** | Web Configurator | (dpworldapp 미사용) | |
|||
| `ap_config` | Wi-Fi AP(`ap0`) 설정 — **Python 전용** | Web Configurator | (dpworldapp 미사용) | |
|||
|
|||
> [!NOTE] |
|||
> `board_config` 의 전체 허용 키는 위 5개다(`db_manager.py:43` `ALLOWED_KEYS`). |
|||
> dpworldapp 이 읽는 것은 `device_config` / `protocol_config` 둘 뿐이고, |
|||
> `log_config` / `net_config` / `ap_config` 는 Web Configurator 자체 기능을 위한 |
|||
> **Python 전용 키**로 dpworldapp 은 읽지 않는다(별도 key ownership 정책 — |
|||
> dpworldapp 공유 키를 오염시키지 않기 위함). 한편 네트워크 적용 엔진이 |
|||
> dpworldapp 과 byte-exact 호환을 위해 생성하는 `network_config.json` 은 |
|||
> `board_config` 행이 아니라 `/home/root/network/` 의 **파일**이다(혼동 주의). |
|||
|
|||
### 2.3 event_history 테이블 |
|||
|
|||
```sql |
|||
CREATE TABLE event_history ( |
|||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|||
data TEXT NOT NULL |
|||
); |
|||
``` |
|||
|
|||
| 역할 | 쓰기 주체 | 읽기 주체 | |
|||
|------|----------|----------| |
|||
| 이벤트/경고 이력 | dpworldapp | (선택적 조회 가능) | |
|||
|
|||
> [!CAUTION] |
|||
> Web Configurator는 `event_history`에 **절대 쓰지 않습니다**. dpworldapp 전용. |
|||
|
|||
--- |
|||
|
|||
## 3. device_config JSON 스키마 |
|||
|
|||
dpworldapp이 읽는 JSON의 정확한 필드명과 타입: |
|||
|
|||
```json |
|||
{ |
|||
"wifi_static": "on", |
|||
"wifi_ip": "10.227.231.38", |
|||
"wifi_netmask": "255.255.255.0", |
|||
"wifi_gateway": "10.227.231.1", |
|||
"wifi_dns1": "10.226.4.4", |
|||
"wifi_dns2": "10.226.4.5", |
|||
"wifi_country_code": "KR", |
|||
"eth_ip": "192.168.55.44", |
|||
"eth_netmask": "255.255.255.0", |
|||
"eth_gateway": "192.168.55.1", |
|||
"protocol_server_ip": "10.226.22.48", |
|||
"protocol_server_port": 20101, |
|||
"update_server_ip": "10.226.22.244", |
|||
"update_server_port": 9999, |
|||
"rtcm_server_ip": "10.226.22.48", |
|||
"rtcm_server_port": 20104, |
|||
"opc_ua_server_ip": "192.168.55.11", |
|||
"opc_ua_server_port": 53530, |
|||
"modbus_server_ip": "192.168.55.100", |
|||
"modbus_server_port": 502, |
|||
"lte_server_ip": "...", |
|||
"lte_server_port": 0, |
|||
"can_bus_type": "extended", |
|||
"can_baudrate": 1000, |
|||
"rs485_mode": "half", |
|||
"rs485_baudrate": 9600, |
|||
"rs485_parity": "...", |
|||
"lte_ip": "...", |
|||
"lte_netmask": "...", |
|||
"lte_gateway": "...", |
|||
"log_save": "on", |
|||
"log_max_size": 100, |
|||
"log_max_duration": 1, |
|||
"WIFI_SSID": [ |
|||
{ |
|||
"wifi_ssid": "KROF01-IT-DEV-Y5", |
|||
"wifi_passwd": "password", |
|||
"wifi_security": "wpa/wpa2" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### 타입 규칙 |
|||
|
|||
| 필드 패턴 | 타입 | 예시 | |
|||
|-----------|------|------| |
|||
| `*_port` | `Integer` (JSON number) | `20101` | |
|||
| `*_baudrate` | `Integer` | `9600` | |
|||
| `log_max_size`, `log_max_duration` | `Integer` | `100` | |
|||
| `wifi_static`, `log_save` | `String` (`"on"` / `"off"`) | `"on"` | |
|||
| 나머지 | `String` | `"192.168.55.44"` | |
|||
|
|||
> [!NOTE] |
|||
> **dpworldapp-owned 필드 (Web Configurator 미작성):** `imu_remap_x` / |
|||
> `imu_remap_x_sign` / `imu_remap_y` / `imu_remap_y_sign` / `imu_remap_z` / |
|||
> `imu_remap_z_sign` 6개 IMU remap 필드는 **v1.4.5 부터 Web Configurator 가 |
|||
> 더 이상 작성하지 않는다**(UI 에서 제거됨, src 에 참조 0). dpworldapp 이 |
|||
> 자체적으로 소유·관리하는 값이므로 위 스키마 예시에서 의도적으로 제외했다. |
|||
> Web Configurator 의 partial-merge POST 는 기존 행을 통째로 덮어쓰지 않으므로 |
|||
> (`/setting/device` 부분 병합), dpworldapp 이 기록한 이 키들은 보존된다. |
|||
|
|||
--- |
|||
|
|||
## 4. protocol_config JSON 스키마 |
|||
|
|||
```json |
|||
{ |
|||
"dev_type": "RTLS", |
|||
"equipment": "ITV", |
|||
"equipment_id": "01", |
|||
"MEID": "7000", |
|||
"protocol": "OPC_UA", |
|||
"version": "v1.0", |
|||
"can_input": "on", |
|||
"speed_data": "CAN", |
|||
"dr_on": "on", |
|||
"heading_on": "on", |
|||
"heading_imu_on": "on", |
|||
"fix_mode_on": "on", |
|||
"analog_input_level": "6", |
|||
"ai0": "0", "ai1": "1", "ai2": "0", "ai3": "0", |
|||
"di0": "0", "di1": "0", "di2": "0", "di3": "0", |
|||
"two_byte_order": "big", |
|||
"four_byte_order": "big", |
|||
"CAN": [ |
|||
{ |
|||
"field": "CAN1", |
|||
"id": "0x18FEFC28", |
|||
"shift": 0, |
|||
"mask": "0xffff", |
|||
"expr": "x*0.05+10.0", |
|||
"odt": "integer", |
|||
"dv": "-9" |
|||
} |
|||
], |
|||
"OPC_UA": [ |
|||
{ |
|||
"field": "TMP1", |
|||
"ns": "3", |
|||
"addr": "1001", |
|||
"shift": 0, |
|||
"expr": "x*10.0", |
|||
"odt": "float64", |
|||
"dv": "-9" |
|||
} |
|||
], |
|||
"MODBUS": [ |
|||
{ |
|||
"field": "TEMP", |
|||
"addr": "100", |
|||
"idt": "integer", |
|||
"odt": "float64", |
|||
"dv": "-9", |
|||
"shift": 0, |
|||
"expr": "x*0.1", |
|||
"mask": "0xffff" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### 프로토콜별 조건부 필드 존재 규칙 |
|||
|
|||
| 조건 | 결과 | |
|||
|------|------| |
|||
| `protocol == "OPC_UA"` | `OPC_UA[]` 포함, `MODBUS` 제외 (null → JSON에서 생략) | |
|||
| `protocol == "MODBUS"` | `MODBUS[]` 포함, `OPC_UA` 제외 | |
|||
| `protocol == "NONE"` | 둘 다 제외 | |
|||
| `can_input == "on"` | `CAN[]` 포함 | |
|||
| `can_input == "off"` | `CAN` 제외 | |
|||
|
|||
--- |
|||
|
|||
## 5. 호환성 체크리스트 |
|||
|
|||
### 배포 전 필수 확인 |
|||
|
|||
- [ ] `~/db/dynamic_data.db` 경로 동일 |
|||
- [ ] `board_config` 테이블 구조 동일 |
|||
- [ ] `key` 값 문자열 동일 (`"device_config"`, `"protocol_config"`) |
|||
- [ ] JSON 필드명 snake_case 동일 |
|||
- [ ] Integer 필드가 JSON number 타입으로 저장 |
|||
- [ ] null/None 필드가 JSON에서 제외됨 |
|||
- [ ] MEID가 항상 문자열로 저장 |
|||
- [ ] 비활성 프로토콜 매핑이 JSON에서 제외됨 |
|||
- [ ] `busy_timeout=5000` 설정됨 |
|||
- [ ] `event_history` 테이블에 쓰기 없음 |
|||
|
|||
### 검증 방법 |
|||
|
|||
```bash |
|||
# 1. 기존 Java 앱으로 설정 저장 후 DB 덤프 |
|||
sqlite3 ~/db/dynamic_data.db "SELECT value FROM board_config WHERE key='device_config'" > java_device.json |
|||
|
|||
# 2. 새 Python 앱으로 동일 설정 저장 후 DB 덤프 |
|||
sqlite3 ~/db/dynamic_data.db "SELECT value FROM board_config WHERE key='device_config'" > python_device.json |
|||
|
|||
# 3. JSON 비교 (필드 순서 무시) |
|||
python3 -c " |
|||
import json |
|||
a = json.loads(open('java_device.json').read()) |
|||
b = json.loads(open('python_device.json').read()) |
|||
print('MATCH' if a == b else 'MISMATCH') |
|||
print('Diff keys:', set(a.keys()) ^ set(b.keys())) |
|||
" |
|||
``` |
|||
@ -0,0 +1,285 @@ |
|||
{ |
|||
"$schema": "https://json-schema.org/draft/2020-12/schema", |
|||
"$id": "https://dpworld/schemas/device_v2.json", |
|||
"title": "Device configuration (schema v2 — flat 정규화)", |
|||
"description": "Web Configurator ↔ dpworldapp 합의 schema. 옵션 B (Medium): type 정규화 + naming 일관성 + enum 명시, flat 구조 유지. 본 schema 는 협의용 (안) — 채택 후 양측 동시 도입 예정.", |
|||
"type": "object", |
|||
"required": [ |
|||
"schema_version", |
|||
"wifi_static", |
|||
"eth_ip", |
|||
"protocol_server_ip", |
|||
"protocol_server_port" |
|||
], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"schema_version": { |
|||
"type": "integer", |
|||
"const": 2, |
|||
"description": "본 schema 버전. v1 (legacy on/off string) ↔ v2 호환 정책은 spec §5.1 참조." |
|||
}, |
|||
|
|||
"wifi_static": { |
|||
"type": "boolean", |
|||
"default": false, |
|||
"description": "WiFi 정적 IP 사용 여부. v1 의 \"on\"/\"off\" → v2 의 true/false. Reader 측 deserializer 는 v1 형식도 관용 수용." |
|||
}, |
|||
"wifi_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "WiFi 인터페이스 IP 주소 (정적 모드 시 사용)." |
|||
}, |
|||
"wifi_netmask": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "WiFi 서브넷 마스크." |
|||
}, |
|||
"wifi_gateway": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "WiFi 게이트웨이." |
|||
}, |
|||
"wifi_dns1": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "WiFi DNS 1." |
|||
}, |
|||
"wifi_dns2": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "WiFi DNS 2." |
|||
}, |
|||
"wifi_country_code": { |
|||
"type": "string", |
|||
"pattern": "^[A-Z]{2}$", |
|||
"default": "KR", |
|||
"description": "WiFi 국가 코드 (ISO 3166-1 alpha-2)." |
|||
}, |
|||
|
|||
"eth_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "Ethernet IP 주소 (관리 인터페이스)." |
|||
}, |
|||
"eth_netmask": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "Ethernet 서브넷 마스크." |
|||
}, |
|||
"eth_gateway": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "Ethernet 게이트웨이." |
|||
}, |
|||
|
|||
"lte_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "LTE 인터페이스 IP." |
|||
}, |
|||
"lte_netmask": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "LTE 서브넷 마스크." |
|||
}, |
|||
"lte_gateway": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "LTE 게이트웨이." |
|||
}, |
|||
"lte_port": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"maximum": 65535, |
|||
"description": "LTE 모뎀 포트. v1 의 string \"534\" → v2 의 integer 534." |
|||
}, |
|||
"lte_server_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "LTE 서버 IP." |
|||
}, |
|||
"lte_server_port": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"maximum": 65535, |
|||
"description": "LTE 서버 포트. v1 의 string \"20111\" → v2 의 integer 20111." |
|||
}, |
|||
|
|||
"protocol_server_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "Protocol 서버 IP (RTLS 데이터 송신 대상)." |
|||
}, |
|||
"protocol_server_port": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"maximum": 65535, |
|||
"description": "Protocol 서버 포트." |
|||
}, |
|||
"update_server_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "Firmware/Config 업데이트 서버 IP." |
|||
}, |
|||
"update_server_port": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"maximum": 65535, |
|||
"description": "업데이트 서버 포트." |
|||
}, |
|||
"rtcm_server_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "RTCM 보정 서버 IP (GNSS RTK)." |
|||
}, |
|||
"rtcm_server_port": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"maximum": 65535, |
|||
"description": "RTCM 서버 포트." |
|||
}, |
|||
"opc_ua_server_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "OPC-UA 서버 IP." |
|||
}, |
|||
"opc_ua_server_port": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"maximum": 65535, |
|||
"description": "OPC-UA 서버 포트." |
|||
}, |
|||
"modbus_server_ip": { |
|||
"type": "string", |
|||
"format": "ipv4", |
|||
"description": "Modbus 서버 IP." |
|||
}, |
|||
"modbus_server_port": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"maximum": 65535, |
|||
"description": "Modbus 서버 포트." |
|||
}, |
|||
|
|||
"can_bus_type": { |
|||
"type": "string", |
|||
"enum": ["extended", "standard"], |
|||
"default": "extended", |
|||
"description": "CAN bus 타입." |
|||
}, |
|||
"can_baudrate": { |
|||
"type": "integer", |
|||
"enum": [100, 250, 500, 1000], |
|||
"description": "CAN 통신 속도 (kbps). 운영 ground truth 일치." |
|||
}, |
|||
|
|||
"rs485_mode": { |
|||
"type": "string", |
|||
"enum": ["half", "full"], |
|||
"default": "half", |
|||
"description": "RS-485 통신 모드." |
|||
}, |
|||
"rs485_baudrate": { |
|||
"type": "integer", |
|||
"enum": [1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200], |
|||
"default": 9600, |
|||
"description": "RS-485 통신 속도 (bps)." |
|||
}, |
|||
"rs485_databits": { |
|||
"type": "integer", |
|||
"enum": [5, 6, 7, 8, 9], |
|||
"default": 8, |
|||
"description": "RS-485 데이터 비트." |
|||
}, |
|||
"rs485_parity": { |
|||
"type": "string", |
|||
"enum": ["no", "even", "odd"], |
|||
"default": "no", |
|||
"description": "RS-485 패리티." |
|||
}, |
|||
"rs485_stopbits": { |
|||
"type": "integer", |
|||
"enum": [0, 1, 2], |
|||
"default": 1, |
|||
"description": "RS-485 정지 비트." |
|||
}, |
|||
|
|||
"imu_remap_x": { |
|||
"type": "string", |
|||
"enum": ["x", "y", "z"], |
|||
"default": "x", |
|||
"description": "IMU X 축 매핑." |
|||
}, |
|||
"imu_remap_y": { |
|||
"type": "string", |
|||
"enum": ["x", "y", "z"], |
|||
"default": "y", |
|||
"description": "IMU Y 축 매핑." |
|||
}, |
|||
"imu_remap_z": { |
|||
"type": "string", |
|||
"enum": ["x", "y", "z"], |
|||
"default": "z", |
|||
"description": "IMU Z 축 매핑." |
|||
}, |
|||
"imu_remap_x_sign": { |
|||
"type": "string", |
|||
"enum": ["plus", "minus"], |
|||
"default": "plus", |
|||
"description": "IMU X 축 부호." |
|||
}, |
|||
"imu_remap_y_sign": { |
|||
"type": "string", |
|||
"enum": ["plus", "minus"], |
|||
"default": "plus", |
|||
"description": "IMU Y 축 부호." |
|||
}, |
|||
"imu_remap_z_sign": { |
|||
"type": "string", |
|||
"enum": ["plus", "minus"], |
|||
"default": "plus", |
|||
"description": "IMU Z 축 부호." |
|||
}, |
|||
|
|||
"log_save": { |
|||
"type": "boolean", |
|||
"default": true, |
|||
"description": "로그 저장 여부. v1 의 \"on\"/\"off\" → v2 의 true/false." |
|||
}, |
|||
"log_max_size": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"description": "로그 파일 최대 크기 (MB)." |
|||
}, |
|||
"log_max_duration": { |
|||
"type": "integer", |
|||
"minimum": 1, |
|||
"description": "로그 파일 최대 보관 기간 (일)." |
|||
}, |
|||
|
|||
"ssid_list": { |
|||
"type": "array", |
|||
"description": "WiFi 연결 후보 SSID 목록 (우선순위 순). v1 의 'WIFI_SSID' upper-case → v2 의 ssid_list snake_case.", |
|||
"items": { |
|||
"type": "object", |
|||
"required": ["wifi_ssid", "wifi_security"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"wifi_ssid": { |
|||
"type": "string", |
|||
"description": "SSID 이름." |
|||
}, |
|||
"wifi_passwd": { |
|||
"type": "string", |
|||
"description": "WiFi 비밀번호. security 가 'none' 이면 빈 string 가능." |
|||
}, |
|||
"wifi_security": { |
|||
"type": "string", |
|||
"enum": ["wpa/wpa2", "wpa3", "wep", "none"], |
|||
"description": "WiFi 보안 방식." |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,174 @@ |
|||
{ |
|||
"$schema": "https://json-schema.org/draft/2020-12/schema", |
|||
"$id": "https://dpworld/schemas/device_v3.json", |
|||
"title": "Device configuration (schema v3 — nested 재설계)", |
|||
"description": "Web Configurator ↔ dpworldapp 옵션 C (Aggressive). v2 의 type 정규화 + naming 일관 + Nested grouping. ★ 큰 BC break — 양쪽 소비자 전면 재설계 필요. 협의 통과 가능성 낮음. future ideal 로 제시.", |
|||
"type": "object", |
|||
"required": ["schema_version", "network", "servers"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"schema_version": { |
|||
"type": "integer", |
|||
"const": 3, |
|||
"description": "본 schema 버전 (nested 재설계)." |
|||
}, |
|||
|
|||
"network": { |
|||
"type": "object", |
|||
"required": ["wifi", "eth"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"wifi": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"static": {"type": "boolean", "default": false, "description": "정적 IP 사용 여부."}, |
|||
"ip": {"type": "string", "format": "ipv4"}, |
|||
"netmask": {"type": "string", "format": "ipv4"}, |
|||
"gateway": {"type": "string", "format": "ipv4"}, |
|||
"dns1": {"type": "string", "format": "ipv4"}, |
|||
"dns2": {"type": "string", "format": "ipv4"}, |
|||
"country_code": {"type": "string", "pattern": "^[A-Z]{2}$", "default": "KR"}, |
|||
"ssid_list": { |
|||
"type": "array", |
|||
"description": "WiFi 연결 후보 SSID 목록.", |
|||
"items": { |
|||
"type": "object", |
|||
"required": ["ssid", "security"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"ssid": {"type": "string", "description": "SSID 이름."}, |
|||
"password": {"type": "string", "description": "WiFi 비밀번호."}, |
|||
"security": { |
|||
"type": "string", |
|||
"enum": ["wpa/wpa2", "wpa3", "wep", "none"] |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
"eth": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"ip": {"type": "string", "format": "ipv4"}, |
|||
"netmask": {"type": "string", "format": "ipv4"}, |
|||
"gateway": {"type": "string", "format": "ipv4"} |
|||
} |
|||
}, |
|||
"lte": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"ip": {"type": "string", "format": "ipv4"}, |
|||
"netmask": {"type": "string", "format": "ipv4"}, |
|||
"gateway": {"type": "string", "format": "ipv4"}, |
|||
"port": {"type": "integer", "minimum": 1, "maximum": 65535, "description": "LTE 모뎀 포트."}, |
|||
"server": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"ip": {"type": "string", "format": "ipv4"}, |
|||
"port": {"type": "integer", "minimum": 1, "maximum": 65535} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"servers": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"description": "원격 서버 엔드포인트 (server.ip + server.port 쌍).", |
|||
"properties": { |
|||
"protocol": {"$ref": "#/$defs/ServerEndpoint", "description": "Protocol 데이터 송신 서버."}, |
|||
"update": {"$ref": "#/$defs/ServerEndpoint", "description": "Firmware/Config 업데이트 서버."}, |
|||
"rtcm": {"$ref": "#/$defs/ServerEndpoint", "description": "RTCM 보정 서버."}, |
|||
"opc_ua": {"$ref": "#/$defs/ServerEndpoint", "description": "OPC-UA 서버."}, |
|||
"modbus": {"$ref": "#/$defs/ServerEndpoint", "description": "Modbus 서버."} |
|||
} |
|||
}, |
|||
|
|||
"serial": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"can": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"bus_type": {"type": "string", "enum": ["extended", "standard"], "default": "extended"}, |
|||
"baudrate": {"type": "integer", "enum": [100, 250, 500, 1000]} |
|||
} |
|||
}, |
|||
"rs485": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"mode": {"type": "string", "enum": ["half", "full"], "default": "half"}, |
|||
"baudrate": {"type": "integer", "default": 9600}, |
|||
"databits": {"type": "integer", "enum": [5,6,7,8,9], "default": 8}, |
|||
"parity": {"type": "string", "enum": ["no","even","odd"], "default": "no"}, |
|||
"stopbits": {"type": "integer", "enum": [0,1,2], "default": 1} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"hardware": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"imu": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"description": "IMU 축 매핑 + 부호.", |
|||
"properties": { |
|||
"remap": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"x": {"type": "string", "enum": ["x","y","z"], "default": "x"}, |
|||
"y": {"type": "string", "enum": ["x","y","z"], "default": "y"}, |
|||
"z": {"type": "string", "enum": ["x","y","z"], "default": "z"} |
|||
} |
|||
}, |
|||
"sign": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"x": {"type": "string", "enum": ["plus","minus"], "default": "plus"}, |
|||
"y": {"type": "string", "enum": ["plus","minus"], "default": "plus"}, |
|||
"z": {"type": "string", "enum": ["plus","minus"], "default": "plus"} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"log": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"save": {"type": "boolean", "default": true, "description": "로그 저장 여부."}, |
|||
"max_size": {"type": "integer", "minimum": 1, "description": "최대 크기 (MB)."}, |
|||
"max_duration": {"type": "integer", "minimum": 1, "description": "최대 보관 기간 (일)."} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"$defs": { |
|||
"ServerEndpoint": { |
|||
"type": "object", |
|||
"required": ["ip", "port"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"ip": {"type": "string", "format": "ipv4"}, |
|||
"port": {"type": "integer", "minimum": 1, "maximum": 65535} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,679 @@ |
|||
# Migration 예시 — v1 → v2 → v3 변환 |
|||
|
|||
본 문서는 `device_config` / `protocol_config` 의 **schema 버전별 변환 예시** 와 양측 (Web Configurator, dpworldapp) 의 **변환 함수 spec** 을 제공합니다. |
|||
|
|||
협의 spec 본문: `docs/config-spec/config-contract-proposal.md` |
|||
|
|||
--- |
|||
|
|||
## 1. 변환 순서 + 호환성 정책 |
|||
|
|||
``` |
|||
v1 (현재 — config_device.json default) |
|||
│ Phase 1 ~ 3 — Boolean/Integer/Enum 정규화 |
|||
▼ |
|||
v2 (옵션 B — flat 정규화, 권장) |
|||
│ Phase 5 — nested 재설계 (협의 후 결정) |
|||
▼ |
|||
v3 (옵션 C — nested, future ideal) |
|||
``` |
|||
|
|||
**호환성 약속** (spec §5.7): |
|||
- 양측이 항상 **현재 + 직전** schema_version read 가능 |
|||
- write 는 최신 schema 만 |
|||
|
|||
**Reader-level deserializer** 가 옛 형식 (`"on"`, `"534"`) 도 관용 수용. Writer 는 신규 형식만. |
|||
|
|||
--- |
|||
|
|||
## 2. v1 → v2 변환 (device) |
|||
|
|||
### 2.1 변환 예시 — Boolean 필드 |
|||
|
|||
**v1 (현재)**: |
|||
```json |
|||
{ |
|||
"wifi_static": "off", |
|||
"log_save": "on" |
|||
} |
|||
``` |
|||
|
|||
**v2 변환 후**: |
|||
```json |
|||
{ |
|||
"schema_version": 2, |
|||
"wifi_static": false, |
|||
"log_save": true |
|||
} |
|||
``` |
|||
|
|||
**변환 규칙**: |
|||
| v1 값 | v2 값 | |
|||
|---|---| |
|||
| `"on"` | `true` | |
|||
| `"off"` | `false` | |
|||
| `true` | `true` (이미 v2) | |
|||
| `false` | `false` (이미 v2) | |
|||
| 그 외 | 에러 (validation reject 또는 default 사용) | |
|||
|
|||
### 2.2 변환 예시 — Integer 필드 |
|||
|
|||
**v1**: |
|||
```json |
|||
{ |
|||
"lte_port": "534", |
|||
"lte_server_port": "20111" |
|||
} |
|||
``` |
|||
|
|||
**v2**: |
|||
```json |
|||
{ |
|||
"lte_port": 534, |
|||
"lte_server_port": 20111 |
|||
} |
|||
``` |
|||
|
|||
**변환 규칙**: |
|||
| v1 값 | v2 값 | |
|||
|---|---| |
|||
| digit string (예: `"534"`) | `int(s)` | |
|||
| int (예: `534`) | 그대로 | |
|||
| 비-digit string | 에러 | |
|||
|
|||
### 2.3 변환 예시 — Naming (WIFI_SSID → ssid_list) |
|||
|
|||
**v1**: |
|||
```json |
|||
{ |
|||
"WIFI_SSID": [ |
|||
{"wifi_ssid": "mobidigm", "wifi_passwd": "mobidigm", "wifi_security": "wpa/wpa2"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**v2**: |
|||
```json |
|||
{ |
|||
"ssid_list": [ |
|||
{"wifi_ssid": "mobidigm", "wifi_passwd": "mobidigm", "wifi_security": "wpa/wpa2"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**참고**: 옵션 B 에서는 array element 의 키 (`wifi_ssid`/`wifi_passwd`/`wifi_security`) 는 그대로. 옵션 C 에서는 `ssid`/`password`/`security` 로 추가 정규화. |
|||
|
|||
### 2.4 device v1 → v2 전체 예시 |
|||
|
|||
**v1 (default `/var/www/html/config_device.json`)**: |
|||
```json |
|||
{ |
|||
"wifi_static": "off", |
|||
"wifi_ip": "192.168.78.74", |
|||
"wifi_country_code": "KR", |
|||
"eth_ip": "192.168.55.55", |
|||
"lte_port": "534", |
|||
"lte_server_port": "20111", |
|||
"protocol_server_ip": "192.168.78.2", |
|||
"protocol_server_port": 8080, |
|||
"can_baudrate": 1000, |
|||
"rs485_databits": 8, |
|||
"rs485_stopbits": 0, |
|||
"imu_remap_x": "x", |
|||
"log_save": "on", |
|||
"log_max_size": 100, |
|||
"WIFI_SSID": [ |
|||
{"wifi_ssid": "mobidigm", "wifi_passwd": "mobidigm", "wifi_security": "wpa/wpa2"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**v2 변환 후**: |
|||
```json |
|||
{ |
|||
"schema_version": 2, |
|||
"wifi_static": false, |
|||
"wifi_ip": "192.168.78.74", |
|||
"wifi_country_code": "KR", |
|||
"eth_ip": "192.168.55.55", |
|||
"lte_port": 534, |
|||
"lte_server_port": 20111, |
|||
"protocol_server_ip": "192.168.78.2", |
|||
"protocol_server_port": 8080, |
|||
"can_baudrate": 1000, |
|||
"rs485_databits": 8, |
|||
"rs485_stopbits": 0, |
|||
"imu_remap_x": "x", |
|||
"log_save": true, |
|||
"log_max_size": 100, |
|||
"ssid_list": [ |
|||
{"wifi_ssid": "mobidigm", "wifi_passwd": "mobidigm", "wifi_security": "wpa/wpa2"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 3. v1 → v2 변환 (protocol) |
|||
|
|||
### 3.1 변환 예시 — Boolean + ai/di |
|||
|
|||
**v1**: |
|||
```json |
|||
{ |
|||
"dr_on": "on", |
|||
"odo_on": "on", |
|||
"ai0": "1", |
|||
"ai3": "0" |
|||
} |
|||
``` |
|||
|
|||
**v2**: |
|||
```json |
|||
{ |
|||
"dr_on": true, |
|||
"odo_on": true, |
|||
"ai0": true, |
|||
"ai3": false |
|||
} |
|||
``` |
|||
|
|||
### 3.2 변환 예시 — MEID 정규화 |
|||
|
|||
**v1 (default 는 int)**: |
|||
```json |
|||
{"MEID": 7000} |
|||
``` |
|||
|
|||
**v2**: |
|||
```json |
|||
{"meid": 7000} |
|||
``` |
|||
|
|||
### 3.3 변환 예시 — odo_speed.shift Integer |
|||
|
|||
**v1**: |
|||
```json |
|||
{ |
|||
"odo_speed": { |
|||
"source": "CAN", |
|||
"id": "0x18FEFC28", |
|||
"shift": "8", |
|||
"mask": "0xffff", |
|||
"expr": "x*0.05+10.0" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**v2**: |
|||
```json |
|||
{ |
|||
"odo_speed": { |
|||
"source": "CAN", |
|||
"id": "0x18FEFC28", |
|||
"shift": 8, |
|||
"mask": "0xffff", |
|||
"expr": "x*0.05+10.0" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 3.4 protocol v1 → v2 전체 예시 |
|||
|
|||
**v1 일부**: |
|||
```json |
|||
{ |
|||
"dev_type": "RTLS", |
|||
"version": "v1.0", |
|||
"dr_on": "on", |
|||
"odo_on": "on", |
|||
"MEID": 7000, |
|||
"protocol": "OPC-UA", |
|||
"ai0": "1", |
|||
"OPC_UA": [ |
|||
{"field": "TMP1", "ns": "3", "addr": "1001", "odt": "integer", "dv": "-9", "expr": "x*10.0"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**v2 변환**: |
|||
```json |
|||
{ |
|||
"schema_version": 2, |
|||
"dev_type": "RTLS", |
|||
"version": "v1.0", |
|||
"dr_on": true, |
|||
"odo_on": true, |
|||
"meid": 7000, |
|||
"protocol": "OPC-UA", |
|||
"ai0": true, |
|||
"OPC_UA": [ |
|||
{"field": "TMP1", "ns": 3, "addr": 1001, "odt": "integer", "dv": -9, "expr": "x*10.0"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 4. v2 → v3 변환 (옵션 C 시점) |
|||
|
|||
### 4.1 device v2 → v3 — Nested grouping |
|||
|
|||
**v2 일부**: |
|||
```json |
|||
{ |
|||
"schema_version": 2, |
|||
"wifi_static": false, |
|||
"wifi_ip": "192.168.78.74", |
|||
"wifi_country_code": "KR", |
|||
"eth_ip": "192.168.55.55", |
|||
"lte_port": 534, |
|||
"lte_server_ip": "104.208.105.62", |
|||
"lte_server_port": 20111, |
|||
"protocol_server_ip": "192.168.78.2", |
|||
"protocol_server_port": 8080, |
|||
"ssid_list": [ |
|||
{"wifi_ssid": "mobidigm", "wifi_passwd": "mobidigm", "wifi_security": "wpa/wpa2"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**v3 변환**: |
|||
```json |
|||
{ |
|||
"schema_version": 3, |
|||
"network": { |
|||
"wifi": { |
|||
"static": false, |
|||
"ip": "192.168.78.74", |
|||
"country_code": "KR", |
|||
"ssid_list": [ |
|||
{"ssid": "mobidigm", "password": "mobidigm", "security": "wpa/wpa2"} |
|||
] |
|||
}, |
|||
"eth": { |
|||
"ip": "192.168.55.55" |
|||
}, |
|||
"lte": { |
|||
"port": 534, |
|||
"server": {"ip": "104.208.105.62", "port": 20111} |
|||
} |
|||
}, |
|||
"servers": { |
|||
"protocol": {"ip": "192.168.78.2", "port": 8080} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 4.2 protocol v2 → v3 |
|||
|
|||
**v2 일부**: |
|||
```json |
|||
{ |
|||
"schema_version": 2, |
|||
"dev_type": "RTLS", |
|||
"version": "v1.0", |
|||
"dr_on": true, |
|||
"odo_on": true, |
|||
"heading_on": true, |
|||
"ai0": true, "ai1": false, "ai2": false, "ai3": false, |
|||
"two_byte_order": "big", |
|||
"four_byte_order": "big" |
|||
} |
|||
``` |
|||
|
|||
**v3 변환**: |
|||
```json |
|||
{ |
|||
"schema_version": 3, |
|||
"device": { |
|||
"type": "RTLS", |
|||
"version": "v1.0" |
|||
}, |
|||
"features": { |
|||
"dr": true, |
|||
"odo": true, |
|||
"heading": true |
|||
}, |
|||
"analog_input": { |
|||
"enabled": [true, false, false, false] |
|||
}, |
|||
"byte_order": { |
|||
"two_byte": "big", |
|||
"four_byte": "big" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 5. 양측 변환 함수 spec (pseudocode) |
|||
|
|||
### 5.1 Web Configurator (Python) |
|||
|
|||
```python |
|||
# src/migrations.py — v1 → v2 (옵션 B 채택 가정) |
|||
|
|||
# Boolean 필드 변환 정책 |
|||
_BOOL_FIELDS_DEVICE = ("wifi_static", "log_save") |
|||
_BOOL_FIELDS_PROTOCOL = ( |
|||
"dr_on", "odo_on", "heading_on", "heading_imu_on", |
|||
"fix_mode_on", "can_input", |
|||
"ai0", "ai1", "ai2", "ai3", |
|||
"di0", "di1", "di2", "di3", |
|||
) |
|||
_INT_FIELDS_DEVICE = ("lte_port", "lte_server_port") |
|||
_RENAME_DEVICE = {"WIFI_SSID": "ssid_list"} |
|||
_RENAME_PROTOCOL = {"MEID": "meid"} |
|||
|
|||
|
|||
def _normalize_bool(v): |
|||
"""v1 'on'/'off' 또는 v2 boolean 모두 v2 boolean 으로.""" |
|||
if isinstance(v, bool): |
|||
return v |
|||
if isinstance(v, str): |
|||
s = v.strip().lower() |
|||
if s == "on": return True |
|||
if s == "off": return False |
|||
if s == "1": return True |
|||
if s == "0": return False |
|||
raise ValueError(f"unrecognized boolean value: {v!r}") |
|||
|
|||
|
|||
def _normalize_int(v): |
|||
"""digit string 또는 int 을 int 로.""" |
|||
if isinstance(v, bool): |
|||
raise ValueError("bool not allowed for int field") |
|||
if isinstance(v, int): |
|||
return v |
|||
if isinstance(v, str) and v.strip().lstrip("-").isdigit(): |
|||
return int(v) |
|||
raise ValueError(f"not a valid int: {v!r}") |
|||
|
|||
|
|||
def migrate_device_v1_to_v2(cfg: dict) -> dict: |
|||
"""device_config v1 → v2 변환. idempotent (v2 그대로 두면 no-op).""" |
|||
out = dict(cfg) |
|||
|
|||
if out.get("schema_version") == 2: |
|||
return out # 이미 v2 |
|||
|
|||
# Boolean |
|||
for k in _BOOL_FIELDS_DEVICE: |
|||
if k in out: |
|||
out[k] = _normalize_bool(out[k]) |
|||
|
|||
# Integer |
|||
for k in _INT_FIELDS_DEVICE: |
|||
if k in out: |
|||
out[k] = _normalize_int(out[k]) |
|||
|
|||
# Rename |
|||
for old, new in _RENAME_DEVICE.items(): |
|||
if old in out: |
|||
out[new] = out.pop(old) |
|||
|
|||
# odo_speed.shift / odo_direction.shift 는 protocol_config 에 있으므로 device 에는 없음 |
|||
# (참고용 — protocol 변환은 별도 함수) |
|||
|
|||
out["schema_version"] = 2 |
|||
return out |
|||
|
|||
|
|||
def migrate_protocol_v1_to_v2(cfg: dict) -> dict: |
|||
"""protocol_config v1 → v2 변환.""" |
|||
out = dict(cfg) |
|||
|
|||
if out.get("schema_version") == 2: |
|||
return out |
|||
|
|||
# Boolean |
|||
for k in _BOOL_FIELDS_PROTOCOL: |
|||
if k in out: |
|||
out[k] = _normalize_bool(out[k]) |
|||
|
|||
# Rename |
|||
for old, new in _RENAME_PROTOCOL.items(): |
|||
if old in out: |
|||
out[new] = out.pop(old) |
|||
|
|||
# Nested shift Integer |
|||
for nk in ("odo_speed", "odo_direction"): |
|||
if isinstance(out.get(nk), dict) and "shift" in out[nk]: |
|||
out[nk]["shift"] = _normalize_int(out[nk]["shift"]) |
|||
|
|||
# Array element Integer |
|||
for arr_key in ("OPC_UA", "CAN", "MODBUS"): |
|||
if isinstance(out.get(arr_key), list): |
|||
for elem in out[arr_key]: |
|||
if not isinstance(elem, dict): |
|||
continue |
|||
for k in ("ns", "addr", "shift", "dv"): |
|||
if k in elem and not isinstance(elem[k], int): |
|||
elem[k] = _normalize_int(elem[k]) |
|||
|
|||
out["schema_version"] = 2 |
|||
return out |
|||
|
|||
|
|||
# v2 → v3 변환 (옵션 C 채택 시) |
|||
def migrate_device_v2_to_v3(cfg: dict) -> dict: |
|||
"""device_config v2 → v3 변환. nested 재설계.""" |
|||
if cfg.get("schema_version") == 3: |
|||
return cfg |
|||
|
|||
out = {"schema_version": 3} |
|||
network = {} |
|||
|
|||
# wifi |
|||
wifi = {} |
|||
if "wifi_static" in cfg: wifi["static"] = cfg["wifi_static"] |
|||
if "wifi_ip" in cfg: wifi["ip"] = cfg["wifi_ip"] |
|||
if "wifi_netmask" in cfg: wifi["netmask"] = cfg["wifi_netmask"] |
|||
if "wifi_gateway" in cfg: wifi["gateway"] = cfg["wifi_gateway"] |
|||
if "wifi_dns1" in cfg: wifi["dns1"] = cfg["wifi_dns1"] |
|||
if "wifi_dns2" in cfg: wifi["dns2"] = cfg["wifi_dns2"] |
|||
if "wifi_country_code" in cfg: wifi["country_code"] = cfg["wifi_country_code"] |
|||
if "ssid_list" in cfg: |
|||
# ssid_list element 도 key rename |
|||
wifi["ssid_list"] = [ |
|||
{ |
|||
"ssid": e.get("wifi_ssid"), |
|||
"password": e.get("wifi_passwd"), |
|||
"security": e.get("wifi_security"), |
|||
} |
|||
for e in cfg["ssid_list"] |
|||
] |
|||
if wifi: |
|||
network["wifi"] = wifi |
|||
|
|||
# eth |
|||
eth = {} |
|||
if "eth_ip" in cfg: eth["ip"] = cfg["eth_ip"] |
|||
if "eth_netmask" in cfg: eth["netmask"] = cfg["eth_netmask"] |
|||
if "eth_gateway" in cfg: eth["gateway"] = cfg["eth_gateway"] |
|||
if eth: |
|||
network["eth"] = eth |
|||
|
|||
# lte |
|||
lte = {} |
|||
if "lte_ip" in cfg: lte["ip"] = cfg["lte_ip"] |
|||
if "lte_netmask" in cfg: lte["netmask"] = cfg["lte_netmask"] |
|||
if "lte_gateway" in cfg: lte["gateway"] = cfg["lte_gateway"] |
|||
if "lte_port" in cfg: lte["port"] = cfg["lte_port"] |
|||
if "lte_server_ip" in cfg or "lte_server_port" in cfg: |
|||
lte["server"] = { |
|||
"ip": cfg.get("lte_server_ip"), |
|||
"port": cfg.get("lte_server_port"), |
|||
} |
|||
if lte: |
|||
network["lte"] = lte |
|||
|
|||
if network: |
|||
out["network"] = network |
|||
|
|||
# servers |
|||
servers = {} |
|||
for prefix, key in [ |
|||
("protocol", "protocol"), |
|||
("update", "update"), |
|||
("rtcm", "rtcm"), |
|||
("opc_ua", "opc_ua"), |
|||
("modbus", "modbus"), |
|||
]: |
|||
ip_k = f"{prefix}_server_ip" |
|||
port_k = f"{prefix}_server_port" |
|||
if ip_k in cfg or port_k in cfg: |
|||
servers[key] = {"ip": cfg.get(ip_k), "port": cfg.get(port_k)} |
|||
if servers: |
|||
out["servers"] = servers |
|||
|
|||
# serial |
|||
serial = {} |
|||
can = {} |
|||
if "can_bus_type" in cfg: can["bus_type"] = cfg["can_bus_type"] |
|||
if "can_baudrate" in cfg: can["baudrate"] = cfg["can_baudrate"] |
|||
if can: |
|||
serial["can"] = can |
|||
|
|||
rs485 = {} |
|||
for src, dst in [ |
|||
("rs485_mode", "mode"), |
|||
("rs485_baudrate", "baudrate"), |
|||
("rs485_databits", "databits"), |
|||
("rs485_parity", "parity"), |
|||
("rs485_stopbits", "stopbits"), |
|||
]: |
|||
if src in cfg: |
|||
rs485[dst] = cfg[src] |
|||
if rs485: |
|||
serial["rs485"] = rs485 |
|||
|
|||
if serial: |
|||
out["serial"] = serial |
|||
|
|||
# hardware.imu |
|||
imu_remap = {} |
|||
imu_sign = {} |
|||
for axis in ("x", "y", "z"): |
|||
if f"imu_remap_{axis}" in cfg: |
|||
imu_remap[axis] = cfg[f"imu_remap_{axis}"] |
|||
if f"imu_remap_{axis}_sign" in cfg: |
|||
imu_sign[axis] = cfg[f"imu_remap_{axis}_sign"] |
|||
if imu_remap or imu_sign: |
|||
out["hardware"] = {"imu": {}} |
|||
if imu_remap: |
|||
out["hardware"]["imu"]["remap"] = imu_remap |
|||
if imu_sign: |
|||
out["hardware"]["imu"]["sign"] = imu_sign |
|||
|
|||
# log |
|||
log = {} |
|||
if "log_save" in cfg: log["save"] = cfg["log_save"] |
|||
if "log_max_size" in cfg: log["max_size"] = cfg["log_max_size"] |
|||
if "log_max_duration" in cfg: log["max_duration"] = cfg["log_max_duration"] |
|||
if log: |
|||
out["log"] = log |
|||
|
|||
return out |
|||
``` |
|||
|
|||
### 5.2 dpworldapp (디바이스 측 config-reader 계약) |
|||
|
|||
디바이스 측 동작은 **.56의 배포 dpworldapp 바이너리 대조로 검증**한 아래 contract를 |
|||
충족하면 된다 (구현 언어/방식은 무관). 양측이 동일 의미를 보장하도록 동작만 명시한다: |
|||
|
|||
- **`schema_version`**: 정수로 read. 없으면 v1(레거시)로 간주. |
|||
- **`wifi_static`** (boolean, v1 관용 수용): `true`/`false` 외에 문자열 |
|||
`"on"`/`"true"`/`"1"` → `true`, `"off"`/`"false"`/`"0"` → `false` 로 해석. |
|||
그 외 값은 거부(invalid). |
|||
- **`lte_port`** (string-or-integer 관용 수용): 정수 또는 숫자 문자열(`"5000"`)을 |
|||
모두 정수로 read. 숫자가 아닌 문자열은 거부(invalid). |
|||
|
|||
> 위 관용 수용 규칙은 **reader 단계에서만** 적용되며, write 시점에는 정규(canonical) |
|||
> 형식(boolean·integer)으로 저장한다. |
|||
|
|||
--- |
|||
|
|||
## 6. 운영 시나리오 |
|||
|
|||
### 6.1 양측 v1 → v2 동시 도입 |
|||
|
|||
1. 양측 v1 호환 deserializer + v2 writer 동시 배포 |
|||
2. 디바이스 startup 시 양측이 v1 데이터 read 가능 → 메모리에서 v2 형식 유지 |
|||
3. 다음 write 시점에 v2 형식으로 DB 저장 + `schema_version=2` 명시 |
|||
4. 이후 read 시 schema_version=2 확인 → 변환 skip |
|||
|
|||
### 6.2 옛 fleet 의 v1 → v2 자동 migration |
|||
|
|||
- Web Configurator: `migrations.py` 의 `migrate_device_v1_to_v2` 자동 실행 |
|||
- dpworldapp: startup 시 `migrate_*_config()` 자동 실행 |
|||
- 양측 모두 idempotent — 여러 번 실행해도 안전 |
|||
|
|||
### 6.3 v2 → v3 단계적 전환 (옵션 C 채택 시) |
|||
|
|||
- 양측이 v2 read + write 유지 (안정) |
|||
- v3 호환 deserializer + writer 추가 배포 |
|||
- 운영자 결정 시점에 manual schema_version bump (또는 자동) |
|||
- v2 → v3 변환 함수 양측 동시 실행 |
|||
|
|||
--- |
|||
|
|||
## 7. 검증 (양측 동일 spec) |
|||
|
|||
### 7.1 JSON Schema validation |
|||
|
|||
양측 모두 변환 후 `device_schema_v2.json` (또는 v3) 으로 validation: |
|||
|
|||
```python |
|||
# Web Configurator |
|||
import json |
|||
import jsonschema # 또는 자체 validator |
|||
schema = json.load(open("docs/dpworldapp_schema_handoff/device_schema_v2.json")) |
|||
jsonschema.validate(instance=device_config, schema=schema) |
|||
``` |
|||
|
|||
```java |
|||
// dpworldapp (json-schema-validator 라이브러리 사용 예) |
|||
JsonSchema schema = factory.getSchema(deviceSchemaV2Stream); |
|||
Set<ValidationMessage> errors = schema.validate(deviceConfigJsonNode); |
|||
if (!errors.isEmpty()) { |
|||
// reject |
|||
} |
|||
``` |
|||
|
|||
### 7.2 검증 실패 시 |
|||
|
|||
spec §5.8 정책: hard reject + 명시 에러. |
|||
|
|||
--- |
|||
|
|||
## 8. 잠재 함정 |
|||
|
|||
### 8.1 양측 동시 도입 안 됐을 때 |
|||
|
|||
- Web Configurator v2, dpworldapp v1: dpworldapp 이 v2 형식 못 읽음 → BC deserializer 필수 |
|||
- Web Configurator v1, dpworldapp v2: Web Configurator 가 v2 read 못 함 → 위 마찬가지 |
|||
|
|||
→ **양측 BC deserializer 가 안전망** |
|||
|
|||
### 8.2 array element 의 정규화 불일치 |
|||
|
|||
옵션 B: `ssid_list[].wifi_ssid` 유지 vs 옵션 C: `ssid_list[].ssid` |
|||
|
|||
협의 시 array element 의 prefix 정규화도 같이 결정. |
|||
|
|||
### 8.3 정규화 후 enum mismatch |
|||
|
|||
예: dpworldapp 이 `wpa/wpa2` 만 알고 사용자가 `wpa3` 추가하면 reject. |
|||
|
|||
→ 새 enum value 추가는 RFC-like 프로세스 (spec §5.6) 거쳐야 함. |
|||
|
|||
--- |
|||
|
|||
## 9. 변경 이력 |
|||
|
|||
| 날짜 | 변경 | 출처 | |
|||
|---|---|---| |
|||
| 2026-06-10 | 초안 작성 | Web Configurator team | |
|||
| (협의 후) | 옵션 채택 + 함수 spec 정밀화 | dpworldapp 팀 협의 결과 | |
|||
@ -0,0 +1,222 @@ |
|||
{ |
|||
"$schema": "https://json-schema.org/draft/2020-12/schema", |
|||
"$id": "https://dpworld/schemas/protocol_v2.json", |
|||
"title": "Protocol configuration (schema v2 — flat 정규화)", |
|||
"description": "Web Configurator ↔ dpworldapp 합의 schema. 옵션 B (Medium): boolean/integer 정규화 + naming 일관 (MEID→meid) + enum 명시.", |
|||
"type": "object", |
|||
"required": ["schema_version", "dev_type", "protocol"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"schema_version": { |
|||
"type": "integer", |
|||
"const": 2, |
|||
"description": "본 schema 버전." |
|||
}, |
|||
|
|||
"dev_type": { |
|||
"type": "string", |
|||
"enum": ["RTLS"], |
|||
"default": "RTLS", |
|||
"description": "디바이스 종류. 향후 enum 확장 가능." |
|||
}, |
|||
"version": { |
|||
"type": "string", |
|||
"pattern": "^v\\d+\\.\\d+(\\.\\d+)?$", |
|||
"default": "v1.0", |
|||
"description": "Configuration 버전 string (free-form, schema_version 과 별개)." |
|||
}, |
|||
|
|||
"dr_on": { |
|||
"type": "boolean", |
|||
"default": true, |
|||
"description": "Dead Reckoning 활성. v1 의 \"on\"/\"off\" → v2 의 true/false." |
|||
}, |
|||
"odo_on": { |
|||
"type": "boolean", |
|||
"default": false, |
|||
"description": "Odometer 활성." |
|||
}, |
|||
"heading_on": { |
|||
"type": "boolean", |
|||
"default": true, |
|||
"description": "Heading 계산 활성." |
|||
}, |
|||
"heading_imu_on": { |
|||
"type": "boolean", |
|||
"default": false, |
|||
"description": "IMU 기반 heading 활성." |
|||
}, |
|||
"fix_mode_on": { |
|||
"type": "boolean", |
|||
"default": true, |
|||
"description": "Fix mode 활성." |
|||
}, |
|||
"can_input": { |
|||
"type": "boolean", |
|||
"default": false, |
|||
"description": "CAN 입력 활성." |
|||
}, |
|||
|
|||
"speed_data": { |
|||
"type": "string", |
|||
"enum": ["CAN", "HW", "OPC-UA", "MODBUS"], |
|||
"description": "속도 데이터 source. odo_on=true 시 사용 가능 source 결정." |
|||
}, |
|||
|
|||
"equipment": { |
|||
"type": "string", |
|||
"enum": ["ITV", "FL", "CR"], |
|||
"description": "장비 종류. ★ 협의 항목: dpworldapp 팀 enum 전체 list 확정 필요 (spec §8 Q1)." |
|||
}, |
|||
"equipment_id": { |
|||
"type": "string", |
|||
"pattern": "^\\d{2}$", |
|||
"description": "장비 ID (zero-padded 2-digit)." |
|||
}, |
|||
"meid": { |
|||
"type": "integer", |
|||
"minimum": 0, |
|||
"description": "MEID. v1 의 'MEID' upper-case (int) → v2 의 meid snake_case (int). default file 의 int 형식이 정답." |
|||
}, |
|||
|
|||
"protocol": { |
|||
"type": "string", |
|||
"enum": ["OPC-UA", "MODBUS", "CAN-BUS", "NONE"], |
|||
"description": "활성 protocol. 'NONE' 시 OPC_UA/CAN/MODBUS array 보존 (data loss 방지)." |
|||
}, |
|||
|
|||
"two_byte_order": { |
|||
"type": "string", |
|||
"enum": ["big", "little"], |
|||
"default": "big", |
|||
"description": "2-byte field 의 byte order." |
|||
}, |
|||
"four_byte_order": { |
|||
"type": "string", |
|||
"enum": ["big", "little", "bigSwap", "littleSwap"], |
|||
"default": "big", |
|||
"description": "4-byte field 의 byte order." |
|||
}, |
|||
"analog_input_level": { |
|||
"type": "string", |
|||
"enum": ["2", "4", "6"], |
|||
"description": "Analog input voltage level. string 으로 유지 — device 계약 정합." |
|||
}, |
|||
|
|||
"ai0": {"type": "boolean", "default": false, "description": "Analog Input 0 활성. v1 의 \"1\"/\"0\" → v2 의 true/false."}, |
|||
"ai1": {"type": "boolean", "default": false, "description": "Analog Input 1 활성."}, |
|||
"ai2": {"type": "boolean", "default": false, "description": "Analog Input 2 활성."}, |
|||
"ai3": {"type": "boolean", "default": false, "description": "Analog Input 3 활성."}, |
|||
"di0": {"type": "boolean", "default": false, "description": "Digital Input 0 활성."}, |
|||
"di1": {"type": "boolean", "default": false, "description": "Digital Input 1 활성."}, |
|||
"di2": {"type": "boolean", "default": false, "description": "Digital Input 2 활성."}, |
|||
"di3": {"type": "boolean", "default": false, "description": "Digital Input 3 활성."}, |
|||
|
|||
"odo_speed": { |
|||
"$ref": "#/$defs/OdoField", |
|||
"description": "Odometer speed 필드 정의." |
|||
}, |
|||
"odo_direction": { |
|||
"$ref": "#/$defs/OdoField", |
|||
"description": "Odometer direction 필드 정의." |
|||
}, |
|||
|
|||
"OPC_UA": { |
|||
"type": "array", |
|||
"description": "OPC-UA register mapping 목록. ★ 협의: array name 도 lowercase 통일 'opc_ua' 가능 (옵션 C 부분).", |
|||
"items": {"$ref": "#/$defs/OpcUaRegister"} |
|||
}, |
|||
"CAN": { |
|||
"type": "array", |
|||
"description": "CAN-BUS register mapping 목록.", |
|||
"items": {"$ref": "#/$defs/CanRegister"} |
|||
}, |
|||
"MODBUS": { |
|||
"type": "array", |
|||
"description": "Modbus register mapping 목록.", |
|||
"items": {"$ref": "#/$defs/ModbusRegister"} |
|||
} |
|||
}, |
|||
|
|||
"$defs": { |
|||
"OdoField": { |
|||
"type": "object", |
|||
"required": ["source"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"source": { |
|||
"type": "string", |
|||
"enum": ["CAN", "HW", "OPC-UA", "MODBUS"], |
|||
"description": "Odometer 데이터 source." |
|||
}, |
|||
"id": { |
|||
"type": "string", |
|||
"pattern": "^0x[0-9a-fA-F]+$", |
|||
"description": "CAN ID (hex string, source='CAN' 시 사용). 가독성을 위해 hex string 유지." |
|||
}, |
|||
"shift": { |
|||
"type": "integer", |
|||
"minimum": 0, |
|||
"maximum": 63, |
|||
"description": "비트 shift. v1 의 string \"8\" → v2 의 integer 8." |
|||
}, |
|||
"mask": { |
|||
"type": "string", |
|||
"pattern": "^0x[0-9a-fA-F]+$", |
|||
"description": "비트 mask (hex string). 가독성을 위해 hex string 유지." |
|||
}, |
|||
"expr": { |
|||
"type": "string", |
|||
"maxLength": 256, |
|||
"description": "변환 expression DSL (`x*0.05+10.0`). forbidden chars 정책: v1.4.8 Stage 4 참조." |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"OpcUaRegister": { |
|||
"type": "object", |
|||
"required": ["field", "ns", "addr", "odt"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"field": {"type": "string", "description": "Logical field 이름."}, |
|||
"ns": {"type": "integer", "minimum": 0, "description": "OPC-UA namespace. v1 의 string \"3\" → v2 의 integer 3."}, |
|||
"addr": {"type": "integer", "minimum": 0, "description": "OPC-UA node address. v1 의 string \"1001\" → v2 의 integer."}, |
|||
"odt": { |
|||
"type": "string", |
|||
"enum": ["integer", "float", "float64", "boolean", "string"], |
|||
"description": "Output data type. ★ float64/float/string 은 v1.5.5+ 시점 dpworldapp 처리에 결함 — handoff doc 참조." |
|||
}, |
|||
"dv": {"type": "integer", "description": "Default value. v1 의 string → v2 의 integer."}, |
|||
"expr": {"type": "string", "maxLength": 256, "description": "변환 expression."} |
|||
} |
|||
}, |
|||
|
|||
"CanRegister": { |
|||
"type": "object", |
|||
"required": ["field", "id", "shift", "mask", "odt"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"field": {"type": "string"}, |
|||
"id": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$", "description": "CAN ID (hex string)."}, |
|||
"shift": {"type": "integer", "minimum": 0, "maximum": 63}, |
|||
"mask": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, |
|||
"odt": {"type": "string", "enum": ["integer", "float", "float64", "boolean", "string"]}, |
|||
"dv": {"type": "integer"}, |
|||
"expr": {"type": "string", "maxLength": 256} |
|||
} |
|||
}, |
|||
|
|||
"ModbusRegister": { |
|||
"type": "object", |
|||
"required": ["field", "addr", "odt"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"field": {"type": "string"}, |
|||
"addr": {"type": "integer", "minimum": 0, "description": "Modbus register address."}, |
|||
"odt": {"type": "string", "enum": ["integer", "float", "float64", "boolean", "string"]}, |
|||
"dv": {"type": "integer"}, |
|||
"expr": {"type": "string", "maxLength": 256} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,172 @@ |
|||
{ |
|||
"$schema": "https://json-schema.org/draft/2020-12/schema", |
|||
"$id": "https://dpworld/schemas/protocol_v3.json", |
|||
"title": "Protocol configuration (schema v3 — nested 재설계)", |
|||
"description": "Web Configurator ↔ dpworldapp 옵션 C. v2 의 정규화 + Nested grouping. ★ 큰 BC break. future ideal.", |
|||
"type": "object", |
|||
"required": ["schema_version", "device", "protocol"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"schema_version": {"type": "integer", "const": 3}, |
|||
|
|||
"device": { |
|||
"type": "object", |
|||
"required": ["type"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"type": {"type": "string", "enum": ["RTLS"], "description": "디바이스 종류."}, |
|||
"version": {"type": "string", "pattern": "^v\\d+\\.\\d+(\\.\\d+)?$", "default": "v1.0"}, |
|||
"equipment": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"type": {"type": "string", "enum": ["ITV","FL","CR"]}, |
|||
"id": {"type": "string", "pattern": "^\\d{2}$"}, |
|||
"meid": {"type": "integer", "minimum": 0} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"protocol": { |
|||
"type": "string", |
|||
"enum": ["OPC-UA", "MODBUS", "CAN-BUS", "NONE"], |
|||
"description": "활성 protocol." |
|||
}, |
|||
|
|||
"features": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"description": "On/Off 기능 토글 — v2 의 dr_on/odo_on 등 boolean fields 그룹화.", |
|||
"properties": { |
|||
"dr": {"type": "boolean", "default": true, "description": "Dead Reckoning."}, |
|||
"odo": {"type": "boolean", "default": false, "description": "Odometer."}, |
|||
"heading": {"type": "boolean", "default": true, "description": "Heading 계산."}, |
|||
"heading_imu": {"type": "boolean", "default": false, "description": "IMU 기반 heading."}, |
|||
"fix_mode": {"type": "boolean", "default": true, "description": "Fix mode."}, |
|||
"can_input": {"type": "boolean", "default": false, "description": "CAN 입력."} |
|||
} |
|||
}, |
|||
|
|||
"speed_data": { |
|||
"type": "string", |
|||
"enum": ["CAN", "HW", "OPC-UA", "MODBUS"], |
|||
"description": "속도 데이터 source." |
|||
}, |
|||
|
|||
"byte_order": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"description": "v2 의 two_byte_order / four_byte_order 통합 그룹.", |
|||
"properties": { |
|||
"two_byte": {"type": "string", "enum": ["big","little"], "default": "big"}, |
|||
"four_byte": {"type": "string", "enum": ["big","little","bigSwap","littleSwap"], "default": "big"} |
|||
} |
|||
}, |
|||
|
|||
"analog_input": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"description": "Analog input level + ai0~ai3.", |
|||
"properties": { |
|||
"level": {"type": "string", "enum": ["2","4","6"]}, |
|||
"enabled": { |
|||
"type": "array", |
|||
"minItems": 4, |
|||
"maxItems": 4, |
|||
"description": "ai0~ai3 활성 여부 (boolean 4개).", |
|||
"items": {"type": "boolean"} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"digital_input": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"enabled": { |
|||
"type": "array", |
|||
"minItems": 4, |
|||
"maxItems": 4, |
|||
"description": "di0~di3 활성 여부.", |
|||
"items": {"type": "boolean"} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"odo": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"description": "Odometer 설정.", |
|||
"properties": { |
|||
"speed": {"$ref": "#/$defs/OdoField"}, |
|||
"direction": {"$ref": "#/$defs/OdoField"} |
|||
} |
|||
}, |
|||
|
|||
"registers": { |
|||
"type": "object", |
|||
"additionalProperties": false, |
|||
"description": "v2 의 OPC_UA/CAN/MODBUS upper-case array → lowercase nested 그룹.", |
|||
"properties": { |
|||
"opc_ua": {"type": "array", "items": {"$ref": "#/$defs/OpcUaRegister"}}, |
|||
"can": {"type": "array", "items": {"$ref": "#/$defs/CanRegister"}}, |
|||
"modbus": {"type": "array", "items": {"$ref": "#/$defs/ModbusRegister"}} |
|||
} |
|||
} |
|||
}, |
|||
|
|||
"$defs": { |
|||
"OdoField": { |
|||
"type": "object", |
|||
"required": ["source"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"source": {"type": "string", "enum": ["CAN","HW","OPC-UA","MODBUS"]}, |
|||
"id": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, |
|||
"shift": {"type": "integer", "minimum": 0, "maximum": 63}, |
|||
"mask": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, |
|||
"expr": {"type": "string", "maxLength": 256} |
|||
} |
|||
}, |
|||
"OpcUaRegister": { |
|||
"type": "object", |
|||
"required": ["field","ns","addr","odt"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"field": {"type": "string"}, |
|||
"ns": {"type": "integer", "minimum": 0}, |
|||
"addr": {"type": "integer", "minimum": 0}, |
|||
"odt": {"type": "string", "enum": ["integer","float","float64","boolean","string"]}, |
|||
"dv": {"type": "integer"}, |
|||
"expr": {"type": "string", "maxLength": 256} |
|||
} |
|||
}, |
|||
"CanRegister": { |
|||
"type": "object", |
|||
"required": ["field","id","shift","mask","odt"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"field": {"type": "string"}, |
|||
"id": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, |
|||
"shift": {"type": "integer", "minimum": 0, "maximum": 63}, |
|||
"mask": {"type": "string", "pattern": "^0x[0-9a-fA-F]+$"}, |
|||
"odt": {"type": "string", "enum": ["integer","float","float64","boolean","string"]}, |
|||
"dv": {"type": "integer"}, |
|||
"expr": {"type": "string", "maxLength": 256} |
|||
} |
|||
}, |
|||
"ModbusRegister": { |
|||
"type": "object", |
|||
"required": ["field","addr","odt"], |
|||
"additionalProperties": false, |
|||
"properties": { |
|||
"field": {"type": "string"}, |
|||
"addr": {"type": "integer", "minimum": 0}, |
|||
"odt": {"type": "string", "enum": ["integer","float","float64","boolean","string"]}, |
|||
"dv": {"type": "integer"}, |
|||
"expr": {"type": "string", "maxLength": 256} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
# Firmware Boot Hardening — dpworld-network-apply-hardened.sh |
|||
|
|||
## 근본 원인 (PMU 워치독 재부팅 루프, 2026-06-15) |
|||
|
|||
`.56` (telechips-tcc8030-main)이 하드웨어 재부팅 루프에 진입했다. 타임라인: |
|||
|
|||
1. **트리거:** WiFi 국가 코드 변경이 DB에 대기(pending) 상태였다. |
|||
2. **부팅 경로:** `dpworld-network-seed.service`가 펌웨어 내장 |
|||
`/usr/bin/dpworld-network-apply.sh --boot`를 실행했다. |
|||
3. **행(Hang):** 원본 펌웨어 스크립트가 `modprobe -r wlan`을 호출해 |
|||
새 국가 코드로 WiFi 모듈을 라이브 재로드하려 했다. QCA6490 |
|||
(cnss_pci / Qualcomm WLAN.HSP.1.1) 모듈에서 이 호출은 **무한 |
|||
정지** 상태가 된다 — PCIe 링크 해제가 완료되지 않기 때문이다. |
|||
4. **리셋:** Telechips PMU 하드웨어 워치독이 20초 후 발동 → |
|||
하드 리셋 → 2단계부터 루프 반복. |
|||
5. **해결:** 하드닝 스크립트를 라이브 디바이스에 설치(~07:44). 이후 |
|||
디바이스 안정 유지 중. |
|||
|
|||
## 하드닝 스크립트 |
|||
|
|||
`deploy/dpworld-network-apply-hardened.sh`는 펌웨어 내장 |
|||
`dpworld-network-apply.sh`를 POSIX sh로 독립 재작성한 것이다. |
|||
펌웨어 원본의 문서화된 6가지 문제(P1–P6, CC)를 수정하며, |
|||
부팅 하드닝 수정은 **P2**에 해당한다. |
|||
|
|||
### P2: 부팅 시 modprobe -r 제거 |
|||
|
|||
| | 펌웨어 원본 | 하드닝 버전 | |
|||
|---|---|---| |
|||
| `--boot` + 국가 코드 변경 | `reload_wifi_modules()` 호출 → `modprobe -r wlan` → **HANG** | `modprobe.d` 기록 + `reboot-required` 마커 설정; **modprobe 호출 없음** | |
|||
| `--country-now` (전문가 모드) | 동일한 `reload_wifi_modules()` | 호환성을 위해 인자 수용하나 **재부팅 지연 처리** — 의도를 로그에 기록하고 `reboot-required` 유지; **라이브 모듈 언로드 없음** (v1.11.6: 행(hang)이 드라이버 언로드 경로 자체에서 발생하므로 가드된 전문가 재로드도 미탑재) | |
|||
| 국가 코드 검증 | `wpa_cli get country` (wpa 설정 렌더링 기반 — 거짓 양성 가능) | `live_country()`: sysfs `/sys/module/wlan/parameters/country_code` → `iw reg get` 폴백 | |
|||
|
|||
### 세 가지 호출 모드 |
|||
|
|||
``` |
|||
dpworld-network-apply-hardened.sh # 온디맨드 (apply.service 또는 수동) |
|||
dpworld-network-apply-hardened.sh --boot # 부팅 시드 (라이브 터치 없음, /run 만 채움) |
|||
dpworld-network-apply-hardened.sh --country-now # 호환 별칭 — 재부팅 지연 처리 (라이브 모듈 재로드 없음) |
|||
``` |
|||
|
|||
### reboot-required 마커 |
|||
|
|||
- **설정:** 라디오의 실효 국가 코드(sysfs/iw reg)가 원하는 국가 코드와 다를 때 |
|||
`${STATE_DIR}/reboot-required` (`/opt/dpworld-network/reboot-required`)에 생성. |
|||
- **해제:** `live_country()` == 원하는 국가 코드일 때 — 즉, 업데이트된 `modprobe.d`가 |
|||
모듈 로드 시점에 적용된 재부팅 이후. |
|||
- Web Configurator는 이 마커를 읽어 대시보드에 "Reboot required"를 표시한다. |
|||
|
|||
### 빈 네트워크 부트스트랩 (v1.11.6) |
|||
|
|||
`--boot` 실행 시(및 온디맨드 시), `/home/root/network`가 비어 있으면 |
|||
`/run/systemd/network` 동기화 **전에** `/etc/dpworld/network-defaults`에서 |
|||
시드를 채운다 — 펌웨어의 팩토리/복구 부트스트랩을 복원하는 동작이다. |
|||
디렉터리에 파일이 하나라도 있으면 절대 덮어쓰지 않는다. |
|||
|
|||
## v1.6 Country-Deferred 설계와의 관계 |
|||
|
|||
v1.6에서 Python `apply_engine`이 도입됐으며, 이미 국가 코드 변경을 |
|||
`modprobe.d`로 지연 처리하고 동일 마커를 설정하도록 설계되어 있다. |
|||
하드닝 스크립트는 **펌웨어 레이어**에서 동일하게 동작하는 계층을 추가한다 — |
|||
Python 엔진이 실행 중이지 않은 경우(예: 새 플래시 직후나 서비스 크래시 시)에도 |
|||
펌웨어 자체 부팅 서비스가 modprobe hang을 유발할 수 없게 된다. |
|||
|
|||
두 레이어 모두 동일한 `REBOOT_MARK` 경로와 `MODPROBE_CONF`에 기록하므로 |
|||
멱등(idempotent)적이며 조합해서 사용할 수 있다. 하드닝 스크립트는 Python |
|||
엔진의 `country-now` 마커(`/run/dpworld-network/country-now`)를 확인하여 |
|||
존재하면 `COUNTRY_NOW=1`로 승격한다 — 엔진 호환성을 유지하기 위함이다. |
|||
v1.11.6부터는 `COUNTRY_NOW=1`이어도 하드닝 스크립트가 WiFi 모듈을 라이브 |
|||
언로드하지 **않는다**; 마커를 소비하고, 지연 처리를 로그에 남기며, `reboot-required` |
|||
마커를 유지한다. |
|||
|
|||
## 설치 / 배포 |
|||
|
|||
`scripts/deploy.ps1`은 배포 시마다 세 가지 아티팩트를 설치한다 |
|||
(해시 비교 방식, 멱등, 영속적 `/lib/systemd/system`): |
|||
|
|||
| 로컬 경로 | 디바이스 경로 | |
|||
|---|---| |
|||
| `deploy/dpworld-network-apply-hardened.sh` | `/usr/bin/dpworld-network-apply-hardened.sh` (chmod +x) | |
|||
| `deploy/dpworld-network-apply.service.d/20-hardened.conf` | `/lib/systemd/system/dpworld-network-apply.service.d/20-hardened.conf` | |
|||
| `deploy/dpworld-network-seed.service.d/20-hardened.conf` | `/lib/systemd/system/dpworld-network-seed.service.d/20-hardened.conf` | |
|||
|
|||
변경 후에는 `systemctl daemon-reload`가 호출된다. 세 경로 모두 영속적 |
|||
읽기-쓰기 오버레이(`/lib`) 위에 있어 재부팅 및 유저스페이스 펌웨어 재플래시 |
|||
이후에도 유지된다. |
|||
|
|||
펌웨어 원본 `/usr/bin/dpworld-network-apply.sh`는 **수정하지 않는다**. |
|||
|
|||
## 테스트 |
|||
|
|||
`tests/test_firmware_apply_hardened.py` (23개 테스트, 표준 라이브러리만 사용, 디바이스 불필요): |
|||
|
|||
- 아티팩트가 예상 경로에 존재하는지 확인. |
|||
- 스크립트 내 **`modprobe -r` 구문이 전혀 없음** (가드된 전문가 경로 포함). |
|||
- `safe_module_reload` / `reload_wifi_modules` 헬퍼가 잔존하지 않음. |
|||
- 빈 네트워크 부트스트랩(`seed_defaults_if_empty`)이 `/run` 동기화 전에 |
|||
`/etc/dpworld/network-defaults`에서 시드를 채우는지 확인. |
|||
- `reboot-required` 마커가 올바르게 설정 및 해제되는지 확인. |
|||
- 마커 판단이 `live_country()`를 사용하는지 확인 (`wpa_cli get country` 아님). |
|||
- 두 드롭인(drop-in) 모두 `ExecStart`가 하드닝 스크립트를 가리키는지 확인. |
|||
- `deploy.ps1`이 스크립트, 두 드롭인을 설치하고 `daemon-reload`를 호출하는지 확인. |
|||
- 세 아티팩트 모두 LF 줄 끝 (CRLF 없음). |
|||
|
|||
## 펌웨어 베이킹 (BSP) — 원본 교체 방법 |
|||
|
|||
위 "설치 / 배포" 방식은 Web Configurator가 사용하는 **라이브 디바이스 오버라이드** 방식이다: |
|||
`dpworld-network-apply-hardened.sh`를 별도 파일로 설치하고, 두 개의 systemd 드롭인을 |
|||
함께 설치하되, 펌웨어 원본 `/usr/bin/dpworld-network-apply.sh`는 손대지 않는다 |
|||
(실행 중인 디바이스는 펌웨어에 베이킹된 원본을 덮어쓸 수 없으며, 재플래시하면 원본이 복원된다). |
|||
|
|||
**펌웨어 이미지(BSP 베이킹) 시에는 대신 다음과 같이 진행한다:** |
|||
|
|||
1. `deploy/dpworld-network-apply-hardened.sh`의 내용을 |
|||
`/usr/bin/dpworld-network-apply.sh`에 설치한다 — 즉, **원본 이름을 유지한 채 |
|||
펌웨어 원본 스크립트를 교체**한다 (root:root, 0755, LF, POSIX /bin/sh). |
|||
하드닝 스크립트의 헤더에 이미 이 최종 이름이 명시되어 있다. |
|||
2. 두 오버라이드 드롭인(`dpworld-network-apply.service.d/20-hardened.conf`와 |
|||
`dpworld-network-seed.service.d/20-hardened.conf`)은 **베이킹하지 않는다**. |
|||
이 드롭인들은 라이브 디바이스 오버라이드 전용이며 `…-hardened.sh`를 가리키는데, |
|||
베이킹 이미지에는 해당 파일이 존재하지 않는다. 펌웨어 자체의 |
|||
`dpworld-network-seed.service` / `dpworld-network-apply.service`가 이미 |
|||
`/usr/bin/dpworld-network-apply.sh`를 호출하므로 하드닝 동작이 자동으로 적용된다. |
|||
3. 하드닝 스크립트는 원본을 완전히 독립적으로 재작성한 것으로 (모든 호출 모드 포함: |
|||
인자 없음 / `--boot` / `--country-now`), 원본을 내부적으로 호출하지 않는다. |
|||
베이킹 전 펌웨어 원본과 diff를 수행해 펌웨어 고유 책임 사항이 누락되지 않았는지 |
|||
확인할 것. |
|||
|
|||
**예상되는 동작 변경:** 하드닝 스크립트는 QCA6490 / cnss_pci 모듈에서 PMU 워치독 |
|||
재부팅 루프를 유발하는 라이브 `modprobe -r wlan` (국가 코드 라이브 재로드)을 |
|||
제거한다 — **이전의 `--country-now` 전문가 경로 포함** (v1.11.6). 국가 코드 변경은 |
|||
항상 **재부팅 지연 처리**된다: 새 국가 코드가 `modprobe.d`에 기록되고 `reboot-required` |
|||
마커가 설정되며, 다음 재부팅 시에 적용된다. 이는 P2 수정이며 회귀가 아니다. |
|||
@ -0,0 +1,131 @@ |
|||
# Firmware OTA 서브시스템 가이드 |
|||
|
|||
> 살아있는 문서(living guide) — 앱 **v1.11.10** 기준. 코드: `src/firmware/*.py`. |
|||
> 아키텍처 전체는 [`architecture.md`](architecture.md) §3.1.2. |
|||
|
|||
웹 UI 에서 펌웨어 ZIP 을 올려 dpworldapp 의 FW-MMI 채널(**TCP 8990**)로 플래시한다. |
|||
플래시 전 config 백업, 재부팅 후 복원 검사까지 한 흐름으로 오케스트레이션한다. |
|||
오케스트레이터는 `FirmwareController`(단일 인스턴스, thread-safe). |
|||
|
|||
--- |
|||
|
|||
## 1. staging → flash 흐름 |
|||
|
|||
``` |
|||
upload(ZIP) → stage(추출·sha256) → preflight(게이트) → flash(백업→전송→commit) → reboot → restore-check |
|||
``` |
|||
|
|||
1. **upload** — 요청 본문(펌웨어 ZIP)을 디스크 임시파일에 1MB 청크로 스트리밍. |
|||
버퍼는 **반드시 디스크 백엔드**(`/opt/fw_upload`) — `MemoryMax=48M`·no-swap 환경에서 |
|||
tmpfs(`/tmp`)에 수백 MB 를 버퍼하면 cgroup OOM 으로 서비스가 죽는다. |
|||
2. **stage** — ZIP 추출 → 확장자로 컴포넌트 자동 식별 → 각 파일 sha256 계산 → `/opt/fw_staging`. |
|||
zip-bomb/디스크 채움 가드(컴포넌트당·전체 압축 해제 크기 상한, 중복 파일명 거부, free-space 확인). |
|||
sidecar(`.fw_build_dates.json`)에 sha256+build_date 를 증분 기록 → 서비스 재시작 시 |
|||
`_recover_staging` 가 sha256 재검증으로 안전 복구(불일치/미기록 파일은 거부·삭제). |
|||
3. **preflight** — 게이트 체크리스트. critical 전부 통과해야 `ok`. |
|||
4. **flash** — 백그라운드 워커가 백업 → 컴포넌트 전송 → commit → reboot 트리거. |
|||
5. **restore-check** — 재부팅·재접속 후 운영자가 호출 → reseed 감지 시 config 복원 + dpworldapp 재시작. |
|||
|
|||
### 컴포넌트 식별 (확장자 → wire signature) |
|||
|
|||
| 확장자 | role | signature | |
|||
|--------|------|-----------| |
|||
| `.rom` | bootloader | `BTL` | |
|||
| `.img` | kernel | `KRN` | |
|||
| `.ext4` | rootfs | `RTF` | |
|||
| `.dtb` | dtb | `DTB` | |
|||
|
|||
commit 은 별도 message-only 프레임(`CPU` signature, payload 0). |
|||
|
|||
--- |
|||
|
|||
## 2. phase stepper (UI 상태) |
|||
|
|||
`fw_controller._PHASES` — 순서 의미 있음. 각 phase 는 `pending`/`active`/`done`/`error`, |
|||
tier 는 `na`/`warn`/`ok`/`error` 로 매핑. |
|||
|
|||
| key | label | 의미 | |
|||
|-----|-------|------| |
|||
| `upload` | Upload | ZIP 수신 | |
|||
| `verify` | Verify | sha256 계산/검증 | |
|||
| `preflight` | Pre-flight | 게이트 체크 | |
|||
| `backup` | Backup | 플래시 전 config 백업 | |
|||
| `flash` | Flash | 컴포넌트 전송 | |
|||
| `commit` | Commit | 슬롯 기록·커밋(마지막 컴포넌트 100% 시 시작) | |
|||
| `reboot` | Reboot | 장치 재부팅(~10s) | |
|||
| `config` | Config check | 재부팅 후 복원 검사 | |
|||
|
|||
상태머신: `idle`→`staging`→`staged`→`flashing`→`rebooting`→`done` (실패 시 `failed`). |
|||
`flashing`/`staging`/`rebooting`/`done` 중에는 새 upload/flash 를 거부(race 방지). |
|||
|
|||
### preflight 게이트 항목 |
|||
|
|||
- `staging`(컴포넌트 staged, **critical**) |
|||
- `space`(여유공간 headroom 128MB, **critical**) — staged 바이트는 이미 디스크에 있으므로 중복 계상 안 함 |
|||
- `fw_port`(dpworldapp FW 포트 8990 도달, **critical**) |
|||
- `db`(config DB 존재, non-critical — flash 는 되지만 restore-check 에 필요) |
|||
- `slot`(활성 A/B 슬롯, informational) |
|||
|
|||
--- |
|||
|
|||
## 3. config 안전 (config_safety) |
|||
|
|||
- **플래시 전 백업**: `backup_config` 가 `device_config`/`protocol_config`(+ DB)를 |
|||
`/opt/config_backups/<타임스탬프>/` 로 저장. `backup` phase 에서 수행. |
|||
- **재부팅 후 복원 검사**(`detect_and_restore`): 펌웨어 flash 가 config 를 factory default 로 |
|||
reseed 했는지 **필드 단위로 감지**한다. "reseed" 판정 = 사용자 구별 필드가 대거 factory default |
|||
로 되돌아갔고(≥80%, 최소 2개) **새 편집 흔적이 전혀 없을 때**(do-no-harm). 이 경우에만 백업본을 |
|||
**단일 트랜잭션**(BEGIN IMMEDIATE)으로 원자 복원하고 dpworldapp 을 재시작한다. |
|||
애매하면 복원하지 않음(fail-safe toward no-restore). |
|||
- DB 가 읽기 불가/잠금/비어 있어도 500 으로 죽지 않고 사유를 보고한다. |
|||
- restore-check 응답은 **정직성** 원칙: dpworldapp 재시작이 실제 실패하면 |
|||
`restart_error` 를 명시하고 "재시작됨" 으로 거짓 보고하지 않는다(v1.5.4.5 STAB-2). |
|||
|
|||
--- |
|||
|
|||
## 4. TCP 8990 dpworldapp 채널 (wire protocol) |
|||
|
|||
`fw_client` 가 `protocol` 프레이밍으로 8990 에 전송한다(`server.py`: `FW_HOST`/`FW_PORT`, |
|||
기본 `127.0.0.1:8990`). dpworldapp 유닛 상태와 무관하게 서빙되는 포트다. |
|||
|
|||
- **프레임** = ASCII signature + 8바이트 little-endian size + `<size>` payload. |
|||
message-only step 은 size 0, payload 없음. |
|||
- **ACK**: 성공 `SUCCESS`, 실패 `FW_FAIL`(deployed firmware behavior 확인). `classify_ack` 가 success 우선 분류. |
|||
- **per-step ACK 타임아웃(ms)**: BTL/KRN/RTF 20000, DTB 10000, CPU(commit) 120000. |
|||
|
|||
--- |
|||
|
|||
## 5. A/B rootfs 슬롯 + `/opt` 영속 (flash 생존) |
|||
|
|||
- 장치는 **A/B rootfs 슬롯**(p5=A, p6=B)을 가지며 flash 마다 flip 한다. 활성 슬롯은 |
|||
`/proc/cmdline` 의 `root=` 로 best-effort 탐지(`_detect_slot`). status 에 `slot.before/after` 노출. |
|||
- commit 성공 후 staging(`/opt/fw_staging`)은 공간 회수를 위해 삭제(슬롯에 이미 기록됨). |
|||
config 백업(`/opt/config_backups`)은 **보존**. |
|||
- `/opt`(p12)·`/home/root`(p11)는 flash 후에도 **영속** — 업로드 버퍼·staging·config 백업·DB 가 |
|||
여기에 있어 flash 를 견딘다. 단, web-configurator/AP 의 systemd 유닛 등 rootfs(`/lib`) 자산은 |
|||
flash 마다 wipe 된다(§6). |
|||
|
|||
--- |
|||
|
|||
## 6. ⚠ flash 생존 caveat |
|||
|
|||
펌웨어 flash 는 rootfs 슬롯을 통째로 교체하므로 rootfs(`/lib/systemd` 유닛, `/usr/bin` 스크립트)에 |
|||
설치된 web-configurator·Wi-Fi AP·network-apply 자산이 **매번 wipe** 된다. flash 후에는 이들을 |
|||
재설치해야 한다. 근본 자동화는 BSP 이미지 베이킹이 정답(백로그) — 메모리 `web-survives-flash` 참조. |
|||
|
|||
--- |
|||
|
|||
## 7. API |
|||
|
|||
| Method | Path | 비고 | |
|||
|--------|------|------| |
|||
| `GET` | `/api/firmware/status` | phase stepper + 컴포넌트 진행률 + slot + backup + message/error | |
|||
| `POST` | `/api/firmware/preflight` | 게이트 체크리스트(`{ok, checks}`) | |
|||
| `POST` | `/api/firmware/upload` | ZIP 스트리밍 업로드(per-read 소켓 타임아웃) → stage. `{ok, components}` | |
|||
| `POST` | `/api/firmware/flash` | staged 컴포넌트 플래시 시작. 진행 중이면 409 | |
|||
| `POST` | `/api/firmware/restore-check` | reseed 감지 → 복원 + dpworldapp 재시작 | |
|||
|
|||
오류는 sanitize 되어 내부 fs 경로/예외 내부를 노출하지 않는다(공간부족 507, 잘못된 ZIP 400 등). |
|||
|
|||
> **API 인증 없음**: :9090 은 현재 미인증(문서화된 threat boundary). 운영자 로그인은 설계 |
|||
> 백로그 — [`architecture.md`](architecture.md) §10. |
|||
File diff suppressed because it is too large
@ -0,0 +1,132 @@ |
|||
# Wi-Fi AP 서브시스템 가이드 |
|||
|
|||
> 살아있는 문서(living guide) — 앱 **v1.11.10** 기준. 코드: `src/network/ap_*.py`, |
|||
> `deploy/dpworld-ap-apply.sh`. 아키텍처 전체는 [`architecture.md`](architecture.md) §3.1.1. |
|||
|
|||
장비를 소프트 AP(`ap0`)로 띄워 운영자가 무선으로 Web Configurator(:9090)에 접속하게 한다. |
|||
STA(`wlan0`, 업스트림 Wi-Fi)는 **무접촉**으로 유지한다. |
|||
|
|||
--- |
|||
|
|||
## 1. ap0 가상 인터페이스 (vif) |
|||
|
|||
- `wlan0` 위에 `iw dev wlan0 interface add ap0 type __ap` 로 **별도 vif** 생성. |
|||
- **MAC**: `wlan0` MAC 첫 옥텟에 local-admin bit(`0x02`)를 OR 해서 별도 MAC 부여 |
|||
(`ap_mac()` in `dpworld-ap-apply.sh`) — STA 와 충돌하지 않는 LA-bit 주소. |
|||
- **채널 = SCC(Single-Channel Concurrency)**: 기본(`ap_band=auto`)은 STA 가 붙어 있는 채널을 |
|||
**추종**한다(`ap_engine.resolve_channel`). 단일 라디오는 동시에 한 채널만 쓸 수 있어, |
|||
STA 와 AP 가 같은 채널이어야 둘 다 동작한다. STA 미연결 시 2.4GHz 채널 6 으로 폴백. |
|||
`2g`/`5g` 명시(MCC)는 옵트인이며 STA 와 채널이 갈리면 한쪽이 끊길 수 있다. |
|||
|
|||
--- |
|||
|
|||
## 2. ap_engine 라이프사이클 |
|||
|
|||
`ApEngine`(`src/network/ap_engine.py`)이 적용을 오케스트레이션한다. 직렬화 lock 으로 |
|||
동시 apply 의 tmp 파일 경합·서비스 중복 start 를 막는다. |
|||
|
|||
1. `ap_config` → intent 정규화(`ap_model.ap_intent_from_db`). |
|||
2. `validate_ap()` hard-rule 검증 → 실패 시 상태 `REJECTED`(파일 미작성). |
|||
3. 활성화면: 채널 산정 → `hostapd-ap0.conf` / `udhcpd-ap0.conf` 렌더(0600, 디렉터리 0700) → |
|||
**marker** `ap-enabled` 작성. 비활성화면 marker 제거. |
|||
4. `systemctl start dpworld-ap-apply.service` 트리거(oneshot, `dpworld-ap-apply.sh` 실행). |
|||
5. 결과 상태 persist: `APPLYING`→`COMMITTED` / `FAILED`. |
|||
|
|||
상태값: `REJECTED`, `APPLYING`, `COMMITTED`, `FAILED`. |
|||
|
|||
### marker = kill-switch |
|||
|
|||
`/home/root/network/ap/ap-enabled` 파일의 **존재 여부**가 enable/disable 판정 기준이다. |
|||
`dpworld-ap-apply.sh` 는 marker 가 있으면 `ap_up`, 없으면 `ap_down`(멱등 정리)을 한다. |
|||
|
|||
--- |
|||
|
|||
## 3. `ap_config` DB 키 + 필드 |
|||
|
|||
별도 `board_config` 키 — **Python 전용**(`device_config` 무관, Java/dpworldapp 미사용). |
|||
`db_manager.ALLOWED_KEYS` 에 등록됨. 기본값(`ap_model.DEFAULT_AP_CONFIG`): |
|||
|
|||
| 필드 | 기본값 | 설명 | |
|||
|------|--------|------| |
|||
| `ap_enabled` | `false` | AP on/off | |
|||
| `ap_ssid` | `""` | SSID (최대 32 byte, `"`·`\`·`#`·제어문자 금지) | |
|||
| `ap_passphrase` | `""` | WPA2-PSK passphrase (8–63 byte, 동일 금지문자) | |
|||
| `ap_band` | `"auto"` | `auto`(SCC) / `2g` / `5g` | |
|||
| `ap_channel` | `0` | 0 = auto. band 별 유효 채널만 허용 | |
|||
| `ap_hidden` | `false` | SSID 브로드캐스트 숨김 | |
|||
| `ap_ip` | `192.168.50.1` | AP 게이트웨이 IP (/24) | |
|||
| `dhcp_start` | `192.168.50.50` | DHCP 풀 시작 (AP 서브넷 내) | |
|||
| `dhcp_end` | `192.168.50.150` | DHCP 풀 끝 | |
|||
| `dhcp_lease` | `43200` | 리스 시간(초) | |
|||
|
|||
> 검증은 `ap_validator.validate_ap` — SSID/PSK 바이트 길이, hostapd-conf 인젝션 차단 |
|||
> (`#`·따옴표·역슬래시·개행), 채널-밴드 정합, DHCP 범위가 AP /24 서브넷 안인지·AP IP 와 |
|||
> 겹치지 않는지, 그리고 **유효한 country regdomain 이 있어야** 활성화 가능 |
|||
> (`country_pending`이면 "reboot required"). |
|||
|
|||
--- |
|||
|
|||
## 4. systemd 유닛 (`deploy/`) |
|||
|
|||
| 유닛 | 역할 | |
|||
|------|------| |
|||
| `dpworld-ap-apply.service` | oneshot — `dpworld-ap-apply.sh` 실행(엔진이 on-demand start, `[Install]` 없음). `--boot`(seed)/무인자(on-demand) | |
|||
| `dpworld-ap-seed.service` | 부팅 시 marker 기준 AP 재구성(seed) | |
|||
| `dpworld-hostapd-ap0.service` | `hostapd /home/root/network/ap/hostapd-ap0.conf` | |
|||
| `dpworld-udhcpd-ap0.service` | `udhcpd` (ap0 DHCP 서버) | |
|||
|
|||
`dpworld-ap-apply.sh` 가 vif 생성 → MAC 설정 → IP 부여 → 방화벽 → hostapd/udhcpd restart 순으로 |
|||
멱등 reconcile 한다(`ap_up`). 어느 단계든 실패하면 `ap_down` 으로 fail-closed. |
|||
|
|||
--- |
|||
|
|||
## 5. 방화벽 (v1.11.7) |
|||
|
|||
`ap0` 전용 iptables chain(`DPWORLD_AP_IN` / `DPWORLD_AP_FWD`)을 쓴다 — STA/eth 무영향. |
|||
|
|||
- **INPUT(장치 자체)**: **전면 개방**(SSH 22 포함). 즉 **WPA2 PSK 가 유일한 접근 게이트**. |
|||
(이전 default-deny[9090/DHCP/ICMP 만 허용]에서 v1.11.7 에 전환 — 현장 관리 접근성 우선, |
|||
비밀번호 강화 전제.) |
|||
- **FORWARD(경유)**: **blanket DROP**. `ip_forward=1` 이어도 AP 클라이언트가 PLC(eth1)·업링크로 |
|||
라우팅하는 것을 전면 차단 = **provisioning 격리**(PLC/내부망 보호). 이 격리는 유지된다. |
|||
|
|||
★ 요약: AP 클라이언트는 **장치 자체엔 접근 가능**하나(PSK 통과 시), **내부망/PLC 로는 경유 불가**. |
|||
|
|||
--- |
|||
|
|||
## 6. API |
|||
|
|||
| Method | Path | 비고 | |
|||
|--------|------|------| |
|||
| `GET` | `/api/network/ap/status` | 라이브 상태 — 응답에서 **`ap_passphrase` 제거**(미인증 API) | |
|||
| `POST` | `/api/network/ap/config` | `ap_config` 저장(필드 화이트리스트 merge, 알 수 없는 키 400) | |
|||
| `POST` | `/api/network/ap/apply` | bring-up/down 적용. `{"dry_run": true}` 면 적용 없이 config 회신(PSK 제거) | |
|||
|
|||
`status` 응답: `ap_enabled`·`ap0_up`·`hostapd_running`·`clients`·`country_pending` + 마스킹된 `config`. |
|||
서브시스템 import 실패(예: Windows dev box) 시 503 fail-soft. |
|||
|
|||
> **API 인증 없음**: :9090 은 현재 미인증(문서화된 threat boundary). AP 가 열리면 PSK 만이 |
|||
> 게이트이므로 **강한 AP PSK** 가 사실상의 1차 방어선이다. 운영자 로그인은 설계 백로그 |
|||
> ([`architecture.md`](architecture.md) §10). |
|||
|
|||
--- |
|||
|
|||
## 7. kill-switch (수동 비활성화) |
|||
|
|||
엔진/웹이 응답하지 않을 때 root 셸에서 즉시 AP 를 내릴 수 있다: |
|||
|
|||
```sh |
|||
rm /home/root/network/ap/ap-enabled && /usr/bin/dpworld-ap-apply.sh |
|||
``` |
|||
|
|||
marker 가 사라지면 스크립트가 `ap_down`(데몬 stop + 방화벽 flush + ap0 삭제)을 수행한다. |
|||
|
|||
--- |
|||
|
|||
## 8. ⚠ flash 생존 caveat |
|||
|
|||
`/home/root/network/ap/*`(config·marker)와 `/opt` 상태는 펌웨어 flash 후에도 **생존**한다. |
|||
그러나 `deploy/` 의 systemd 유닛·`dpworld-ap-apply.sh` 는 rootfs(`/lib`, `/usr/bin`)에 설치되며 |
|||
**flash 마다 wipe** 된다. 따라서 flash 후에는 AP 유닛/스크립트를 **재설치**해야 AP 가 다시 뜬다. |
|||
근본 해결(BSP 이미지 베이킹)은 백로그 — `firmware-ota-guide.md` §6 및 메모리 |
|||
`web-survives-flash` 참조. |
|||
@ -0,0 +1,434 @@ |
|||
<# |
|||
.SYNOPSIS |
|||
Deploy the NEW Web Configurator to a device, or roll back the last deploy. |
|||
|
|||
.DESCRIPTION |
|||
Builds a clean archive of the committed src/ tree, transfers it over SSH, |
|||
backs up the device's current src/, swaps in the new tree, stamps the |
|||
deployed version, and restarts the web-configurator systemd service. |
|||
|
|||
Every deploy must sit on a version tag (vX.Y.Z); use -AllowUntagged for a |
|||
flagged dev build. See docs/DEPLOY.md. |
|||
|
|||
.EXAMPLE |
|||
.\scripts\deploy.ps1 192.168.55.56 |
|||
.EXAMPLE |
|||
.\scripts\deploy.ps1 192.168.55.56 -AllowUntagged |
|||
.EXAMPLE |
|||
.\scripts\deploy.ps1 192.168.55.56 -Rollback |
|||
#> |
|||
[CmdletBinding()] |
|||
param( |
|||
[Parameter(Mandatory = $true, Position = 0)] |
|||
[string]$DeviceIp, |
|||
[switch]$AllowUntagged, |
|||
[switch]$Rollback |
|||
) |
|||
|
|||
$ErrorActionPreference = 'Stop' |
|||
|
|||
# --- Fixed device facts --------------------------------------------------- |
|||
$AppDir = '/opt/web-configurator' |
|||
$Service = 'web-configurator' |
|||
$Port = 9090 |
|||
$SshTarget = "root@$DeviceIp" |
|||
$SshOpts = @('-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes') |
|||
|
|||
# --- Helpers -------------------------------------------------------------- |
|||
function Write-Step([string]$Message) { |
|||
Write-Host "==> $Message" -ForegroundColor Cyan |
|||
} |
|||
|
|||
# Run a command on the device; throw on non-zero exit. |
|||
function Invoke-Ssh([string]$Command) { |
|||
$output = & ssh @SshOpts $SshTarget $Command |
|||
if ($LASTEXITCODE -ne 0) { |
|||
throw "SSH command failed (exit $LASTEXITCODE): $Command" |
|||
} |
|||
return $output |
|||
} |
|||
|
|||
# Run a git command allowed to fail; return trimmed stdout, or $null. |
|||
function Invoke-GitSafe([string]$GitArgs) { |
|||
$out = cmd /c "git $GitArgs 2>nul" |
|||
if ($LASTEXITCODE -eq 0 -and $out) { return ($out | Out-String).Trim() } |
|||
return $null |
|||
} |
|||
|
|||
function Test-ServiceHealthy { |
|||
$active = (Invoke-Ssh "systemctl is-active $Service" | Out-String).Trim() |
|||
if ($active -ne 'active') { return $false } |
|||
try { |
|||
$resp = Invoke-WebRequest -Uri "http://${DeviceIp}:$Port/" ` |
|||
-TimeoutSec 5 -UseBasicParsing |
|||
return ($resp.StatusCode -eq 200) |
|||
} catch { |
|||
return $false |
|||
} |
|||
} |
|||
|
|||
function Confirm-Health { |
|||
Write-Step "Verifying service health" |
|||
for ($i = 1; $i -le 5; $i++) { |
|||
Start-Sleep -Seconds 3 |
|||
if (Test-ServiceHealthy) { |
|||
Write-Host " service active, HTTP 200 on :$Port" -ForegroundColor Green |
|||
return |
|||
} |
|||
} |
|||
throw "Verification failed: $Service not healthy on $DeviceIp after restart" |
|||
} |
|||
|
|||
# --- Rollback ------------------------------------------------------------- |
|||
if ($Rollback) { |
|||
Write-Step "Rollback on $DeviceIp" |
|||
$raw = Invoke-Ssh "ls -1dt $AppDir/backups/src-* 2>/dev/null || true" |
|||
$backups = @($raw | Where-Object { $_ -and $_.Trim() }) |
|||
if ($backups.Count -eq 0) { |
|||
throw "No backups found on $DeviceIp under $AppDir/backups" |
|||
} |
|||
$latest = $backups[0].Trim() |
|||
Write-Host " restoring $latest" -ForegroundColor Yellow |
|||
Invoke-Ssh "cd $AppDir && rm -rf src && cp -r '$latest' src" | Out-Null |
|||
$now = (Get-Date).ToString('o') |
|||
$deployedBy = "$env:USERNAME@$env:COMPUTERNAME" |
|||
Invoke-Ssh ("printf 'version=rolled-back\nrestored_from=%s\ndeployed_at=%s\ndeployed_by=%s\n'" + |
|||
" '$latest' '$now' '$deployedBy' > $AppDir/DEPLOYED_VERSION") | Out-Null |
|||
Invoke-Ssh ("printf '%s %s %s %s\n' '$now' 'rolled-back' '$latest' '$deployedBy'" + |
|||
" >> $AppDir/deploy-history.log") | Out-Null |
|||
Invoke-Ssh "systemctl restart $Service" | Out-Null |
|||
Confirm-Health |
|||
Write-Host "Rollback complete on $DeviceIp ($latest)" -ForegroundColor Green |
|||
return |
|||
} |
|||
|
|||
# --- Pre-flight ----------------------------------------------------------- |
|||
Write-Step "Pre-flight checks" |
|||
try { Invoke-Ssh "echo ok" | Out-Null } |
|||
catch { throw "Cannot reach $DeviceIp over SSH. Check the IP and that your key is authorized." } |
|||
|
|||
$dirtyRaw = & git status --porcelain |
|||
if ($LASTEXITCODE -ne 0) { throw "git status failed — not a git repo?" } |
|||
# Skip untracked-only lines (??) — only staged/modified tracked files block deploy |
|||
$dirty = @($dirtyRaw | Where-Object { $_ -and $_ -notmatch '^\?\?' }) |
|||
if ($dirty.Count -gt 0) { throw "Local working tree is not clean. Commit or stash before deploying." } |
|||
|
|||
$commit = (& git rev-parse HEAD).Trim() |
|||
if ($LASTEXITCODE -ne 0) { throw "git rev-parse HEAD failed" } |
|||
$short = (& git rev-parse --short HEAD).Trim() |
|||
if ($LASTEXITCODE -ne 0) { throw "git rev-parse --short HEAD failed" } |
|||
$tag = Invoke-GitSafe 'describe --exact-match --tags HEAD' |
|||
|
|||
if ($tag) { |
|||
$version = $tag |
|||
Write-Host " deploying release $version" -ForegroundColor Green |
|||
} elseif ($AllowUntagged) { |
|||
$nearest = Invoke-GitSafe 'describe --tags --abbrev=0' |
|||
if (-not $nearest) { $nearest = 'v0.0.0' } |
|||
$version = "$nearest-dev+$short" |
|||
Write-Host " WARNING: HEAD is untagged - dev build $version" -ForegroundColor Yellow |
|||
} else { |
|||
throw "HEAD is not at a version tag. Tag a release, or pass -AllowUntagged for a dev build." |
|||
} |
|||
|
|||
# --- Deploy --------------------------------------------------------------- |
|||
$localTar = Join-Path $env:TEMP "webcfg-deploy-$short.tar" |
|||
try { |
|||
Write-Step "Building archive of committed src/" |
|||
& git archive --format=tar --output="$localTar" HEAD src |
|||
if ($LASTEXITCODE -ne 0) { throw "git archive failed" } |
|||
|
|||
Write-Step "Transferring to $DeviceIp" |
|||
# v1.5.0: first-deploy guard — ensure $AppDir exists before scp (otherwise |
|||
# scp fails with "dest open: No such file or directory" on a fresh device). |
|||
# v1.5.2 C1: pre-create firmware OTA dirs (ProtectSystem=strict requires they exist |
|||
# AND be in ReadWritePaths). systemd unit lists them; we ensure presence. |
|||
Invoke-Ssh "mkdir -p $AppDir /opt/fw_staging /opt/fw_upload /opt/config_backups" | Out-Null |
|||
& scp @SshOpts "$localTar" "${SshTarget}:$AppDir/_deploy.tar" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp failed" } |
|||
# v1.4.6.6: GNU tar의 timestamp warning이 stderr로 출력되면 PowerShell 5.1 |
|||
# NativeCommandError로 wrap되어 ErrorActionPreference=Stop과 결합 시 terminating |
|||
# 에러 발생. --warning=no-timestamp로 시간 차 warning만 끄고 진짜 에러는 보존. |
|||
Invoke-Ssh ("rm -rf $AppDir/_deploy_tmp && mkdir -p $AppDir/_deploy_tmp" + |
|||
" && tar --warning=no-timestamp -xf $AppDir/_deploy.tar -C $AppDir/_deploy_tmp") | Out-Null |
|||
|
|||
Write-Step "Backing up current src/ (keep last 3)" |
|||
$ts = Get-Date -Format 'yyyyMMdd-HHmmss' |
|||
Invoke-Ssh "cd $AppDir && mkdir -p backups && if [ -d src ]; then cp -r src backups/src-$ts; fi" | Out-Null |
|||
$raw = Invoke-Ssh "ls -1dt $AppDir/backups/src-* 2>/dev/null || true" |
|||
$backups = @($raw | Where-Object { $_ -and $_.Trim() }) |
|||
if ($backups.Count -gt 3) { |
|||
foreach ($old in $backups[3..($backups.Count - 1)]) { |
|||
Invoke-Ssh "rm -rf '$($old.Trim())'" | Out-Null |
|||
} |
|||
} |
|||
|
|||
# v1.5.2 D1 (H17): install unit to /lib/systemd/system/ — persistent across reboots on |
|||
# Telechips/Poky 4.0.17. /etc/systemd/system/ is a tmpfs overlay that is lost on every |
|||
# reboot; all firmware-native services (dpworldapp, app-runner, nginx) live in |
|||
# /lib/systemd/system/ which is on the read-write overlay (persistent). Installing here |
|||
# ensures web-configurator survives power cycles without manual re-registration. |
|||
# Enable symlink is created in /etc/systemd/system/multi-user.target.wants/ (which also |
|||
# survives because it is backed by the same persistent overlay). |
|||
# #8 review fix: unit/dir installs are idempotent and independent of the src tree — done |
|||
# BEFORE the src swap so a scp failure aborts while the OLD src is still active on disk. |
|||
$unitPath = "/lib/systemd/system/web-configurator.service" |
|||
$unitCheck = (Invoke-Ssh "test -f $unitPath && echo yes || echo no").Trim() |
|||
$localUnit = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\web-configurator.service")).Path |
|||
|
|||
if ($unitCheck -ne "yes") { |
|||
Write-Host "[deploy] first deploy on $DeviceIp — installing systemd unit to persistent /lib/systemd/system/" -ForegroundColor Yellow |
|||
& scp @SshOpts "$localUnit" "${SshTarget}:${unitPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of unit file failed" } |
|||
Invoke-Ssh "systemctl daemon-reload && systemctl enable web-configurator" | Out-Null |
|||
} else { |
|||
$remoteHash = (Invoke-Ssh "sha256sum $unitPath | cut -d' ' -f1").Trim() |
|||
$localHash = (Get-FileHash $localUnit -Algorithm SHA256).Hash.ToLower() |
|||
if ($remoteHash -ne $localHash) { |
|||
Write-Host "[deploy] unit file changed — reinstalling to /lib/systemd/system/ and daemon-reload" -ForegroundColor Yellow |
|||
& scp @SshOpts "$localUnit" "${SshTarget}:${unitPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated unit file failed" } |
|||
Invoke-Ssh "systemctl daemon-reload" | Out-Null |
|||
} |
|||
} |
|||
|
|||
# v1.6.0: 네트워크 적용 엔진 디렉토리 생성 (ReadWritePaths - prefix 와 쌍 — src swap 전 보장) |
|||
# #5/#8 review fix: /opt/config_backups/network 는 plaintext PSK 저장 — chmod 700 으로 world-read 차단. |
|||
# /home/root/network 는 dpworldapp 공유 디렉토리로 기본 권한 유지. |
|||
Invoke-Ssh "mkdir -p /home/root/network /opt/config_backups/network && chmod 700 /opt/config_backups/network" | Out-Null |
|||
|
|||
# v1.6.0: dpworld-net-recover.service 설치 (oneshot — enable 불필요, 파일만) |
|||
# C2 review fix: /etc/systemd/system 은 tmpfs overlay 로 재부팅 시 소실 (v1.5.2 D1 참조) |
|||
# → web-configurator.service 와 동일한 hash-compare 패턴으로 영속 /lib/systemd/system 에 설치. |
|||
# 구버전이 설치한 휘발성 /etc 사본은 /lib 유닛을 가리므로(systemd 우선순위) 제거. |
|||
$recoverPath = "/lib/systemd/system/dpworld-net-recover.service" |
|||
$recoverUnit = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-net-recover.service")).Path |
|||
$recoverCheck = (Invoke-Ssh "test -f $recoverPath && echo yes || echo no").Trim() |
|||
|
|||
if ($recoverCheck -ne "yes") { |
|||
Write-Host "[deploy] installing dpworld-net-recover.service to persistent /lib/systemd/system/" -ForegroundColor Yellow |
|||
& scp @SshOpts $recoverUnit "${SshTarget}:${recoverPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-net-recover.service failed" } |
|||
Invoke-Ssh "rm -f /etc/systemd/system/dpworld-net-recover.service && systemctl daemon-reload" | Out-Null |
|||
} else { |
|||
$recoverRemoteHash = (Invoke-Ssh "sha256sum $recoverPath | cut -d' ' -f1").Trim() |
|||
$recoverLocalHash = (Get-FileHash $recoverUnit -Algorithm SHA256).Hash.ToLower() |
|||
if ($recoverRemoteHash -ne $recoverLocalHash) { |
|||
Write-Host "[deploy] dpworld-net-recover.service changed — reinstalling to /lib/systemd/system/ and daemon-reload" -ForegroundColor Yellow |
|||
& scp @SshOpts $recoverUnit "${SshTarget}:${recoverPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-net-recover.service failed" } |
|||
Invoke-Ssh "rm -f /etc/systemd/system/dpworld-net-recover.service && systemctl daemon-reload" | Out-Null |
|||
} |
|||
} |
|||
|
|||
# v1.6.0: dpworld-network-apply.service 온디맨드 drop-in 설치 |
|||
# firmware 소유 apply.service 의 Requires=dpworld-network-seed.service (boot-only oneshot) 을 |
|||
# 런타임에 빈 값으로 override — 재부팅 후 systemctl start 가 "Dependency failed" rc=1 을 내던 |
|||
# 결함 해소 (.56 실측, DEF-2b). 드롭인 디렉토리는 /lib/systemd/system/ 하 (persistent overlay — |
|||
# /etc 는 tmpfs 로 재부팅 시 소실, v1.5.2 D1 참조). |
|||
$ondemandConfPath = "/lib/systemd/system/dpworld-network-apply.service.d/10-ondemand.conf" |
|||
$ondemandConfLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-apply-ondemand.conf")).Path |
|||
$ondemandCheck = (Invoke-Ssh "test -f $ondemandConfPath && echo yes || echo no").Trim() |
|||
|
|||
if ($ondemandCheck -ne "yes") { |
|||
Write-Host "[deploy] installing dpworld-network-apply ondemand drop-in to persistent /lib/systemd/system/" -ForegroundColor Yellow |
|||
Invoke-Ssh "mkdir -p /lib/systemd/system/dpworld-network-apply.service.d" | Out-Null |
|||
& scp @SshOpts $ondemandConfLocal "${SshTarget}:${ondemandConfPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-apply-ondemand.conf failed" } |
|||
Invoke-Ssh "systemctl daemon-reload" | Out-Null |
|||
} else { |
|||
$ondemandRemoteHash = (Invoke-Ssh "sha256sum $ondemandConfPath | cut -d' ' -f1").Trim() |
|||
$ondemandLocalHash = (Get-FileHash $ondemandConfLocal -Algorithm SHA256).Hash.ToLower() |
|||
if ($ondemandRemoteHash -ne $ondemandLocalHash) { |
|||
Write-Host "[deploy] dpworld-network-apply ondemand drop-in changed — reinstalling and daemon-reload" -ForegroundColor Yellow |
|||
& scp @SshOpts $ondemandConfLocal "${SshTarget}:${ondemandConfPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-apply-ondemand.conf failed" } |
|||
Invoke-Ssh "systemctl daemon-reload" | Out-Null |
|||
} |
|||
} |
|||
|
|||
# v1.7.1: firmware boot hardening — dpworld-network-apply-hardened.sh + 2 drop-ins. |
|||
# 배경: 펌웨어 dpworld-network-seed.service 가 --boot 에서 modprobe -r wlan 을 호출 → |
|||
# QCA6490(cnss_pci) 모듈 hang → Telechips PMU 하드웨어 watchdog(20 s) 리셋 → 부팅 루프. |
|||
# 조치: 두 drop-in(20-hardened.conf) 이 seed/apply 양쪽 ExecStart 를 하드닝본으로 교체. |
|||
# 하드닝본은 --boot 경로에서 modprobe -r 을 절대 호출하지 않음. country 는 modprobe.d + |
|||
# reboot-deferred 로 처리. 펌웨어 원본 /usr/bin/dpworld-network-apply.sh 은 무수정 보존. |
|||
# 설치 경로 모두 /lib/systemd/system (persistent overlay — /etc 는 tmpfs, v1.5.2 D1 참조). |
|||
$needsDaemonReload = $false |
|||
$hardenedScript = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-apply-hardened.sh")).Path |
|||
$hardenedScriptPath = "/usr/bin/dpworld-network-apply-hardened.sh" |
|||
$hardenedScriptCheck = (Invoke-Ssh "test -f $hardenedScriptPath && echo yes || echo no").Trim() |
|||
|
|||
if ($hardenedScriptCheck -ne "yes") { |
|||
Write-Host "[deploy] installing dpworld-network-apply-hardened.sh to $hardenedScriptPath" -ForegroundColor Yellow |
|||
& scp @SshOpts $hardenedScript "${SshTarget}:${hardenedScriptPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-apply-hardened.sh failed" } |
|||
Invoke-Ssh "chmod +x $hardenedScriptPath" | Out-Null |
|||
$needsDaemonReload = $true |
|||
} else { |
|||
$hardenedRemoteHash = (Invoke-Ssh "sha256sum $hardenedScriptPath | cut -d' ' -f1").Trim() |
|||
$hardenedLocalHash = (Get-FileHash $hardenedScript -Algorithm SHA256).Hash.ToLower() |
|||
if ($hardenedRemoteHash -ne $hardenedLocalHash) { |
|||
Write-Host "[deploy] dpworld-network-apply-hardened.sh changed — reinstalling" -ForegroundColor Yellow |
|||
& scp @SshOpts $hardenedScript "${SshTarget}:${hardenedScriptPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-apply-hardened.sh failed" } |
|||
Invoke-Ssh "chmod +x $hardenedScriptPath" | Out-Null |
|||
$needsDaemonReload = $true |
|||
} |
|||
} |
|||
|
|||
# drop-in: dpworld-network-apply.service.d/20-hardened.conf |
|||
$applyDropinLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-apply.service.d\20-hardened.conf")).Path |
|||
$applyDropinPath = "/lib/systemd/system/dpworld-network-apply.service.d/20-hardened.conf" |
|||
$applyDropinCheck = (Invoke-Ssh "test -f $applyDropinPath && echo yes || echo no").Trim() |
|||
|
|||
if ($applyDropinCheck -ne "yes") { |
|||
Write-Host "[deploy] installing dpworld-network-apply drop-in 20-hardened.conf" -ForegroundColor Yellow |
|||
Invoke-Ssh "mkdir -p /lib/systemd/system/dpworld-network-apply.service.d" | Out-Null |
|||
& scp @SshOpts $applyDropinLocal "${SshTarget}:${applyDropinPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-apply 20-hardened.conf failed" } |
|||
$needsDaemonReload = $true |
|||
} else { |
|||
$applyDropinRemoteHash = (Invoke-Ssh "sha256sum $applyDropinPath | cut -d' ' -f1").Trim() |
|||
$applyDropinLocalHash = (Get-FileHash $applyDropinLocal -Algorithm SHA256).Hash.ToLower() |
|||
if ($applyDropinRemoteHash -ne $applyDropinLocalHash) { |
|||
Write-Host "[deploy] dpworld-network-apply 20-hardened.conf changed — reinstalling" -ForegroundColor Yellow |
|||
& scp @SshOpts $applyDropinLocal "${SshTarget}:${applyDropinPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-apply 20-hardened.conf failed" } |
|||
$needsDaemonReload = $true |
|||
} |
|||
} |
|||
|
|||
# drop-in: dpworld-network-seed.service.d/20-hardened.conf |
|||
$seedDropinLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-seed.service.d\20-hardened.conf")).Path |
|||
$seedDropinPath = "/lib/systemd/system/dpworld-network-seed.service.d/20-hardened.conf" |
|||
$seedDropinCheck = (Invoke-Ssh "test -f $seedDropinPath && echo yes || echo no").Trim() |
|||
|
|||
if ($seedDropinCheck -ne "yes") { |
|||
Write-Host "[deploy] installing dpworld-network-seed drop-in 20-hardened.conf" -ForegroundColor Yellow |
|||
Invoke-Ssh "mkdir -p /lib/systemd/system/dpworld-network-seed.service.d" | Out-Null |
|||
& scp @SshOpts $seedDropinLocal "${SshTarget}:${seedDropinPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-seed 20-hardened.conf failed" } |
|||
$needsDaemonReload = $true |
|||
} else { |
|||
$seedDropinRemoteHash = (Invoke-Ssh "sha256sum $seedDropinPath | cut -d' ' -f1").Trim() |
|||
$seedDropinLocalHash = (Get-FileHash $seedDropinLocal -Algorithm SHA256).Hash.ToLower() |
|||
if ($seedDropinRemoteHash -ne $seedDropinLocalHash) { |
|||
Write-Host "[deploy] dpworld-network-seed 20-hardened.conf changed — reinstalling" -ForegroundColor Yellow |
|||
& scp @SshOpts $seedDropinLocal "${SshTarget}:${seedDropinPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-seed 20-hardened.conf failed" } |
|||
$needsDaemonReload = $true |
|||
} |
|||
} |
|||
|
|||
if ($needsDaemonReload) { |
|||
Invoke-Ssh "systemctl daemon-reload" | Out-Null |
|||
} |
|||
|
|||
# AP: install — begin |
|||
# WiFi AP 엔진: applier 스크립트(/usr/bin) + 4 유닛(/lib/systemd/system) 설치. |
|||
# seed 만 enable(부팅 reconcile). apply/hostapd/udhcpd 는 enable 안 함 — apply 스크립트가 기동. |
|||
# 설치 경로 /lib/systemd/system (persistent overlay — /etc 는 tmpfs, v1.5.2 D1 참조). |
|||
$apNeedsReload = $false |
|||
# 1) applier 스크립트 |
|||
$apScript = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-ap-apply.sh")).Path |
|||
$apScriptPath = "/usr/bin/dpworld-ap-apply.sh" |
|||
$apScriptCheck = (Invoke-Ssh "test -f $apScriptPath && echo yes || echo no").Trim() |
|||
if ($apScriptCheck -ne "yes") { |
|||
Write-Host "[deploy] installing dpworld-ap-apply.sh to $apScriptPath" -ForegroundColor Yellow |
|||
& scp @SshOpts $apScript "${SshTarget}:${apScriptPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-ap-apply.sh failed" } |
|||
Invoke-Ssh "chmod 0755 $apScriptPath" | Out-Null |
|||
$apNeedsReload = $true |
|||
} else { |
|||
$apScriptRemoteHash = (Invoke-Ssh "sha256sum $apScriptPath | cut -d' ' -f1").Trim() |
|||
$apScriptLocalHash = (Get-FileHash $apScript -Algorithm SHA256).Hash.ToLower() |
|||
if ($apScriptRemoteHash -ne $apScriptLocalHash) { |
|||
Write-Host "[deploy] dpworld-ap-apply.sh changed — reinstalling" -ForegroundColor Yellow |
|||
& scp @SshOpts $apScript "${SshTarget}:${apScriptPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-ap-apply.sh failed" } |
|||
Invoke-Ssh "chmod 0755 $apScriptPath" | Out-Null |
|||
$apNeedsReload = $true |
|||
} |
|||
} |
|||
# 2) systemd 유닛 4종 (영속 /lib/systemd/system, 휘발성 /etc 사본 제거) |
|||
$apUnits = @("dpworld-ap-seed.service", "dpworld-ap-apply.service", |
|||
"dpworld-hostapd-ap0.service", "dpworld-udhcpd-ap0.service") |
|||
foreach ($u in $apUnits) { |
|||
$uLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\$u")).Path |
|||
$uPath = "/lib/systemd/system/$u" |
|||
$uCheck = (Invoke-Ssh "test -f $uPath && echo yes || echo no").Trim() |
|||
if ($uCheck -ne "yes") { |
|||
Write-Host "[deploy] installing $u to /lib/systemd/system/" -ForegroundColor Yellow |
|||
& scp @SshOpts $uLocal "${SshTarget}:${uPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of $u failed" } |
|||
Invoke-Ssh "rm -f /etc/systemd/system/$u" | Out-Null |
|||
$apNeedsReload = $true |
|||
} else { |
|||
$uRemoteHash = (Invoke-Ssh "sha256sum $uPath | cut -d' ' -f1").Trim() |
|||
$uLocalHash = (Get-FileHash $uLocal -Algorithm SHA256).Hash.ToLower() |
|||
if ($uRemoteHash -ne $uLocalHash) { |
|||
Write-Host "[deploy] $u changed — reinstalling" -ForegroundColor Yellow |
|||
& scp @SshOpts $uLocal "${SshTarget}:${uPath}" |
|||
if ($LASTEXITCODE -ne 0) { throw "scp of updated $u failed" } |
|||
Invoke-Ssh "rm -f /etc/systemd/system/$u" | Out-Null |
|||
$apNeedsReload = $true |
|||
} |
|||
} |
|||
} |
|||
if ($apNeedsReload) { Invoke-Ssh "systemctl daemon-reload" | Out-Null } |
|||
# seed 만 enable — 부팅 시 마커 기준 reconcile (apply/데몬 유닛은 apply 스크립트가 기동) |
|||
Invoke-Ssh "systemctl enable dpworld-ap-seed.service" | Out-Null |
|||
# AP: install — end |
|||
|
|||
Write-Step "Swapping in new src/" |
|||
Invoke-Ssh ("cd $AppDir && rm -rf src && mv _deploy_tmp/src src" + |
|||
" && rm -rf _deploy_tmp _deploy.tar") | Out-Null |
|||
|
|||
Write-Step "Stamping version $version" |
|||
$deployedBy = "$env:USERNAME@$env:COMPUTERNAME" |
|||
$now = (Get-Date).ToString('o') |
|||
$tagField = if ($tag) { $tag } else { '(untagged)' } |
|||
Invoke-Ssh ("printf 'version=%s\ntag=%s\ncommit=%s\ndeployed_at=%s\ndeployed_by=%s\n'" + |
|||
" '$version' '$tagField' '$commit' '$now' '$deployedBy' > $AppDir/DEPLOYED_VERSION") | Out-Null |
|||
Invoke-Ssh ("printf '%s %s %s %s\n' '$now' '$version' '$short' '$deployedBy'" + |
|||
" >> $AppDir/deploy-history.log") | Out-Null |
|||
|
|||
# v1.5.2 D2 (H18): wrap swap + unit install + restart in try/catch for auto-rollback. |
|||
# If Confirm-Health throws (service not healthy after restart), restore the backed-up |
|||
# src/ from backups/src-$ts and restart — leaving the device in a known-good state. |
|||
Write-Step "Restarting $Service" |
|||
try { |
|||
Invoke-Ssh "systemctl restart $Service" | Out-Null |
|||
Confirm-Health |
|||
} |
|||
catch { |
|||
Write-Host "[deploy] FAILED — auto-rolling back to backups/src-$ts" -ForegroundColor Red |
|||
try { |
|||
Invoke-Ssh "cd $AppDir && rm -rf src && cp -r backups/src-$ts src" | Out-Null |
|||
Invoke-Ssh "systemctl restart $Service" | Out-Null |
|||
Start-Sleep -Seconds 3 |
|||
if (Test-ServiceHealthy) { |
|||
Write-Host " rollback succeeded — device is back on previous version" -ForegroundColor Yellow |
|||
} else { |
|||
Write-Host " WARNING: rollback restart may still be starting up" -ForegroundColor Yellow |
|||
} |
|||
$rollbackBy = "$env:USERNAME@$env:COMPUTERNAME" |
|||
$rollbackAt = (Get-Date).ToString('o') |
|||
Invoke-Ssh ("printf 'version=rolled-back\nfrom=failed-$version\nrestored_from=backups/src-$ts\nrolled_back_at=$rollbackAt\nrolled_back_by=$rollbackBy\n'" + |
|||
" > $AppDir/DEPLOYED_VERSION") | Out-Null |
|||
} |
|||
catch { |
|||
Write-Host "[deploy] CRITICAL: rollback also failed — device may need manual recovery" -ForegroundColor Red |
|||
} |
|||
throw # re-throw original error so the caller sees failure |
|||
} |
|||
} |
|||
finally { |
|||
Remove-Item -Path $localTar -ErrorAction SilentlyContinue |
|||
} |
|||
|
|||
Write-Host "" |
|||
Write-Host "Deployed $version to $DeviceIp" -ForegroundColor Green |
|||
Write-Host " commit: $commit" |
|||
Write-Host " verify: ssh $SshTarget cat $AppDir/DEPLOYED_VERSION" |
|||
@ -0,0 +1,845 @@ |
|||
""" |
|||
config_validator.py — Configuration Validation & Processing |
|||
|
|||
Handles: |
|||
1. Stripping None/null values from dicts (matching the legacy app's behavior of omitting null-valued keys on save) |
|||
2. Processing protocol config (removing inactive protocol mappings) |
|||
3. Basic validation of device and protocol configurations |
|||
|
|||
These rules ensure dpworldapp receives the exact JSON format it expects. |
|||
""" |
|||
|
|||
from dpworldapp_enums import ( |
|||
DEVICE_ENUM_VALUES, |
|||
PROTOCOL_ENUM_VALUES, |
|||
PROTOCOL_ALIASES, |
|||
ODO_SOURCE_VALUES, |
|||
) |
|||
|
|||
# v1.4.4: Super_Relay 프로젝트 소유 키 (board_config 별도 row). |
|||
# 우리 device_config/protocol_config에 들어가면 의미 충돌 → strict reject. |
|||
# Source: [[memory:board-config-key-ownership-policy]] + java_app_drift verified evidence. |
|||
_FORBIDDEN_KEYS_DEVICE = frozenset({"security", "transport_config"}) |
|||
_FORBIDDEN_KEYS_PROTOCOL = frozenset({"security", "transport_config"}) |
|||
|
|||
# v1.4.8 Stage 4: rs485 운영 강제 범위 |
|||
_RS485_DATABITS_RANGE = range(5, 10) # 5-9 inclusive (시리얼 표준) |
|||
_RS485_STOPBITS_RANGE = range(0, 3) # 0-2 inclusive (0=none, 1=1, 2=2) |
|||
|
|||
# v2: expr length cap = 39 (reader calc_str[40]; >=40 silently dropped) |
|||
_ODO_EXPR_MAX_LEN = 39 |
|||
# v2 + v1.11.10 reconciliation (register-expr path only): the reader does NOT |
|||
# validate expr content, so math/operator chars and backtick stay allowed |
|||
# (v1.11.8 TestRegisterEntriesExpr accepts backtick, ';', parens, &, |). Block |
|||
# only '$' (command substitution — v1.11.10 injection guard) plus control chars. |
|||
# NOTE: project tests are intentionally narrow here; revisit if expr injection |
|||
# policy is later unified. |
|||
_ODO_EXPR_FORBIDDEN_CHARS = frozenset("$\n\r\x00") |
|||
|
|||
# v1.11.8 E: odo.shift 운영 범위 (CAN 8byte → uint64 >> shift, 0..63) + mask/id 길이 상한 |
|||
_ODO_SHIFT_RANGE = range(0, 64) # 0-63 inclusive (CAN_8bytes_as_LE_uint64 >> shift) |
|||
_ODO_FIELD_STR_MAX_LEN = 64 # mask / id 문자열 상한 (DoS / 비정상 입력 방어) |
|||
|
|||
# v2: idt = device config-reader contract (case-insensitive match); float64 removed, float added; |
|||
# variants (unsignedInteger/shor/unsignedShort) accepted via case-insensitive lookup. |
|||
# The check is CASE-INSENSITIVE (lowercase before membership). |
|||
_REGISTER_IDT_VALUES = frozenset({ |
|||
"integer", "float", "boolean", "unsigned integer", "unsignedinteger", |
|||
"short", "shor", "unsigned short", "unsignedshort", |
|||
}) |
|||
# odt canonical set unchanged; synonyms float/double→float64, bool→boolean handled |
|||
# in _validate_register_entry (normalize before membership; case-insensitive). |
|||
_REGISTER_ODT_VALUES = frozenset({"integer", "float64", "boolean", "string"}) |
|||
_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 |
|||
|
|||
|
|||
# v1.4.6.7 C-2: defense in depth — frontend convertNestedToFlat이 ports를 Integer로 emit |
|||
# (state.js fix와 정합). 외부 도구 / Java app POST 회귀 / MMI 직접 입력에서 string port가 |
|||
# 들어와도 backend에서 silently Integer로 normalize. dha baseline 운영 ground truth Integer. |
|||
_DEVICE_PORT_KEYS = frozenset({ |
|||
"protocol_server_port", |
|||
"update_server_port", |
|||
"rtcm_server_port", |
|||
"opc_ua_server_port", |
|||
"modbus_server_port", |
|||
"lte_server_port", |
|||
}) |
|||
|
|||
|
|||
def normalize_device_port_types(cfg): |
|||
"""Coerce string-typed port keys in `device_config` to Integer. |
|||
Returns the same dict mutated in place (or unchanged if not a dict). |
|||
device contract Integer + dha baseline 일치 (verify-before-asserting 2026-06-04). |
|||
""" |
|||
if not isinstance(cfg, dict): |
|||
return cfg |
|||
for k in _DEVICE_PORT_KEYS: |
|||
v = cfg.get(k) |
|||
if isinstance(v, str) and v.strip().lstrip("-").isdigit(): |
|||
cfg[k] = int(v) |
|||
return cfg |
|||
|
|||
|
|||
# v1.4.6.9 H5: ports type/range hard-reject helper — sibling of validate_rs485_integers / |
|||
# validate_wifi_country_code. server.py에서 400으로 reject. multi-agent review에서 노출: |
|||
# validate_device_config가 port errors를 soft-warn으로 처리해 string 'http' / '80.5' / |
|||
# negative / out-of-range가 device_config에 잔존 → 정수 파싱 실패로 값이 wipe될 수 있음. |
|||
_PORT_VALID_RANGE = range(1, 65536) # IANA-valid range (port 0 reserved) |
|||
|
|||
|
|||
def validate_device_port_types_hard(data): |
|||
"""Hard-reject port type / range errors. Returns list[str] of error messages. |
|||
None / missing keys are OK (partial-merge POST 시 미포함 키 자연 보존). |
|||
""" |
|||
errors = [] |
|||
if not isinstance(data, dict): |
|||
return errors |
|||
for k in _DEVICE_PORT_KEYS: |
|||
if k not in data: |
|||
continue |
|||
v = data[k] |
|||
if v is None: |
|||
continue |
|||
# bool은 int 서브클래스 — 명시 reject (e.g. True가 1로 변환되어 port=1 되는 결함 차단) |
|||
if isinstance(v, bool): |
|||
errors.append(f"{k} must be int 1..65535, got bool {v!r}") |
|||
continue |
|||
if isinstance(v, int): |
|||
if v not in _PORT_VALID_RANGE: |
|||
errors.append(f"{k} out of range 1..65535: {v}") |
|||
continue |
|||
if isinstance(v, str): |
|||
s = v.strip() |
|||
if not s.isdigit(): |
|||
errors.append(f"{k} must be digit string, got {v!r}") |
|||
continue |
|||
try: |
|||
iv = int(s) |
|||
except (ValueError, TypeError): |
|||
errors.append(f"{k} int parse failed: {v!r}") |
|||
continue |
|||
if iv not in _PORT_VALID_RANGE: |
|||
errors.append(f"{k} out of range 1..65535: {iv}") |
|||
continue |
|||
errors.append(f"{k} unsupported type: {type(v).__name__}") |
|||
return errors |
|||
|
|||
|
|||
def strip_none_values(obj): |
|||
""" |
|||
Recursively remove keys with None values from dicts. |
|||
Matches the legacy app's behavior of omitting null-valued keys on save. |
|||
|
|||
Args: |
|||
obj: dict, list, or primitive value |
|||
|
|||
Returns: |
|||
Cleaned object with no None values |
|||
""" |
|||
if isinstance(obj, dict): |
|||
return { |
|||
k: strip_none_values(v) |
|||
for k, v in obj.items() |
|||
if v is not None |
|||
} |
|||
elif isinstance(obj, list): |
|||
return [strip_none_values(item) for item in obj] |
|||
return obj |
|||
|
|||
|
|||
def process_protocol_config(data): |
|||
""" |
|||
Process protocol configuration before saving. |
|||
|
|||
IMPORTANT: Unlike the legacy app's save behavior, |
|||
we intentionally PRESERVE all protocol mapping arrays (OPC_UA, MODBUS, CAN) |
|||
even when they are not the currently active protocol. This prevents data loss |
|||
when the user switches protocols — the inactive mappings stay in the DB |
|||
and are restored when the user switches back. |
|||
|
|||
Args: |
|||
data: Protocol config dict |
|||
|
|||
Returns: |
|||
Processed dict with all mapping arrays preserved |
|||
""" |
|||
if not isinstance(data, dict): |
|||
return data |
|||
|
|||
result = dict(data) |
|||
|
|||
# Ensure OPC_UA, MODBUS, and CAN arrays always exist (at least as empty lists) |
|||
# This prevents data from being lost and ensures consistent JSON structure |
|||
if "OPC_UA" not in result or result["OPC_UA"] is None: |
|||
result["OPC_UA"] = [] |
|||
if "MODBUS" not in result or result["MODBUS"] is None: |
|||
result["MODBUS"] = [] |
|||
if "CAN" not in result or result["CAN"] is None: |
|||
result["CAN"] = [] |
|||
|
|||
return result |
|||
|
|||
|
|||
def ensure_meid_string(data): |
|||
""" |
|||
Ensure MEID is always stored as a string. |
|||
Matches the legacy app's behavior of storing MEID as a string. |
|||
|
|||
Args: |
|||
data: Config dict potentially containing MEID |
|||
|
|||
Returns: |
|||
Dict with MEID converted to string if present |
|||
""" |
|||
if not isinstance(data, dict): |
|||
return data |
|||
|
|||
if "MEID" in data and data["MEID"] is not None: |
|||
data["MEID"] = str(data["MEID"]) |
|||
|
|||
return data |
|||
|
|||
|
|||
def validate_wifi_country_code(value): |
|||
"""Validate wifi_country_code: accept 2 ASCII letters (case-insensitive, trim). |
|||
|
|||
Returns None if valid or missing; returns error message string if invalid. |
|||
v2: reader trims + uppercases, never rejects on case. We trim+upper on input |
|||
(normalize_device_input) then accept any 2 alpha chars here. |
|||
""" |
|||
if value is None: |
|||
return None |
|||
if not isinstance(value, str): |
|||
return f"Invalid wifi_country_code (expected string): {value!r}" |
|||
stripped = value.strip() |
|||
if len(stripped) == 2 and stripped.isalpha(): |
|||
return None |
|||
return f"Invalid wifi_country_code (expected 2 letters after strip): {value!r}" |
|||
|
|||
|
|||
def validate_super_relay_keys(data, owner_label): |
|||
"""Reject keys owned by Super_Relay project (board_config 별도 row). |
|||
|
|||
Args: |
|||
data: device_config 또는 protocol_config dict |
|||
owner_label: "device" 또는 "protocol" |
|||
|
|||
Returns: |
|||
List of error strings (empty if valid) |
|||
""" |
|||
if not isinstance(data, dict): |
|||
return [] |
|||
errors = [] |
|||
forbidden = _FORBIDDEN_KEYS_DEVICE if owner_label == "device" else _FORBIDDEN_KEYS_PROTOCOL |
|||
for k in forbidden: |
|||
if k in data: |
|||
errors.append( |
|||
f"'{k}' is owned by Super_Relay project (board_config 별도 row) — " |
|||
f"do not include in {owner_label}_config" |
|||
) |
|||
return errors |
|||
|
|||
|
|||
def validate_rs485_integers(data): |
|||
"""H1: rs485_databits / rs485_stopbits type check (Integer) + 운영 강제 범위.""" |
|||
if not isinstance(data, dict): |
|||
return [] |
|||
errors = [] |
|||
# type check (v1.4.4 + v1.4.5.1 bool reject) |
|||
for k in ("rs485_databits", "rs485_stopbits"): |
|||
v = data.get(k) |
|||
if v is None: |
|||
continue |
|||
if isinstance(v, bool) or not isinstance(v, int): |
|||
errors.append(f"{k} must be integer (got {type(v).__name__})") |
|||
continue |
|||
# v1.4.8 운영 강제 범위 |
|||
if k == "rs485_databits" and v not in _RS485_DATABITS_RANGE: |
|||
errors.append(f"rs485_databits={v} out of range 5-9") |
|||
elif k == "rs485_stopbits" and v not in _RS485_STOPBITS_RANGE: |
|||
errors.append(f"rs485_stopbits={v} out of range 0-2") |
|||
return errors |
|||
|
|||
|
|||
def validate_device_config(data): |
|||
""" |
|||
Validate device configuration structure. |
|||
Returns list of error messages (empty if valid). |
|||
|
|||
Args: |
|||
data: Device config dict |
|||
|
|||
Returns: |
|||
List of error message strings |
|||
""" |
|||
errors = [] |
|||
|
|||
if not isinstance(data, dict): |
|||
errors.append("Device config must be a JSON object") |
|||
return errors |
|||
|
|||
# Validate IP format for known IP fields |
|||
ip_fields = [ |
|||
"wifi_ip", "wifi_netmask", "wifi_gateway", "wifi_dns1", "wifi_dns2", |
|||
"eth_ip", "eth_netmask", "eth_gateway", |
|||
"protocol_server_ip", "update_server_ip", "rtcm_server_ip", |
|||
"opc_ua_server_ip", "modbus_server_ip", "lte_server_ip", "lte_ip" |
|||
] |
|||
for field in ip_fields: |
|||
value = data.get(field) |
|||
if value and isinstance(value, str) and value.strip(): |
|||
if not _is_valid_ip(value): |
|||
errors.append(f"Invalid IP address format for '{field}': {value}") |
|||
|
|||
# Validate port numbers |
|||
# v1.4.6.4 I2: lte_port는 device contract에서 String 타입 (interface 또는 free-form, dha='534'). |
|||
# port_fields에 추가하지 말 것 — 타입 mismatch. |
|||
# 참고: lte_server_port는 정수 키 (다른 키, port_fields 정상 포함) |
|||
port_fields = [ |
|||
"protocol_server_port", "update_server_port", "rtcm_server_port", |
|||
"opc_ua_server_port", "modbus_server_port", "lte_server_port" |
|||
] |
|||
for field in port_fields: |
|||
value = data.get(field) |
|||
if value is not None: |
|||
try: |
|||
port = int(value) |
|||
if port < 1 or port > 65535: |
|||
errors.append(f"Port out of range for '{field}': {value}") |
|||
except (ValueError, TypeError): |
|||
errors.append(f"Invalid port number for '{field}': {value}") |
|||
|
|||
# WiFi regulatory country code (ISO 3166-1 alpha-2). Non-blocking: |
|||
# mirrors the client constraint server-side. The legacy app did no |
|||
# server validation here — this is intentionally stricter. |
|||
# v1.4.2 I2: delegate to dedicated function (DRY) |
|||
cc_error = validate_wifi_country_code(data.get("wifi_country_code")) |
|||
if cc_error: |
|||
errors.append(cc_error) |
|||
|
|||
# log_auto_cleanup — enum |
|||
auto_cleanup = data.get("log_auto_cleanup") |
|||
if auto_cleanup is not None and auto_cleanup not in ("on", "off"): |
|||
errors.append( |
|||
f"Invalid log_auto_cleanup (expected 'on' or 'off'): {auto_cleanup!r}" |
|||
) |
|||
|
|||
# log_cleanup_max_files — hard floor 5 |
|||
max_files = data.get("log_cleanup_max_files") |
|||
if max_files is not None: |
|||
try: |
|||
if int(max_files) < 5: |
|||
errors.append( |
|||
f"Invalid log_cleanup_max_files (must be >= 5): {max_files!r}" |
|||
) |
|||
except (TypeError, ValueError): |
|||
errors.append( |
|||
f"Invalid log_cleanup_max_files (must be integer): {max_files!r}" |
|||
) |
|||
|
|||
# log_cleanup_max_size_mb — hard floor 50 |
|||
max_size_mb = data.get("log_cleanup_max_size_mb") |
|||
if max_size_mb is not None: |
|||
try: |
|||
if int(max_size_mb) < 50: |
|||
errors.append( |
|||
f"Invalid log_cleanup_max_size_mb (must be >= 50): {max_size_mb!r}" |
|||
) |
|||
except (TypeError, ValueError): |
|||
errors.append( |
|||
f"Invalid log_cleanup_max_size_mb (must be integer): {max_size_mb!r}" |
|||
) |
|||
|
|||
return errors |
|||
|
|||
|
|||
def validate_odo_field(obj, key_label): |
|||
"""Validate odo_speed / odo_direction nested object. |
|||
|
|||
Device contract: {source: speed/dir source enum, id: String, shift: Integer, |
|||
mask: String, expr: String}. |
|||
All keys optional (the legacy app omits null-valued keys on save). Validates types/enum when present. |
|||
|
|||
Returns list of error strings (empty if valid). |
|||
""" |
|||
errors = [] |
|||
if obj is None: |
|||
return errors |
|||
if not isinstance(obj, dict): |
|||
errors.append(f"{key_label} must be an object (got {type(obj).__name__})") |
|||
return errors |
|||
|
|||
src = obj.get("source") |
|||
if src is not None and src not in ODO_SOURCE_VALUES: |
|||
errors.append(f"{key_label}.source={src!r} not in {sorted(ODO_SOURCE_VALUES)}") |
|||
|
|||
id_v = obj.get("id") |
|||
if id_v is not None: |
|||
if not isinstance(id_v, str): |
|||
errors.append(f"{key_label}.id must be string") |
|||
elif len(id_v) > _ODO_FIELD_STR_MAX_LEN: |
|||
errors.append(f"{key_label}.id too long ({len(id_v)} > {_ODO_FIELD_STR_MAX_LEN})") |
|||
|
|||
shift = obj.get("shift") |
|||
# v2: accept int OR digit-string (reader accepts digit-strings — "8" is valid) |
|||
if shift is not None: |
|||
iv = _coerce_int_or_none(shift) |
|||
if iv is None: |
|||
errors.append(f"{key_label}.shift must be integer (got {shift!r})") |
|||
elif iv not in _ODO_SHIFT_RANGE: |
|||
errors.append(f"{key_label}.shift out of range 0-63: {iv}") |
|||
|
|||
mask = obj.get("mask") |
|||
if mask is not None: |
|||
if not isinstance(mask, str): |
|||
errors.append(f"{key_label}.mask must be string") |
|||
elif len(mask) > _ODO_FIELD_STR_MAX_LEN: |
|||
errors.append(f"{key_label}.mask too long ({len(mask)} > {_ODO_FIELD_STR_MAX_LEN})") |
|||
|
|||
expr = obj.get("expr") |
|||
if expr is not None: |
|||
if not isinstance(expr, str): |
|||
errors.append(f"{key_label}.expr must be string") |
|||
else: |
|||
# v2: length bound only (<=39); reader does NOT validate odo expr content |
|||
# (odo_speed/odo_direction expr stays permissive — see v1.4.8 accept tests) |
|||
if len(expr) > _ODO_EXPR_MAX_LEN: |
|||
errors.append(f"{key_label}.expr too long ({len(expr)} > {_ODO_EXPR_MAX_LEN})") |
|||
|
|||
return errors |
|||
|
|||
|
|||
def validate_protocol_config(data): |
|||
""" |
|||
Validate protocol configuration structure. |
|||
Returns list of error messages (empty if valid). |
|||
|
|||
Args: |
|||
data: Protocol config dict |
|||
|
|||
Returns: |
|||
List of error message strings |
|||
""" |
|||
errors = [] |
|||
|
|||
if not isinstance(data, dict): |
|||
errors.append("Protocol config must be a JSON object") |
|||
return errors |
|||
|
|||
# Validate protocol mapping arrays if present |
|||
# v2: cap = 30 (reader truncates at 30) |
|||
MAX_MAPPING_ENTRIES = 30 |
|||
protocol = data.get("protocol", "") |
|||
|
|||
for arr_key in ("MODBUS", "OPC_UA", "CAN"): |
|||
arr = data.get(arr_key) |
|||
if isinstance(arr, list) and len(arr) > MAX_MAPPING_ENTRIES: |
|||
errors.append(f"{arr_key} array too large ({len(arr)} > {MAX_MAPPING_ENTRIES})") |
|||
|
|||
if protocol == "MODBUS" and "MODBUS" in data: |
|||
modbus = data["MODBUS"] |
|||
if modbus is not None and not isinstance(modbus, list): |
|||
errors.append("MODBUS mapping must be a JSON array") |
|||
elif isinstance(modbus, list): |
|||
for i, entry in enumerate(modbus): |
|||
if not isinstance(entry, dict): |
|||
errors.append(f"MODBUS entry {i} must be an object") |
|||
continue |
|||
if not entry.get("field"): |
|||
errors.append(f"MODBUS entry {i}: 'field' is required") |
|||
if not entry.get("addr") and entry.get("addr") != 0: |
|||
errors.append(f"MODBUS entry {i}: 'addr' is required") |
|||
|
|||
if protocol == "OPC_UA" and "OPC_UA" in data: |
|||
opcua = data["OPC_UA"] |
|||
if opcua is not None and not isinstance(opcua, list): |
|||
errors.append("OPC_UA mapping must be a JSON array") |
|||
elif isinstance(opcua, list): |
|||
for i, entry in enumerate(opcua): |
|||
if not isinstance(entry, dict): |
|||
errors.append(f"OPC_UA entry {i} must be an object") |
|||
continue |
|||
if not entry.get("field"): |
|||
errors.append(f"OPC_UA entry {i}: 'field' is required") |
|||
|
|||
if data.get("can_input") == "on" and "CAN" in data: |
|||
can = data["CAN"] |
|||
if can is not None and not isinstance(can, list): |
|||
errors.append("CAN mapping must be a JSON array") |
|||
elif isinstance(can, list): |
|||
for i, entry in enumerate(can): |
|||
if not isinstance(entry, dict): |
|||
errors.append(f"CAN entry {i} must be an object") |
|||
continue |
|||
if not entry.get("field"): |
|||
errors.append(f"CAN entry {i}: 'field' is required") |
|||
if not entry.get("id"): |
|||
errors.append(f"CAN entry {i}: 'id' is required") |
|||
|
|||
# v1.4.3 C2: nested odo object validation |
|||
errors.extend(validate_odo_field(data.get("odo_speed"), "odo_speed")) |
|||
errors.extend(validate_odo_field(data.get("odo_direction"), "odo_direction")) |
|||
|
|||
return errors |
|||
|
|||
|
|||
def _coerce_int_or_none(v): |
|||
"""int (not bool) or a clean integer string → int; else None. |
|||
|
|||
Frontend CRUD tables emit register fields as STRINGS (e.g. shift "0"), |
|||
while imported/legacy configs may carry native ints — accept both, reject |
|||
bool and anything non-integer. |
|||
""" |
|||
if isinstance(v, bool): |
|||
return None |
|||
if isinstance(v, int): |
|||
return v |
|||
if isinstance(v, str): |
|||
s = v.strip() |
|||
if s.lstrip("-").isdigit(): |
|||
try: |
|||
return int(s) |
|||
except (ValueError, TypeError): |
|||
return None |
|||
return None |
|||
|
|||
|
|||
def _is_numeric_value(v): |
|||
"""True iff v is a real number (int/float, not bool) or a numeric string. |
|||
|
|||
DV (default value) is stored as a string by the frontend (e.g. "-1.0") but |
|||
may be a native number in imported configs. |
|||
""" |
|||
if isinstance(v, bool): |
|||
return False |
|||
if isinstance(v, (int, float)): |
|||
return True |
|||
if isinstance(v, str): |
|||
try: |
|||
float(v.strip()) |
|||
return True |
|||
except (ValueError, TypeError): |
|||
return False |
|||
return False |
|||
|
|||
|
|||
def _validate_register_entry(entry, label, errors, require_addr=False, require_id=False): |
|||
"""Per-entry field validation for a single register mapping object.""" |
|||
if not isinstance(entry, dict): |
|||
errors.append(f"{label} must be an object") |
|||
return |
|||
|
|||
if not entry.get("field"): |
|||
errors.append(f"{label}: 'field' is required") |
|||
# addr presence: 0 is a legitimate address (matches validate_protocol_config) |
|||
if require_addr and not entry.get("addr") and entry.get("addr") != 0: |
|||
errors.append(f"{label}: 'addr' is required") |
|||
if require_id and not entry.get("id"): |
|||
errors.append(f"{label}: 'id' is required") |
|||
|
|||
# expr — string, length <=39 only; reader does NOT validate expr content |
|||
expr = entry.get("expr") |
|||
if expr is not None and expr != "": |
|||
if not isinstance(expr, str): |
|||
errors.append(f"{label}.expr must be string") |
|||
else: |
|||
# v2: length bound; math chars allowed; block injection chars only |
|||
if len(expr) > _ODO_EXPR_MAX_LEN: |
|||
errors.append(f"{label}.expr too long ({len(expr)} > {_ODO_EXPR_MAX_LEN})") |
|||
if any(c in _ODO_EXPR_FORBIDDEN_CHARS for c in expr): |
|||
errors.append(f"{label}.expr contains a forbidden character") |
|||
|
|||
# shift — int (not bool) or numeric string, range 0..63 |
|||
shift = entry.get("shift") |
|||
if shift is not None and shift != "": |
|||
iv = _coerce_int_or_none(shift) |
|||
if iv is None: |
|||
errors.append(f"{label}.shift must be integer (got {shift!r})") |
|||
elif iv not in _REGISTER_SHIFT_RANGE: |
|||
errors.append(f"{label}.shift out of range 0-63: {iv}") |
|||
|
|||
# mask / id — bounded strings (type + length only; charset intentionally |
|||
# unconstrained — the device accepts free-form hex like '0xff' / CAN id |
|||
# '0x18FEFC28', and no contract evidence demands a hex-only charset here). |
|||
for fkey in ("mask", "id"): |
|||
fv = entry.get(fkey) |
|||
if fv is not None and fv != "": |
|||
if not isinstance(fv, str): |
|||
errors.append(f"{label}.{fkey} must be string") |
|||
elif len(fv) > _REGISTER_STR_MAX_LEN: |
|||
errors.append(f"{label}.{fkey} too long ({len(fv)} > {_REGISTER_STR_MAX_LEN})") |
|||
|
|||
# idt / odt — device config-reader contract |
|||
# idt: case-insensitive membership (lowercase before check) |
|||
idt = entry.get("idt") |
|||
if idt is not None and idt != "": |
|||
if idt.lower() not in _REGISTER_IDT_VALUES: |
|||
errors.append(f"{label}.idt={idt!r} not in reader idt set") |
|||
# odt: accept synonyms float/double→float64, bool→boolean (case-insensitive) |
|||
odt = entry.get("odt") |
|||
_odt_normalized = None |
|||
if odt is not None and odt != "": |
|||
_odt_lower = odt.lower() |
|||
if _odt_lower in ("float", "double"): |
|||
_odt_normalized = "float64" |
|||
elif _odt_lower == "bool": |
|||
_odt_normalized = "boolean" |
|||
else: |
|||
_odt_normalized = _odt_lower |
|||
if _odt_normalized not in _REGISTER_ODT_VALUES: |
|||
errors.append(f"{label}.odt={odt!r} not in reader odt set") |
|||
|
|||
# dv — numeric normally; arbitrary string allowed when odt=string |
|||
dv = entry.get("dv") |
|||
if dv is not None and dv != "": |
|||
_odt_is_string = (_odt_normalized == "string") if _odt_normalized else False |
|||
if not _odt_is_string and not _is_numeric_value(dv): |
|||
errors.append(f"{label}.dv must be numeric (got {dv!r})") |
|||
|
|||
|
|||
def validate_register_entries(data, merged_data=None): |
|||
"""v1.11.8 A: hard-reject malformed register entries in the ACTIVE protocol's |
|||
MODBUS / OPC_UA / CAN array. Sibling of validate_odo_field — returns list[str] |
|||
(non-empty → the POST handler returns 400). Promotes the structural checks |
|||
that validate_protocol_config only soft-warned (non-list array type, >1000 |
|||
entry cap, required field/addr/id) to hard rejects, and adds per-field |
|||
constraints on expr / shift / mask / id / dv / idt / odt. |
|||
|
|||
Only the currently-active protocol's array is validated so an inactive |
|||
(preserved) array cannot block an unrelated save — matches the gating in |
|||
validate_protocol_config and the POST handler. |
|||
""" |
|||
errors = [] |
|||
if not isinstance(data, dict): |
|||
return errors |
|||
|
|||
# v1.11.9 Fix 3: the CAN gate must use the EFFECTIVE can_input — a partial |
|||
# POST may carry a CAN array but omit can_input, while the DB already has it |
|||
# 'on'. merged_data (DB ∪ incoming) supplies that effective value so the |
|||
# per-entry checks (incl. the expr injection guard) are not silently skipped. |
|||
# Falls back to `data` when no merged view is provided (backward compatible). |
|||
gate = merged_data if isinstance(merged_data, dict) else data |
|||
|
|||
# v1.11.10 Fix 1: the MODBUS/OPC_UA gate must use the EFFECTIVE protocol |
|||
# (same reasoning as the CAN gate above) — a partial POST may carry a MODBUS |
|||
# array but omit the unchanged 'protocol' key, leaving it '' so the per-entry |
|||
# checks (incl. the expr injection guard) were silently skipped. |
|||
protocol = gate.get("protocol", "") |
|||
|
|||
# (array_key, active?, require_addr, require_id) |
|||
specs = [ |
|||
("MODBUS", protocol == "MODBUS", True, False), |
|||
("OPC_UA", protocol == "OPC_UA", False, False), |
|||
("CAN", gate.get("can_input") == "on", False, True), |
|||
] |
|||
for arr_key, active, require_addr, require_id in specs: |
|||
if not active or arr_key not in data: |
|||
continue |
|||
arr = data[arr_key] |
|||
if arr is None: |
|||
continue # treated as absent (process_protocol_config will default to []) |
|||
if not isinstance(arr, list): |
|||
errors.append(f"{arr_key} mapping must be a JSON array (got {type(arr).__name__})") |
|||
continue |
|||
if len(arr) > _REGISTER_MAX_ENTRIES: |
|||
errors.append(f"{arr_key} array too large ({len(arr)} > {_REGISTER_MAX_ENTRIES})") |
|||
continue |
|||
for i, entry in enumerate(arr): |
|||
_validate_register_entry( |
|||
entry, f"{arr_key} entry {i}", errors, |
|||
require_addr=require_addr, require_id=require_id, |
|||
) |
|||
|
|||
return errors |
|||
|
|||
|
|||
def validate_log_config_hard(data): |
|||
"""v1.11.8 B: hard-reject destructive log-config values before they reach |
|||
log_config / drive auto-cleanup. Sibling of validate_rs485_integers — returns |
|||
list[str] (non-empty → 400). Mirrors the floors that validate_device_config |
|||
only soft-warned, plus the two compress thresholds, and rejects bool for the |
|||
integer fields (bool is an int subclass → would silently coerce). |
|||
""" |
|||
errors = [] |
|||
if not isinstance(data, dict): |
|||
return errors |
|||
|
|||
for k in ("log_auto_cleanup", "log_auto_compress"): |
|||
v = data.get(k) |
|||
if v is not None and v not in ("on", "off"): |
|||
errors.append(f"Invalid {k} (expected 'on' or 'off'): {v!r}") |
|||
|
|||
# (key, floor) |
|||
int_floors = [ |
|||
("log_cleanup_max_files", 5), |
|||
("log_cleanup_max_size_mb", 50), |
|||
("log_compress_size_mb", 1), |
|||
("log_compress_age_days", 1), |
|||
] |
|||
for key, floor in int_floors: |
|||
v = data.get(key) |
|||
if v is None: |
|||
continue |
|||
if isinstance(v, bool): |
|||
errors.append(f"Invalid {key} (must be integer, got bool): {v!r}") |
|||
continue |
|||
try: |
|||
iv = int(v) |
|||
except (TypeError, ValueError): |
|||
errors.append(f"Invalid {key} (must be integer): {v!r}") |
|||
continue |
|||
if iv < floor: |
|||
errors.append(f"Invalid {key} (must be >= {floor}): {v!r}") |
|||
|
|||
return errors |
|||
|
|||
|
|||
def validate_device_ip_fields_hard(data): |
|||
"""v1.11.8 D: hard-reject malformed IPv4 in device_config IP fields before the |
|||
write. Empty string = "clear" (allowed). Uses the shared strict _is_valid_ip |
|||
(leading-zero rule), so the device path and the network-apply path agree. |
|||
Sibling of validate_device_port_types_hard — returns list[str] (non-empty → 400). |
|||
""" |
|||
errors = [] |
|||
if not isinstance(data, dict): |
|||
return errors |
|||
ip_fields = ( |
|||
"wifi_ip", "wifi_netmask", "wifi_gateway", "wifi_dns1", "wifi_dns2", |
|||
"eth_ip", "eth_netmask", "eth_gateway", |
|||
"protocol_server_ip", "update_server_ip", "rtcm_server_ip", |
|||
"opc_ua_server_ip", "modbus_server_ip", "lte_server_ip", "lte_ip", |
|||
"lte_netmask", "lte_gateway", |
|||
) |
|||
for field in ip_fields: |
|||
v = data.get(field) |
|||
if v is None: |
|||
continue |
|||
if not isinstance(v, str): |
|||
errors.append(f"Invalid IP for '{field}' (must be string): {v!r}") |
|||
continue |
|||
s = v.strip() |
|||
if s == "": |
|||
continue # empty = clear (allowed) |
|||
if not _is_valid_ip(s): |
|||
errors.append(f"Invalid IPv4 address for '{field}': {v}") |
|||
return errors |
|||
|
|||
|
|||
def validate_wifi_ssid_profiles(data): |
|||
"""v1.11.8 C: hard-reject malformed device_config WIFI_SSID profiles on the |
|||
save-path, REUSING the network/validator SSID/PSK/security rules so the save |
|||
path and the network-apply path cannot diverge. Returns list[str] (non-empty |
|||
→ 400). The profile array key/shape mirrors netmodel.intent_from_device: |
|||
each entry is {wifi_ssid, wifi_passwd, wifi_security}. |
|||
""" |
|||
errors = [] |
|||
if not isinstance(data, dict): |
|||
return errors |
|||
if "WIFI_SSID" not in data or data.get("WIFI_SSID") is None: |
|||
return errors |
|||
raw = data["WIFI_SSID"] |
|||
if not isinstance(raw, list): |
|||
errors.append(f"WIFI_SSID must be a JSON array (got {type(raw).__name__})") |
|||
return errors |
|||
|
|||
# Factor through the network model + validator: build a wlan0 intent fragment |
|||
# so the SSID/PSK/security/gap rules come from the single source of truth. |
|||
from network import netmodel as _netmodel |
|||
from network import validator as _net_validator |
|||
|
|||
# v1.11.8 fix: validate ONLY the first MAX_PROFILES entries — the apply path |
|||
# truncates extras at MAX_PROFILES (intent_from_device[:MAX_PROFILES]). We do |
|||
# NOT hard-reject on profile COUNT: the WiFi UI permits up to 10 rows and legacy |
|||
# device_config may already hold >5, so a count-400 would break otherwise-valid |
|||
# saves with no safety gain (profiles 6+ are never rendered). Content |
|||
# (SSID/PSK/security/gap) of the used profiles is still enforced. |
|||
profiles = [ |
|||
{ |
|||
"ssid": _netmodel._s(e.get("wifi_ssid")) if isinstance(e, dict) else "", |
|||
"password": _netmodel._s(e.get("wifi_passwd")) if isinstance(e, dict) else "", |
|||
"security": _netmodel._norm_security(e.get("wifi_security")) if isinstance(e, dict) else "wpa/wpa2", |
|||
} |
|||
for e in raw[: _net_validator.MAX_PROFILES] |
|||
] |
|||
intent = { |
|||
"wlan0": {"mode": "dhcp", "ip": "", "netmask": "", "gateway": "", |
|||
"dns1": "", "dns2": "", "country_code": "", "profiles": profiles}, |
|||
"eth0": {"mode": "dhcp", "ip": "", "netmask": "", "gateway": "", |
|||
"server_ip": "", "server_port": ""}, |
|||
"eth1": {"mode": "dhcp", "ip": "", "netmask": "", "gateway": "", |
|||
"opc_ua_server_ip": "", "opc_ua_server_port": "", |
|||
"modbus_server_ip": "", "modbus_server_port": ""}, |
|||
} |
|||
errs, _warns = _net_validator.validate(intent) |
|||
# Only surface profile-related errors (the intent's network fields are all |
|||
# dhcp/empty so they never error, but be defensive and keep wlan0/profile msgs). |
|||
for e in errs: |
|||
if "profiles" in e or "SSID" in e or "password" in e or "security" in e: |
|||
errors.append(e) |
|||
return errors |
|||
|
|||
|
|||
def _format_allowed(allowed): |
|||
"""Format an allowed-values set for human-readable error messages. |
|||
|
|||
Handles mixed-type sets that include None (e.g. can_baudrate frozenset). |
|||
sorted() on such sets raises TypeError (None < int unsupported), so we |
|||
sort with a safe key: None always first, then remaining items by str(v). |
|||
|
|||
Returns a string like: [null, 100, 250, 500, 1000] |
|||
""" |
|||
items_str = [ |
|||
"null" if v is None else (f'"{v}"' if isinstance(v, str) else str(v)) |
|||
for v in sorted(allowed, key=lambda v: (v is not None, str(v) if v is not None else "")) |
|||
] |
|||
return "[" + ", ".join(items_str) + "]" |
|||
|
|||
|
|||
def validate_device_enums(cfg): |
|||
"""Return list of error messages; empty if all provided keys are valid.""" |
|||
errors = [] |
|||
for key, allowed in DEVICE_ENUM_VALUES.items(): |
|||
if key in cfg and cfg[key] not in allowed: |
|||
errors.append(f"{key}={cfg[key]!r} not allowed (valid: {_format_allowed(allowed)})") |
|||
return errors |
|||
|
|||
|
|||
def validate_protocol_enums(cfg): |
|||
"""Normalize legacy protocol form + equipment case, then validate. Does not mutate caller.""" |
|||
normalized = dict(cfg) |
|||
if normalized.get("protocol") in PROTOCOL_ALIASES: |
|||
normalized["protocol"] = PROTOCOL_ALIASES[normalized["protocol"]] |
|||
# v2: equipment matching case-insensitive (uppercase before membership check) |
|||
if "equipment" in normalized and isinstance(normalized["equipment"], str): |
|||
normalized["equipment"] = normalized["equipment"].upper() |
|||
errors = [] |
|||
for key, allowed in PROTOCOL_ENUM_VALUES.items(): |
|||
if key in normalized and normalized[key] not in allowed: |
|||
errors.append(f"{key}={normalized[key]!r} not allowed (valid: {_format_allowed(allowed)})") |
|||
return errors |
|||
|
|||
|
|||
def _is_valid_ip(ip_str): |
|||
""" |
|||
Validate IPv4 address format. |
|||
|
|||
Args: |
|||
ip_str: String to validate |
|||
|
|||
Returns: |
|||
True if valid IPv4 format |
|||
""" |
|||
parts = ip_str.strip().split(".") |
|||
if len(parts) != 4: |
|||
return False |
|||
for part in parts: |
|||
# strict, matching netmodel._ip_u32: ASCII digits only, no leading zero (except "0"), 0-255 |
|||
if not (part.isascii() and part.isdigit()): |
|||
return False |
|||
if len(part) > 1 and part[0] == "0": |
|||
return False |
|||
if int(part) > 255: |
|||
return False |
|||
return True |
|||
@ -0,0 +1,443 @@ |
|||
""" |
|||
db_manager.py — Configuration Storage Layer (Multi-Backend) |
|||
|
|||
Storage priority: |
|||
1. sqlite3 Python module (preferred, full dpworldapp compat) |
|||
2. sqlite3 CLI via subprocess (dpworldapp compat on minimal Python) |
|||
3. JSON file fallback (no dpworldapp compat, temp only) |
|||
""" |
|||
|
|||
import os |
|||
import sys |
|||
import json |
|||
import subprocess |
|||
import shutil |
|||
import threading |
|||
import time |
|||
from datetime import datetime, timezone |
|||
|
|||
try: |
|||
import sqlite3 |
|||
HAS_SQLITE = True |
|||
except ImportError: |
|||
HAS_SQLITE = False |
|||
|
|||
DB_PATH_DEFAULT = os.path.join(os.path.expanduser("~"), "db", "dynamic_data.db") |
|||
|
|||
# save_config retries transient SQLite "database is locked" errors: a competing |
|||
# writer can briefly hold the lock even with PRAGMA busy_timeout set. Total |
|||
# attempts, and the pause between them. |
|||
_WRITE_MAX_ATTEMPTS = 3 |
|||
_WRITE_RETRY_DELAY_S = 0.1 |
|||
|
|||
|
|||
def _has_sqlite3_cli(): |
|||
"""Check if sqlite3 command-line tool is available.""" |
|||
return shutil.which("sqlite3") is not None |
|||
|
|||
|
|||
class DBManager: |
|||
"""Configuration storage manager with 3-tier backend.""" |
|||
|
|||
# Whitelist of allowed config keys (SQL injection defense for CLI backend) |
|||
ALLOWED_KEYS = frozenset({'device_config', 'protocol_config', 'log_config', 'net_config', 'ap_config'}) # AP: ap_config key |
|||
|
|||
def __init__(self, db_path=None, backend=None): |
|||
self.db_path = db_path or os.environ.get("DB_PATH", DB_PATH_DEFAULT) |
|||
self._lock = threading.Lock() |
|||
|
|||
if backend is not None: |
|||
# Explicit backend override (used in tests and special deployments) |
|||
self._backend = backend |
|||
if backend == "json": |
|||
self._json_dir = os.path.dirname(self.db_path) |
|||
os.makedirs(self._json_dir, exist_ok=True) |
|||
print(f"[DB] Backend override: {backend} ({self.db_path})") |
|||
elif HAS_SQLITE: |
|||
self._backend = "python" |
|||
print(f"[DB] Using Python sqlite3: {self.db_path}") |
|||
elif _has_sqlite3_cli(): |
|||
self._backend = "cli" |
|||
print(f"[DB] Using sqlite3 CLI: {self.db_path}") |
|||
else: |
|||
self._backend = "json" |
|||
self._json_dir = os.path.dirname(self.db_path) |
|||
os.makedirs(self._json_dir, exist_ok=True) |
|||
print(f"[DB] Fallback to JSON files: {self._json_dir}") |
|||
|
|||
# ─── Python sqlite3 module ─────────────────────────────────── |
|||
|
|||
def _connect(self): |
|||
conn = sqlite3.connect(self.db_path) |
|||
conn.execute("PRAGMA busy_timeout = 5000") |
|||
conn.execute("PRAGMA journal_mode = WAL") |
|||
conn.row_factory = sqlite3.Row |
|||
return conn |
|||
|
|||
def _validate_key(self, key): |
|||
"""Validate config key against whitelist to prevent SQL injection.""" |
|||
if not isinstance(key, str): |
|||
raise ValueError(f"Invalid config key type: {type(key)}") |
|||
if key not in self.ALLOWED_KEYS: |
|||
raise ValueError(f"Invalid config key: {key}") |
|||
|
|||
# ─── sqlite3 CLI (subprocess) ──────────────────────────────── |
|||
|
|||
def _cli_exec(self, sql): |
|||
"""Execute SQL via sqlite3 CLI and return stdout.""" |
|||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True) |
|||
result = subprocess.run( |
|||
["sqlite3", self.db_path], |
|||
input=sql, |
|||
capture_output=True, |
|||
text=True, |
|||
timeout=10 |
|||
) |
|||
if result.returncode != 0: |
|||
raise RuntimeError(f"sqlite3 CLI error: {result.stderr.strip()}") |
|||
return result.stdout.strip() |
|||
|
|||
def _cli_query(self, sql): |
|||
"""Execute query via CLI and return result string.""" |
|||
return self._cli_exec(sql) |
|||
|
|||
# ─── JSON file helpers ─────────────────────────────────────── |
|||
|
|||
def _json_path(self, key): |
|||
return os.path.join(self._json_dir, f"{key}.json") |
|||
|
|||
def _read_json_file(self, key): |
|||
path = self._json_path(key) |
|||
if not os.path.exists(path): |
|||
return None |
|||
try: |
|||
with open(path, "r", encoding="utf-8") as f: |
|||
return json.load(f) |
|||
except (json.JSONDecodeError, IOError): |
|||
return None |
|||
|
|||
def _write_json_file(self, key, data): |
|||
path = self._json_path(key) |
|||
tmp_path = path + ".tmp" |
|||
json_str = json.dumps(data, ensure_ascii=False, indent=2) |
|||
with open(tmp_path, "w", encoding="utf-8") as f: |
|||
f.write(json_str) |
|||
f.flush() |
|||
os.fsync(f.fileno()) |
|||
os.replace(tmp_path, path) |
|||
|
|||
# ─── Public API ────────────────────────────────────────────── |
|||
|
|||
def ensure_tables(self): |
|||
if self._backend == "python": |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
conn.execute(""" |
|||
CREATE TABLE IF NOT EXISTS board_config ( |
|||
key TEXT PRIMARY KEY NOT NULL, |
|||
value TEXT NOT NULL |
|||
) |
|||
""") |
|||
conn.execute(""" |
|||
CREATE TABLE IF NOT EXISTS event_history ( |
|||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|||
data TEXT NOT NULL |
|||
) |
|||
""") |
|||
conn.commit() |
|||
finally: |
|||
conn.close() |
|||
elif self._backend == "cli": |
|||
with self._lock: |
|||
self._cli_exec( |
|||
"CREATE TABLE IF NOT EXISTS board_config " |
|||
"(key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL);\n" |
|||
"CREATE TABLE IF NOT EXISTS event_history " |
|||
"(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL);" |
|||
) |
|||
else: |
|||
os.makedirs(self._json_dir, exist_ok=True) |
|||
|
|||
def get_config(self, key): |
|||
self._validate_key(key) |
|||
if self._backend == "python": |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
cursor = conn.execute( |
|||
"SELECT value FROM board_config WHERE key = ?", (key,) |
|||
) |
|||
row = cursor.fetchone() |
|||
if row is None: |
|||
return None |
|||
try: |
|||
return json.loads(row["value"]) |
|||
except json.JSONDecodeError: |
|||
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; returning None", file=sys.stderr) |
|||
return None |
|||
finally: |
|||
conn.close() |
|||
elif self._backend == "cli": |
|||
with self._lock: |
|||
raw = self._cli_query( |
|||
f"SELECT value FROM board_config WHERE key = '{key}';" |
|||
) |
|||
if not raw: |
|||
return None |
|||
try: |
|||
return json.loads(raw) |
|||
except json.JSONDecodeError: |
|||
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; returning None", file=sys.stderr) |
|||
return None |
|||
else: |
|||
with self._lock: |
|||
return self._read_json_file(key) |
|||
|
|||
def save_config(self, key, data): |
|||
self._validate_key(key) |
|||
json_str = json.dumps(data, ensure_ascii=False, indent=2) |
|||
|
|||
if self._backend == "python": |
|||
last_error = None |
|||
for attempt in range(_WRITE_MAX_ATTEMPTS): |
|||
# #37 fix: acquire the lock only around the actual DB op; the retry |
|||
# backoff sleep below runs OUTSIDE the lock so a contended write |
|||
# does not stall every other DB thread for the backoff window. |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
conn.execute( |
|||
"INSERT OR REPLACE INTO board_config (key, value) VALUES (?, ?)", |
|||
(key, json_str) |
|||
) |
|||
conn.commit() |
|||
return |
|||
except sqlite3.OperationalError as e: |
|||
# Retry only the transient lock; re-raise anything else. |
|||
if "database is locked" not in str(e).lower(): |
|||
raise |
|||
last_error = e |
|||
finally: |
|||
conn.close() |
|||
if attempt < _WRITE_MAX_ATTEMPTS - 1: |
|||
time.sleep(_WRITE_RETRY_DELAY_S) # outside the lock |
|||
raise last_error |
|||
elif self._backend == "cli": |
|||
# Escape single quotes in JSON for SQL |
|||
escaped = json_str.replace("'", "''") |
|||
with self._lock: |
|||
self._cli_exec( |
|||
f"INSERT OR REPLACE INTO board_config (key, value) " |
|||
f"VALUES ('{key}', '{escaped}');" |
|||
) |
|||
else: |
|||
with self._lock: |
|||
self._write_json_file(key, data) |
|||
|
|||
def update_config(self, key, mutator, default=None): |
|||
""" |
|||
Atomic read-modify-write of board_config entry. |
|||
|
|||
Args: |
|||
key: board_config key (must be in ALLOWED_KEYS) |
|||
mutator: callable(current_value) -> new_value. current_value is |
|||
the JSON-deserialized current value (or `default` if absent). |
|||
Must NOT mutate input — return a new dict. |
|||
default: value to pass to mutator when key is missing (default: None) |
|||
|
|||
Returns: |
|||
The new value (after mutator) — i.e., what was actually saved. |
|||
If mutator returns None the key is deleted and None is returned. |
|||
|
|||
Atomic guarantees: |
|||
- python backend: BEGIN IMMEDIATE + SELECT + UPDATE + COMMIT within |
|||
one connection. Other writers block; lock retries with exponential |
|||
backoff (same _WRITE_MAX_ATTEMPTS pattern). |
|||
- cli backend: serialized by _lock (cli is sequential). |
|||
- json backend: lock + read + mutator + atomic write (tempfile+rename). |
|||
""" |
|||
self._validate_key(key) |
|||
|
|||
if self._backend == "python": |
|||
last_error = None |
|||
for attempt in range(_WRITE_MAX_ATTEMPTS): |
|||
# #37 fix: lock only around the atomic RMW; the retry backoff sleep |
|||
# below runs OUTSIDE the lock so a contended write does not stall |
|||
# every other DB thread for the backoff window. |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
# BEGIN IMMEDIATE — acquires write lock immediately, |
|||
# blocking other writers but allowing concurrent reads. |
|||
conn.execute("BEGIN IMMEDIATE") |
|||
cursor = conn.execute( |
|||
"SELECT value FROM board_config WHERE key = ?", (key,) |
|||
) |
|||
row = cursor.fetchone() |
|||
if row is None: |
|||
current = default |
|||
else: |
|||
try: |
|||
current = json.loads(row["value"]) |
|||
except json.JSONDecodeError: |
|||
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; using default", file=sys.stderr) |
|||
current = default |
|||
new_value = mutator(current) |
|||
if new_value is None: |
|||
# mutator returned None → delete key |
|||
conn.execute( |
|||
"DELETE FROM board_config WHERE key = ?", (key,) |
|||
) |
|||
else: |
|||
json_str = json.dumps(new_value, ensure_ascii=False, indent=2) |
|||
conn.execute( |
|||
"INSERT OR REPLACE INTO board_config (key, value) VALUES (?, ?)", |
|||
(key, json_str) |
|||
) |
|||
conn.commit() |
|||
return new_value |
|||
except sqlite3.OperationalError as e: |
|||
if "database is locked" not in str(e).lower(): |
|||
raise |
|||
last_error = e |
|||
try: |
|||
conn.rollback() |
|||
except Exception: |
|||
pass |
|||
finally: |
|||
conn.close() |
|||
if attempt < _WRITE_MAX_ATTEMPTS - 1: |
|||
time.sleep(_WRITE_RETRY_DELAY_S) # outside the lock |
|||
raise last_error |
|||
elif self._backend == "cli": |
|||
with self._lock: |
|||
raw = self._cli_query( |
|||
f"SELECT value FROM board_config WHERE key = '{key}';" |
|||
) |
|||
if not raw: |
|||
current = default |
|||
else: |
|||
try: |
|||
current = json.loads(raw) |
|||
except json.JSONDecodeError: |
|||
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; using default", file=sys.stderr) |
|||
current = default |
|||
new_value = mutator(current) |
|||
if new_value is None: |
|||
self._cli_exec(f"DELETE FROM board_config WHERE key = '{key}';") |
|||
else: |
|||
json_str = json.dumps(new_value, ensure_ascii=False, indent=2) |
|||
escaped = json_str.replace("'", "''") |
|||
self._cli_exec( |
|||
f"INSERT OR REPLACE INTO board_config (key, value) " |
|||
f"VALUES ('{key}', '{escaped}');" |
|||
) |
|||
return new_value |
|||
else: # json backend |
|||
with self._lock: |
|||
current = self._read_json_file(key) |
|||
if current is None: |
|||
current = default |
|||
new_value = mutator(current) |
|||
if new_value is None: |
|||
path = self._json_path(key) |
|||
if os.path.exists(path): |
|||
os.remove(path) |
|||
else: |
|||
self._write_json_file(key, new_value) |
|||
return new_value |
|||
|
|||
def config_exists(self, key): |
|||
self._validate_key(key) |
|||
if self._backend == "python": |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
cursor = conn.execute( |
|||
"SELECT 1 FROM board_config WHERE key = ? LIMIT 1", (key,) |
|||
) |
|||
return cursor.fetchone() is not None |
|||
finally: |
|||
conn.close() |
|||
elif self._backend == "cli": |
|||
with self._lock: |
|||
raw = self._cli_query( |
|||
f"SELECT 1 FROM board_config WHERE key = '{key}' LIMIT 1;" |
|||
) |
|||
return bool(raw) |
|||
else: |
|||
with self._lock: |
|||
return os.path.exists(self._json_path(key)) |
|||
|
|||
# ─── Schema meta (migration flags) ────────────────────────── |
|||
|
|||
def _ensure_schema_meta_table(self) -> None: |
|||
# _lock is acquired here independently; callers must NOT hold the |
|||
# lock when calling this method (sequential acquisition only). |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
conn.execute(""" |
|||
CREATE TABLE IF NOT EXISTS schema_meta ( |
|||
key TEXT PRIMARY KEY, |
|||
value TEXT NOT NULL, |
|||
applied_at TEXT NOT NULL |
|||
) |
|||
""") |
|||
conn.commit() |
|||
finally: |
|||
conn.close() |
|||
|
|||
def get_schema_meta(self, key: str): |
|||
self._ensure_schema_meta_table() |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
row = conn.execute( |
|||
"SELECT value FROM schema_meta WHERE key = ?", (key,) |
|||
).fetchone() |
|||
return row[0] if row else None |
|||
finally: |
|||
conn.close() |
|||
|
|||
def set_schema_meta(self, key: str, value: str) -> None: |
|||
self._ensure_schema_meta_table() |
|||
now = datetime.now(timezone.utc).isoformat() |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
conn.execute(""" |
|||
INSERT INTO schema_meta(key, value, applied_at) |
|||
VALUES (?, ?, ?) |
|||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, |
|||
applied_at=excluded.applied_at |
|||
""", (key, value, now)) |
|||
conn.commit() |
|||
finally: |
|||
conn.close() |
|||
|
|||
def get_raw_json(self, key): |
|||
self._validate_key(key) |
|||
if self._backend == "python": |
|||
with self._lock: |
|||
conn = self._connect() |
|||
try: |
|||
cursor = conn.execute( |
|||
"SELECT value FROM board_config WHERE key = ?", (key,) |
|||
) |
|||
row = cursor.fetchone() |
|||
return row["value"] if row else None |
|||
finally: |
|||
conn.close() |
|||
elif self._backend == "cli": |
|||
with self._lock: |
|||
raw = self._cli_query( |
|||
f"SELECT value FROM board_config WHERE key = '{key}';" |
|||
) |
|||
return raw if raw else None |
|||
else: |
|||
with self._lock: |
|||
data = self._read_json_file(key) |
|||
return json.dumps(data, ensure_ascii=False, indent=2) if data else None |
|||
|
|||
@ -0,0 +1,90 @@ |
|||
"""dpworldapp enum value sets — canonical = device config-reader contract. |
|||
|
|||
source-of-truth: the device config-reader contract, verified against the deployed |
|||
dpworldapp binary on .56 (string table + observed runtime behavior) and the live |
|||
working DB. Pivoted fields (rs485_parity, two/four_byte_order, idt, protocol, ai/di) |
|||
match the reader's accepted literals exactly. |
|||
|
|||
Un-pivoted fields stay consistent with the values the legacy configurator accepts, |
|||
established from operational ground truth on the working DB. |
|||
|
|||
v2 (2026-06-20): contract alignment — |
|||
- rs485_parity: "no" → "none" (reader accepts "none") |
|||
- two_byte_order: add "little swap"/"big swap" space forms (reader accepts them) |
|||
- four_byte_order: camelCase → space forms (reader accepts the space forms) |
|||
- protocol: drop "CAN" (reader has no CAN protocol; CAN is gated by can_input) |
|||
- add ai2/ai3/di2/di3 {0,1} (reader reads all 8 ai/di channels) |
|||
- can_baudrate: widen (reader imposes no allow-list) |
|||
- rs485_baudrate: widen (reader imposes no allow-list; UI already offers 4800/19200/38400/57600) |
|||
|
|||
operational notes: |
|||
- can_baudrate {100,250,500,1000}: operational ground truth (.56 DB=250, dha=500). |
|||
- heading_on/heading_imu_on/fix_mode_on: present in the reader; read directly from |
|||
board_config (no separate UI field). |
|||
- can_input: protocol_config only (absent from device_config). |
|||
""" |
|||
|
|||
DEVICE_ENUM_VALUES = { |
|||
# device_config enum fields only |
|||
# "" = the "not configured" value stored in DB for CAN bus type |
|||
"can_bus_type": frozenset({"", "extended", "standard"}), |
|||
# None = SQL NULL / "not configured"; reader imposes no baudrate allow-list |
|||
"can_baudrate": frozenset({None, 100, 125, 250, 500, 800, 1000}), |
|||
# can_input belongs to protocol_config only (absent from device_config) — |
|||
# see PROTOCOL_ENUM_VALUES["can_input"]. |
|||
"wifi_security": frozenset({"none", "wpa/wpa2"}), |
|||
"wifi_static": frozenset({"on", "off"}), |
|||
# wifi_country_code: free-form string in the schema — config_validator applies |
|||
# the 2-upper-alpha check. |
|||
|
|||
# RS485 (device_config) |
|||
"rs485_mode": frozenset({"", "half", "full"}), |
|||
# None = SQL NULL / not configured; reader imposes no baudrate allow-list |
|||
"rs485_baudrate": frozenset({None, 4800, 9600, 19200, 38400, 57600, 115200}), |
|||
# v2: canonical "none" (reader accepts "none"); aliases map to "none" in enum_normalizer |
|||
"rs485_parity": frozenset({"none", "even", "odd"}), |
|||
# rs485_databits / rs485_stopbits: Integer types only — handled in config_validator.py |
|||
} |
|||
|
|||
PROTOCOL_ENUM_VALUES = { |
|||
# protocol_config enum fields only |
|||
# v2: drop "CAN" — reader has no CAN protocol; CAN data gated by can_input |
|||
"protocol": frozenset({"NONE", "OPC_UA", "MODBUS"}), |
|||
"equipment": frozenset({"ITV", "RS", "ECH", "RTG", "RMG", "MHC", "STS"}), |
|||
"can_input": frozenset({"on", "off"}), |
|||
"speed_data": frozenset({"CAN", "GPS"}), |
|||
# odo_on: stored as "on"/"off" string — Python strict enum |
|||
# (sibling of dr_on/wifi_static/can_input) |
|||
"odo_on": frozenset({"on", "off"}), |
|||
# odo_speed/odo_direction = nested source objects — handled separately |
|||
# transport_config / security: separate board_config row, not validated here |
|||
|
|||
# Analog input level + per-channel enable flags |
|||
"analog_input_level": frozenset({"2", "4", "6"}), |
|||
# ai/di channels are {"0","1"}; "true"/"false" inputs are mapped in enum_normalizer |
|||
"ai0": frozenset({"0", "1"}), |
|||
"ai1": frozenset({"0", "1"}), |
|||
# v2: add ai2/ai3/di2/di3 — reader reads all 8 ai/di channels |
|||
"ai2": frozenset({"0", "1"}), |
|||
"ai3": frozenset({"0", "1"}), |
|||
"di0": frozenset({"0", "1"}), |
|||
"di1": frozenset({"0", "1"}), |
|||
"di2": frozenset({"0", "1"}), |
|||
"di3": frozenset({"0", "1"}), |
|||
|
|||
# device type / DR / byte order (protocol_config) |
|||
"dev_type": frozenset({"TIOT", "RTLS"}), |
|||
"dr_on": frozenset({"on", "off"}), |
|||
# read directly from board_config by the reader (no separate UI field) |
|||
"heading_on": frozenset({"on", "off"}), |
|||
"heading_imu_on": frozenset({"on", "off"}), |
|||
"fix_mode_on": frozenset({"on", "off"}), |
|||
# v2: space forms match the reader's accepted literals ("little swap"/"big swap") |
|||
"two_byte_order": frozenset({"little", "little swap", "big swap", "big"}), |
|||
"four_byte_order": frozenset({"little", "little swap", "big swap", "big"}), |
|||
} |
|||
|
|||
PROTOCOL_ALIASES = {"OPC-UA": "OPC_UA"} |
|||
|
|||
# odo_speed.source / odo_direction.source accepted values |
|||
ODO_SOURCE_VALUES = frozenset({"can", "hw"}) |
|||
@ -0,0 +1,196 @@ |
|||
""" |
|||
v1.5.5 — dpworldapp telemetry stream parser (TCP 8989). |
|||
|
|||
dpworldapp 가 `127.0.0.1:8989` 에 LISTEN 하며 connection 직후 헤더 스냅샷 8 frame |
|||
(static info) + 이후 실시간 telemetry stream 을 push 한다. 우리는 connect → |
|||
헤더 frame parse → disconnect 의 **stateless query 패턴** 으로 정적 정보 (firmware |
|||
version / MAC / IP) 만 수집한다. |
|||
|
|||
발견 경위 (2026-06-09): |
|||
- 6-byte command flow를 TCP telemetry 동작과 비교 검증 |
|||
- TCP 포트 inventory 결과 `.54` dpworldapp 가 8989 + 8990 두 포트만 listen |
|||
(8990 = firmware OTA, 이미 알려진 채널) |
|||
- 8989 에 6-byte ReadAll/GetFwVersion/GetMacAddress 모두 송신 시 같은 응답 → |
|||
명령 분기 없이 connection trigger 만으로 8 frame 스냅샷 + 실시간 stream |
|||
- 6-byte 명령은 사실상 무시되지만 (혹시 server-side validation 가능성) connect |
|||
직후 송신해 두는 게 안전 |
|||
|
|||
응답 frame 예 (timestamp prefix 가 붙음): |
|||
2026-06-09 04:16:10: #[FWVER] ITV-01-2026052801\r\n |
|||
2026-06-09 04:16:10: #[GNSS_FW_VER] HPS 1.4\r\n |
|||
2026-06-09 04:16:10: #[WIFI_MAC] bc2a33acf9f8\r\n |
|||
2026-06-09 04:16:10: #[ETH0_MAC] 92d478cd597b\r\n |
|||
2026-06-09 04:16:10: #[ETH1_MAC] 2e6f303d719f\r\n |
|||
2026-06-09 04:16:10: #[WIFI_IP] 10.227.231.38\r\n |
|||
2026-06-09 04:16:10: #[ETH0_IP] 192.168.55.54\r\n |
|||
2026-06-09 04:16:10: #[ETH1_IP] 192.168.40.20\r\n |
|||
... (이후 실시간 [AI]/[DI]/[GNSS]/[OPC-UA]/[MSG_TURF]/[SERVER] stream) |
|||
|
|||
Frame format: `<arbitrary prefix>#[KEY] <value>\r\n` |
|||
""" |
|||
import re |
|||
import socket |
|||
import threading |
|||
import time |
|||
|
|||
DEFAULT_HOST = "127.0.0.1" |
|||
DEFAULT_PORT = 8989 |
|||
# v1.5.5.1: timeout 2s/3s → 1s. 헤더 8 frame 수신은 실측 100ms 이내라 충분. |
|||
# 짧게 잡아 system_status response latency 단축 + dpworldapp 부담 감소. |
|||
DEFAULT_TIMEOUT_S = 1.0 |
|||
# v1.5.5.1: TTL cache — 매 system_status 호출마다 8989 connect 회피. |
|||
# Dashboard 폴링 10s 간격에 30s cache 면 평균 3회당 1회만 실 query → dpworldapp 부담 1/3. |
|||
DEFAULT_CACHE_TTL_S = 30.0 |
|||
|
|||
# 우리가 수집하는 헤더 frame key 들 (8 static info fields) |
|||
HEADER_KEYS = ( |
|||
"FWVER", |
|||
"GNSS_FW_VER", |
|||
"WIFI_MAC", |
|||
"ETH0_MAC", |
|||
"ETH1_MAC", |
|||
"WIFI_IP", |
|||
"ETH0_IP", |
|||
"ETH1_IP", |
|||
) |
|||
|
|||
# 6-byte ReadAll command (frame start = '#', Type='A', Cmd='A', CRC=0, Term='*', End=0xFE). |
|||
# dpworldapp 가 명령 분기 안 함을 verify 했지만 connect 시 send 해 두는 게 안전 (혹시 |
|||
# server-side validation 도입 시 호환). |
|||
_TRIGGER_CMD = bytes([0x23, 0x41, 0x41, 0x00, 0x2A, 0xFE]) |
|||
|
|||
# Frame parser: timestamp / log prefix 가 있어도 `#[KEY] value` 패턴만 추출 |
|||
_FRAME_PATTERN = re.compile( |
|||
rb"#\[(?P<key>[A-Z0-9_]+)\]\s+(?P<value>[^\r\n]+)" |
|||
) |
|||
|
|||
|
|||
def query_telemetry(host=DEFAULT_HOST, port=DEFAULT_PORT, timeout_s=DEFAULT_TIMEOUT_S): |
|||
"""dpworldapp telemetry stream 에 connect 해 헤더 frame 수집. |
|||
|
|||
Args: |
|||
host: dpworldapp 호스트. 디바이스 안에서는 127.0.0.1. |
|||
port: 8989 (telemetry stream channel). |
|||
timeout_s: 전체 query timeout. 모든 헤더 frame 수신 시 조기 종료. |
|||
|
|||
Returns: |
|||
dict: {key: value} 로 HEADER_KEYS 중 발견된 것만. connection 실패 / timeout / |
|||
parse 실패 시 빈 dict. |
|||
|
|||
Raises: |
|||
없음 — 모든 예외는 빈 dict 로 흡수. Dashboard 표시용이라 실패 시 silent. |
|||
""" |
|||
result = {} |
|||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
|||
s.settimeout(timeout_s) |
|||
buf = b"" |
|||
try: |
|||
s.connect((host, port)) |
|||
# 6-byte trigger. dpworldapp 가 명령 분기 안 하지만 호환 위해 send. |
|||
s.sendall(_TRIGGER_CMD) |
|||
|
|||
deadline = time.monotonic() + timeout_s |
|||
while time.monotonic() < deadline: |
|||
remaining = deadline - time.monotonic() |
|||
if remaining <= 0.0: |
|||
break |
|||
s.settimeout(min(0.5, remaining)) |
|||
try: |
|||
chunk = s.recv(4096) |
|||
except socket.timeout: |
|||
break |
|||
if not chunk: |
|||
break |
|||
buf += chunk |
|||
# 모든 헤더 frame 수신했으면 조기 종료 (실시간 stream 안 받음 — CPU/network 절약) |
|||
if all(k.encode("ascii") in buf for k in HEADER_KEYS): |
|||
break |
|||
except (socket.error, OSError): |
|||
return {} |
|||
finally: |
|||
try: |
|||
s.close() |
|||
except OSError: |
|||
pass |
|||
|
|||
# Frame parse — 중복 key 는 처음 발견된 값 유지 |
|||
for m in _FRAME_PATTERN.finditer(buf): |
|||
key = m.group("key").decode("ascii", errors="ignore") |
|||
if key in HEADER_KEYS and key not in result: |
|||
value = m.group("value").decode("utf-8", errors="ignore").strip() |
|||
result[key] = value |
|||
|
|||
return result |
|||
|
|||
|
|||
# ─── v1.5.5.1: TTL cache ─────────────────────────────────────────── |
|||
# Thread-safe singleton cache. system_status 가 매 polling 마다 telemetry_summary |
|||
# 를 호출해도 30s 안에는 stale 결과 반환 (dpworldapp 8989 connect 회피). |
|||
_CACHE_LOCK = threading.Lock() |
|||
_CACHE_STATE = {"data": None, "expires_at": 0.0} |
|||
|
|||
|
|||
def _cache_clear(): |
|||
"""test 전용 — cache 초기화.""" |
|||
with _CACHE_LOCK: |
|||
_CACHE_STATE["data"] = None |
|||
_CACHE_STATE["expires_at"] = 0.0 |
|||
|
|||
|
|||
def telemetry_summary(host=DEFAULT_HOST, port=DEFAULT_PORT, timeout_s=DEFAULT_TIMEOUT_S, |
|||
cache_ttl_s=DEFAULT_CACHE_TTL_S): |
|||
"""Dashboard 표시용 요약 + TTL cache. |
|||
|
|||
Returns: |
|||
dict: 다음 구조 (모든 호출이 같은 dict reference 가 아닌 fresh copy 반환) |
|||
{ |
|||
"available": bool, # query 성공 여부 |
|||
"firmware": str|None, # [FWVER] |
|||
"gnss_firmware": str|None, # [GNSS_FW_VER] |
|||
"wifi_mac": str|None, |
|||
"eth0_mac": str|None, |
|||
"eth1_mac": str|None, |
|||
"wifi_ip": str|None, |
|||
"eth0_ip": str|None, |
|||
"eth1_ip": str|None, |
|||
"cached": bool, # cache hit 인 경우 True |
|||
"cache_age_s": float, # cache 적중 시 캐시된 시간 경과 (sec) |
|||
} |
|||
|
|||
Cache 동작: |
|||
- cache_ttl_s=0 으로 설정하면 cache bypass (매번 fresh query) |
|||
- 실패한 결과 (available=False) 도 cache 됨 — connection refused 가 30초 동안 |
|||
반복 query 방지. 다만 짧은 retry 가 필요하면 ttl 명시적 단축 가능. |
|||
""" |
|||
now = time.monotonic() |
|||
# Fast path: cache hit |
|||
if cache_ttl_s > 0: |
|||
with _CACHE_LOCK: |
|||
entry = _CACHE_STATE["data"] |
|||
expires = _CACHE_STATE["expires_at"] |
|||
if entry is not None and expires > now: |
|||
cached = dict(entry) |
|||
cached["cached"] = True |
|||
cached["cache_age_s"] = max(0.0, cache_ttl_s - (expires - now)) |
|||
return cached |
|||
|
|||
# Cache miss — actual query |
|||
raw = query_telemetry(host=host, port=port, timeout_s=timeout_s) |
|||
fresh = { |
|||
"available": bool(raw), |
|||
"firmware": raw.get("FWVER"), |
|||
"gnss_firmware": raw.get("GNSS_FW_VER"), |
|||
"wifi_mac": raw.get("WIFI_MAC"), |
|||
"eth0_mac": raw.get("ETH0_MAC"), |
|||
"eth1_mac": raw.get("ETH1_MAC"), |
|||
"wifi_ip": raw.get("WIFI_IP"), |
|||
"eth0_ip": raw.get("ETH0_IP"), |
|||
"eth1_ip": raw.get("ETH1_IP"), |
|||
"cached": False, |
|||
"cache_age_s": 0.0, |
|||
} |
|||
if cache_ttl_s > 0: |
|||
with _CACHE_LOCK: |
|||
_CACHE_STATE["data"] = dict(fresh) |
|||
_CACHE_STATE["expires_at"] = now + cache_ttl_s |
|||
return fresh |
|||
@ -0,0 +1,171 @@ |
|||
"""Input normalization for enum values — v2 canonical = device config-reader contract. |
|||
|
|||
Maps external inputs (case variants, camelCase aliases, legacy forms) to the |
|||
device config-reader canonical form. Semantic-equivalence guessing is risky and |
|||
intentionally excluded. |
|||
|
|||
v2 (2026-06-20): contract alignment — |
|||
- rs485_parity: all "no"/"n"/"NONE"/etc aliases → "none" (new canonical) |
|||
- four_byte_order: camelCase/underscore/upper → space forms ("little swap"/"big swap") |
|||
- two_byte_order: same swap aliases added |
|||
- ai2/ai3/di2/di3: true/false → "1"/"0" (mirror ai0/ai1) |
|||
- normalize_device_input: rs485_baudrate string→int coercion added |
|||
- normalize_device_input: wifi_country_code strip+upper when 2-letter string |
|||
|
|||
board_config key ownership policy (v1.4.1 stage-1): odo_*, transport_config, security |
|||
keys are not validated here — they are owned by separate components / board_config |
|||
rows. enum_normalizer only performs aliasing; ownership policy lives in dpworldapp_enums.py. |
|||
""" |
|||
|
|||
_INPUT_ALIASES_DEVICE = { |
|||
"wifi_static": {"ON": "on", "OFF": "off"}, |
|||
# can_input is a protocol_config key (absent from device_config) — alias removed here. |
|||
# _INPUT_ALIASES_PROTOCOL["can_input"] is kept. |
|||
# wifi_security: case-equivalent only. "None" (title-case) absorbs Python str(None) |
|||
# or common external-tool patterns. Semantic guessing (WEP/WPA3 → wpa/wpa2 etc.) |
|||
# is intentionally excluded — wrong-conversion risk. ([[memory:v1-4-0-3-enum-alignment]]) |
|||
"wifi_security": { |
|||
"NONE": "none", "None": "none", |
|||
"WPA/WPA2": "wpa/wpa2", "wpa/WPA2": "wpa/wpa2", "WPA/wpa2": "wpa/wpa2", |
|||
}, |
|||
"can_bus_type": { |
|||
"NONE": "", "None": "", |
|||
"EXTENDED": "extended", "Extended": "extended", |
|||
"STANDARD": "standard", "Standard": "standard", |
|||
}, |
|||
# H1 RS485 case alias |
|||
"rs485_mode": { |
|||
"HALF": "half", "Half": "half", |
|||
"FULL": "full", "Full": "full", |
|||
}, |
|||
# v2: canonical "none"; all no/n/NONE variants → "none" |
|||
"rs485_parity": { |
|||
"NO": "none", "No": "none", "no": "none", |
|||
"NONE": "none", "None": "none", |
|||
"N": "none", "n": "none", |
|||
"EVEN": "even", "Even": "even", |
|||
"ODD": "odd", "Odd": "odd", |
|||
}, |
|||
} |
|||
|
|||
_INPUT_ALIASES_PROTOCOL = { |
|||
"can_input": {"ON": "on", "OFF": "off"}, |
|||
"odo_on": {"ON": "on", "OFF": "off"}, |
|||
# H2 analog ports "true"/"false" → "1"/"0" |
|||
"ai0": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
"ai1": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
# v2: ai2/ai3/di2/di3 — same pattern as ai0/ai1/di0/di1 |
|||
"ai2": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
"ai3": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
"di0": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
"di1": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
"di2": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
"di3": {"true": "1", "True": "1", "TRUE": "1", "false": "0", "False": "0", "FALSE": "0"}, |
|||
# H3 case alias |
|||
"dr_on": {"ON": "on", "OFF": "off"}, |
|||
# v1.4.6.3 H2: sibling pattern with dr_on |
|||
"heading_on": {"ON": "on", "OFF": "off"}, |
|||
"heading_imu_on": {"ON": "on", "OFF": "off"}, |
|||
"fix_mode_on": {"ON": "on", "OFF": "off"}, |
|||
# v2: space forms canonical; camelCase/underscore/upper all map to space form |
|||
"two_byte_order": { |
|||
"BIG": "big", "Big": "big", |
|||
"LITTLE": "little", "Little": "little", |
|||
# swap aliases → space canonical |
|||
"littleSwap": "little swap", "LITTLE_SWAP": "little swap", |
|||
"littleswap": "little swap", "little_swap": "little swap", |
|||
"bigSwap": "big swap", "BIG_SWAP": "big swap", |
|||
"bigswap": "big swap", "big_swap": "big swap", |
|||
# uppercase pass-through helpers |
|||
"BIG SWAP": "big swap", "LITTLE SWAP": "little swap", |
|||
}, |
|||
# v2: space forms canonical; camelCase/underscore/upper all map to space form |
|||
"four_byte_order": { |
|||
"BIG": "big", "Big": "big", |
|||
"LITTLE": "little", "Little": "little", |
|||
# swap aliases → space canonical |
|||
"littleSwap": "little swap", "LITTLE_SWAP": "little swap", |
|||
"littleswap": "little swap", "little_swap": "little swap", |
|||
"bigSwap": "big swap", "BIG_SWAP": "big swap", |
|||
"bigswap": "big swap", "big_swap": "big swap", |
|||
# uppercase pass-through helpers |
|||
"BIG SWAP": "big swap", "LITTLE SWAP": "little swap", |
|||
}, |
|||
} |
|||
|
|||
# nested odo_speed / odo_direction source alias (case-only) |
|||
_ODO_SOURCE_ALIASES = { |
|||
"CAN": "can", "Can": "can", |
|||
"HW": "hw", "Hw": "hw", |
|||
} |
|||
|
|||
|
|||
def _normalize_odo_field(obj): |
|||
"""Apply case-only alias to the source field of odo_speed / odo_direction.""" |
|||
if not isinstance(obj, dict): |
|||
return obj |
|||
result = dict(obj) |
|||
src = result.get("source") |
|||
if isinstance(src, str) and src in _ODO_SOURCE_ALIASES: |
|||
result["source"] = _ODO_SOURCE_ALIASES[src] |
|||
return result |
|||
|
|||
|
|||
def normalize_device_input(cfg): |
|||
"""alias + baudrate string->int + wifi_country_code strip/upper. No semantic guessing.""" |
|||
if not isinstance(cfg, dict): |
|||
return cfg |
|||
result = dict(cfg) |
|||
for key, aliases in _INPUT_ALIASES_DEVICE.items(): |
|||
v = result.get(key) |
|||
if isinstance(v, str) and v in aliases: |
|||
result[key] = aliases[v] |
|||
# can_baudrate type coercion |
|||
v = result.get("can_baudrate") |
|||
if isinstance(v, str): |
|||
try: |
|||
result["can_baudrate"] = int(v) |
|||
except ValueError: |
|||
pass |
|||
# v2: rs485_baudrate string→int coercion (mirror can_baudrate) |
|||
v = result.get("rs485_baudrate") |
|||
if isinstance(v, str): |
|||
try: |
|||
result["rs485_baudrate"] = int(v) |
|||
except ValueError: |
|||
pass |
|||
# v2: wifi_country_code strip+uppercase (reader trims + uppercases) |
|||
v = result.get("wifi_country_code") |
|||
if isinstance(v, str): |
|||
stripped = v.strip() |
|||
if len(stripped) == 2 and stripped.isalpha(): |
|||
result["wifi_country_code"] = stripped.upper() |
|||
return result |
|||
|
|||
|
|||
def normalize_protocol_input(cfg): |
|||
"""alias + byte_order lowercase fallback for protocol enum inputs.""" |
|||
if not isinstance(cfg, dict): |
|||
return cfg |
|||
result = dict(cfg) |
|||
# v1.9.1 I-2: protocol value alias — persists canonical form to DB |
|||
from dpworldapp_enums import PROTOCOL_ALIASES |
|||
p = result.get("protocol") |
|||
if isinstance(p, str) and p in PROTOCOL_ALIASES: |
|||
result["protocol"] = PROTOCOL_ALIASES[p] |
|||
for key, aliases in _INPUT_ALIASES_PROTOCOL.items(): |
|||
v = result.get(key) |
|||
if isinstance(v, str) and v in aliases: |
|||
result[key] = aliases[v] |
|||
# v2: after alias map, lowercase any remaining two/four_byte_order strings |
|||
# (reader is case-insensitive; handles "BIG SWAP" → "big swap") |
|||
for bo_key in ("two_byte_order", "four_byte_order"): |
|||
v = result.get(bo_key) |
|||
if isinstance(v, str): |
|||
result[bo_key] = v.lower() |
|||
# v1.4.3: nested odo fields source alias |
|||
if "odo_speed" in result: |
|||
result["odo_speed"] = _normalize_odo_field(result["odo_speed"]) |
|||
if "odo_direction" in result: |
|||
result["odo_direction"] = _normalize_odo_field(result["odo_direction"]) |
|||
return result |
|||
@ -0,0 +1 @@ |
|||
"""Firmware OTA module (merged from webconfig_fw, v1.5.0 Phase 4a).""" |
|||
@ -0,0 +1,216 @@ |
|||
"""Config backup + factory-default-reseed detection + restore (sqlite board_config).""" |
|||
import json |
|||
import os |
|||
import shutil |
|||
import sqlite3 |
|||
import time |
|||
|
|||
CONFIG_KEYS = ("device_config", "protocol_config") |
|||
|
|||
|
|||
def _read(db_path, key): |
|||
con = sqlite3.connect(db_path, timeout=5.0) |
|||
try: |
|||
row = con.execute("SELECT value FROM board_config WHERE key=?", (key,)).fetchone() |
|||
return row[0] if row else None |
|||
finally: |
|||
con.close() |
|||
|
|||
|
|||
def _write(db_path, key, value): |
|||
con = sqlite3.connect(db_path, timeout=5.0) |
|||
try: |
|||
con.execute( |
|||
"INSERT INTO board_config(key,value) VALUES(?,?) " |
|||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", |
|||
(key, value), |
|||
) |
|||
con.commit() |
|||
finally: |
|||
con.close() |
|||
|
|||
|
|||
def _write_atomic(db_path, key_value_pairs): |
|||
"""Write multiple board_config keys in a single SQLite transaction. |
|||
|
|||
v1.5.4.4 STAB-1 fix: detect_and_restore가 device_config + protocol_config을 |
|||
별도 connection / commit으로 write하던 결함을 차단. 단일 connection + |
|||
명시적 BEGIN IMMEDIATE로 두 키를 묶어 원자성을 보장 — 두 번째 write 실패 시 |
|||
SQLite가 첫 번째 write도 rollback 처리. DBManager.update_config 패턴과 일관. |
|||
|
|||
Args: |
|||
db_path: SQLite DB path. |
|||
key_value_pairs: iterable of (key, value) tuples. 빈 입력은 no-op. |
|||
|
|||
Raises: |
|||
sqlite3.Error: 어느 한 write라도 실패하면 rollback 후 그대로 전파. |
|||
""" |
|||
if not key_value_pairs: |
|||
return |
|||
con = sqlite3.connect(db_path, timeout=5.0, isolation_level=None) |
|||
try: |
|||
con.execute("BEGIN IMMEDIATE") |
|||
try: |
|||
for key, value in key_value_pairs: |
|||
con.execute( |
|||
"INSERT INTO board_config(key,value) VALUES(?,?) " |
|||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", |
|||
(key, value), |
|||
) |
|||
con.execute("COMMIT") |
|||
except Exception: |
|||
try: |
|||
con.execute("ROLLBACK") |
|||
except sqlite3.Error: |
|||
pass |
|||
raise |
|||
finally: |
|||
con.close() |
|||
|
|||
|
|||
def _coerce(v): |
|||
"""Type-insensitive canonical form for comparing one config value. |
|||
Note: list comparison is order-sensitive by design — a reordered list |
|||
reads as a change, which fails safe toward no-restore. |
|||
bool check must come before int because bool is a subclass of int.""" |
|||
if isinstance(v, bool): |
|||
return "true" if v else "false" |
|||
if isinstance(v, int): |
|||
return str(v) |
|||
if isinstance(v, float): |
|||
return str(int(v)) if v.is_integer() else repr(v) |
|||
if isinstance(v, (dict, list)): |
|||
return json.dumps(v, sort_keys=True, ensure_ascii=False) |
|||
if v is None: |
|||
return "" |
|||
return str(v).strip() |
|||
|
|||
|
|||
def _as_dict(text): |
|||
if text is None: |
|||
return None |
|||
try: |
|||
d = json.loads(text) |
|||
except (ValueError, TypeError): |
|||
return None |
|||
return d if isinstance(d, dict) else None |
|||
|
|||
|
|||
class ResetAssessment: |
|||
def __init__(self, is_reset, reason, reverted, user_changed, considered): |
|||
self.is_reset = is_reset |
|||
self.reason = reason |
|||
self.reverted = reverted # keys reverted to factory default |
|||
self.user_changed = user_changed # keys holding a genuinely new value |
|||
self.considered = considered # user-distinguishing keys judged |
|||
|
|||
|
|||
def assess_reset(current_value, backup_value, default_value): |
|||
"""Field-level reseed detection. current/backup/default are JSON strings. |
|||
|
|||
A "reseed" = the user's distinguishing fields were wholesale reverted to |
|||
factory defaults AND nothing looks like a genuine new edit (do-no-harm). |
|||
Robust to dpworldapp re-serializing the default differently than the file. |
|||
""" |
|||
cur, bak, dft = _as_dict(current_value), _as_dict(backup_value), _as_dict(default_value) |
|||
if cur is None or bak is None or dft is None: |
|||
return ResetAssessment(False, "missing or invalid config json", [], [], []) |
|||
considered, reverted, user_changed = [], [], [] |
|||
for k in (set(bak) & set(dft)): |
|||
bv, dv = _coerce(bak.get(k)), _coerce(dft.get(k)) |
|||
if bv == dv: |
|||
continue # not user-distinguishing (user value already equals default) |
|||
considered.append(k) |
|||
if k not in cur: |
|||
reverted.append(k) # device dropped the key on reseed -> treat as reverted |
|||
continue |
|||
cv = _coerce(cur.get(k)) |
|||
if cv == dv: |
|||
reverted.append(k) # user value lost back to factory default |
|||
elif cv != bv: |
|||
user_changed.append(k) # a genuinely new value (not backup, not default) |
|||
# else cv == bv -> user value preserved |
|||
n = len(considered) |
|||
is_reset = ( |
|||
n >= 2 |
|||
and len(user_changed) == 0 |
|||
and len(reverted) >= max(2, (n * 4 + 4) // 5) # >= ceil(0.8 * n), min 2 |
|||
) |
|||
if is_reset: |
|||
reason = "reseed: %d/%d user fields reverted to factory default, 0 new edits" % (len(reverted), n) |
|||
else: |
|||
reason = "not a clean reseed (reverted=%d, user_changed=%d, considered=%d)" % ( |
|||
len(reverted), len(user_changed), n) |
|||
return ResetAssessment(is_reset, reason, reverted, user_changed, considered) |
|||
|
|||
|
|||
def backup_config(db_path, extra_files, out_dir, stamp=None): |
|||
stamp = stamp or time.strftime("%Y%m%d-%H%M%S") |
|||
dest = os.path.join(out_dir, stamp) |
|||
os.makedirs(dest, exist_ok=True) |
|||
saved = [] |
|||
for key in CONFIG_KEYS: |
|||
val = _read(db_path, key) |
|||
if val is not None: |
|||
with open(os.path.join(dest, key + ".json"), "w", encoding="utf-8") as f: |
|||
f.write(val) |
|||
saved.append(key) |
|||
for fp in extra_files or []: |
|||
if os.path.isfile(fp): |
|||
shutil.copy2(fp, dest) |
|||
with open(os.path.join(dest, "manifest.json"), "w", encoding="utf-8") as f: |
|||
json.dump({"stamp": stamp, "keys": saved, "db": db_path}, f) |
|||
return dest |
|||
|
|||
|
|||
def latest_backup(out_dir): |
|||
if not os.path.isdir(out_dir): |
|||
return None |
|||
subs = [os.path.join(out_dir, d) for d in os.listdir(out_dir)] |
|||
subs = [d for d in subs if os.path.isdir(d)] |
|||
return max(subs, key=os.path.getmtime, default=None) |
|||
|
|||
|
|||
class RestoreResult: |
|||
def __init__(self, restored, reason, backup=None): |
|||
self.restored = restored |
|||
self.reason = reason |
|||
self.backup = backup |
|||
|
|||
|
|||
def detect_and_restore(db_path, default_file_path, backups_dir): |
|||
current = _read(db_path, "device_config") |
|||
backup = latest_backup(backups_dir) |
|||
if backup is None: |
|||
return RestoreResult(False, "no backup available") |
|||
backup_file = os.path.join(backup, "device_config.json") |
|||
if not os.path.isfile(backup_file): |
|||
return RestoreResult(False, "backup has no device_config", backup) |
|||
with open(backup_file, "r", encoding="utf-8") as f: |
|||
backup_value = f.read() |
|||
if not os.path.isfile(default_file_path): |
|||
return RestoreResult(False, "default config file not found", backup) |
|||
with open(default_file_path, "r", encoding="utf-8") as f: |
|||
default_value = f.read() |
|||
assessment = assess_reset(current, backup_value, default_value) |
|||
if not assessment.is_reset: |
|||
return RestoreResult(False, assessment.reason, backup) |
|||
if _as_dict(backup_value) is None: |
|||
return RestoreResult(False, "backup JSON invalid", backup) |
|||
# Validate protocol_config BEFORE writing anything — prevent a half-write. |
|||
proto = os.path.join(backup, "protocol_config.json") |
|||
proto_value = None |
|||
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: |
|||
return RestoreResult(False, "backup protocol_config invalid", backup) |
|||
# Both validated — now write atomically. |
|||
# v1.5.4.4 STAB-1 fix: 이전엔 두 _write 호출이 별도 connection / commit이라 |
|||
# 두 번째 write 실패 시 device_config만 복원된 zombie state가 발생할 수 있었다. |
|||
# 이제 단일 BEGIN IMMEDIATE transaction으로 묶어 atomicity 보장. |
|||
pairs = [("device_config", backup_value)] |
|||
if proto_value is not None: |
|||
pairs.append(("protocol_config", proto_value)) |
|||
_write_atomic(db_path, pairs) |
|||
return RestoreResult(True, "restored from backup (%s)" % assessment.reason, backup) |
|||
@ -0,0 +1,124 @@ |
|||
"""Replay client: drives the dpworldapp FW-MMI channel exactly like DPWMMI.""" |
|||
import os |
|||
import socket |
|||
import time |
|||
|
|||
from . import protocol |
|||
|
|||
|
|||
class StepResult: |
|||
def __init__(self, ok, signature, status, detail="", bytes_sent=0): |
|||
self.ok = ok |
|||
self.signature = signature |
|||
self.status = status # success|failure|timeout|closed|error|success_pending|empty_file|socket_error |
|||
self.detail = detail |
|||
self.bytes_sent = bytes_sent |
|||
|
|||
|
|||
class Outcome: |
|||
def __init__(self, ok, results, failed_step=None): |
|||
self.ok = ok |
|||
self.results = results |
|||
self.failed_step = failed_step |
|||
|
|||
|
|||
class FirmwareStep: |
|||
def __init__(self, signature, path, ack_timeout_ms, is_message_only=False): |
|||
self.signature = signature |
|||
self.path = path |
|||
self.ack_timeout_ms = ack_timeout_ms |
|||
self.is_message_only = is_message_only |
|||
|
|||
|
|||
def _read_ack(sock, timeout_ms): |
|||
end = time.monotonic() + timeout_ms / 1000.0 |
|||
buf = bytearray() |
|||
while True: |
|||
remaining = end - time.monotonic() |
|||
if remaining <= 0: |
|||
return "timeout", buf.decode("ascii", "replace") |
|||
sock.settimeout(remaining) |
|||
try: |
|||
chunk = sock.recv(256) |
|||
except socket.timeout: |
|||
return "timeout", buf.decode("ascii", "replace") |
|||
except OSError: |
|||
return "closed", buf.decode("ascii", "replace") |
|||
if not chunk: |
|||
return "closed", buf.decode("ascii", "replace") |
|||
buf.extend(chunk) |
|||
text = buf.decode("ascii", "replace") |
|||
verdict = protocol.classify_ack(text) |
|||
if verdict in ("success", "failure"): |
|||
return verdict, text |
|||
# Buffer cap MUST run every iteration, before the 'unknown' continue, |
|||
# otherwise an endless stream of non-terminal bytes grows buf without |
|||
# bound (the cap was previously dead code after the continue). |
|||
if len(buf) > 65536: |
|||
return "error", text |
|||
# 'unknown' means the buffer hasn't yet accumulated a recognisable |
|||
# terminal token — keep reading (timeout / socket-close / buffer-cap |
|||
# are the only other exits from the loop). |
|||
if verdict == "unknown": |
|||
continue |
|||
|
|||
|
|||
def send_file(sock, signature, path, ack_timeout_ms, progress_cb=None): |
|||
sock.setblocking(True) |
|||
size = os.path.getsize(path) |
|||
if size <= 0: |
|||
return StepResult(False, signature, "empty_file", "refusing to send 0-byte file: %s" % path, 0) |
|||
sent = 0 |
|||
try: |
|||
sock.sendall(protocol.build_file_header(signature, size)) |
|||
with open(path, "rb") as f: |
|||
while True: |
|||
chunk = f.read(protocol.CHUNK) |
|||
if not chunk: |
|||
break |
|||
sock.sendall(chunk) |
|||
sent += len(chunk) |
|||
if progress_cb: |
|||
progress_cb(signature, sent, size) |
|||
except OSError as e: |
|||
return StepResult(False, signature, "socket_error", str(e), sent) |
|||
status, detail = _read_ack(sock, ack_timeout_ms) |
|||
return StepResult(status == "success", signature, status, detail, sent) |
|||
|
|||
|
|||
def send_message(sock, signature, ack_timeout_ms): |
|||
sock.setblocking(True) |
|||
try: |
|||
sock.sendall(protocol.build_message_frame(signature)) |
|||
except OSError as e: |
|||
return StepResult(False, signature, "socket_error", str(e), 0) |
|||
status, detail = _read_ack(sock, ack_timeout_ms) |
|||
return StepResult(status == "success", signature, status, detail, 0) |
|||
|
|||
|
|||
def run_update(host, port, steps, progress_cb=None, connect_timeout=10.0): |
|||
results = [] |
|||
# Fix 4 (fw_client): initialise sock to None so the finally block's |
|||
# sock.close() call does not raise NameError when create_connection itself |
|||
# raises (e.g. connection refused / network unreachable). Without this a |
|||
# real OSError is replaced by an unrelated NameError, masking the root cause. |
|||
sock = None |
|||
try: |
|||
sock = socket.create_connection((host, port), timeout=connect_timeout) |
|||
for step in steps: |
|||
if step.is_message_only: |
|||
r = send_message(sock, step.signature, step.ack_timeout_ms) |
|||
else: |
|||
r = send_file(sock, step.signature, step.path, step.ack_timeout_ms, progress_cb) |
|||
results.append(r) |
|||
if not r.ok: |
|||
# The CPU commit dropping the link is expected (device reboots). |
|||
if step.signature == protocol.COMMIT_SIGNATURE and r.status in ("closed", "timeout"): |
|||
r.ok = True |
|||
r.status = "success_pending" |
|||
continue |
|||
return Outcome(False, results, failed_step=step.signature) |
|||
finally: |
|||
if sock is not None: |
|||
sock.close() |
|||
return Outcome(True, results) |
|||
@ -0,0 +1,602 @@ |
|||
"""Stateful firmware-update orchestration for the production web UI. |
|||
|
|||
Wraps the *validated* dpw_fw_tool engine (fw_client / staging / config_safety / |
|||
protocol) and exposes a single rich status dict that drives all of the UI's |
|||
graphical state (stepper, per-component bars, preflight, reboot watchdog). |
|||
|
|||
Pure stdlib. Thread-safe. No change to the tested engine modules — this is a |
|||
fresh orchestration layer so the standalone "v1" webui keeps passing its tests. |
|||
|
|||
Merge target: NEW_Web_Configurator/src/firmware/fw_controller.py |
|||
""" |
|||
import json |
|||
import os |
|||
import shlex |
|||
import shutil |
|||
import socket |
|||
import subprocess |
|||
import sys |
|||
import threading |
|||
import time |
|||
import zipfile |
|||
|
|||
from firmware import config_safety, fw_client, protocol, staging |
|||
|
|||
# firmware component identification by file extension |
|||
_EXT_TO_ROLE = { |
|||
".rom": ("bootloader", "BTL"), |
|||
".img": ("kernel", "KRN"), |
|||
".ext4": ("rootfs", "RTF"), |
|||
".dtb": ("dtb", "DTB"), |
|||
} |
|||
_ALL_SIGS = ("BTL", "KRN", "RTF", "DTB") |
|||
_ORDER = {s: i for i, s in enumerate(_ALL_SIGS)} |
|||
_MB = 1024 * 1024 |
|||
_SIDECAR = ".fw_build_dates.json" # name -> {"sha256":..., "build_date":...} (survives restart/recovery) |
|||
# zip-bomb / disk-fill guards (largest real component, rootfs, is ~0.9 GB) |
|||
_MAX_FILE = 1200 * _MB # per-component uncompressed cap |
|||
_MAX_UNCOMPRESSED = 1536 * _MB # total recognized-component uncompressed cap |
|||
|
|||
|
|||
def _zip_build_date(info): |
|||
"""Image build date from a ZIP entry's mtime. None if unset (epoch 1980).""" |
|||
dt = getattr(info, "date_time", None) |
|||
if not dt or dt[0] < 1981: |
|||
return None |
|||
return "%04d-%02d-%02d" % (dt[0], dt[1], dt[2]) |
|||
|
|||
# Lifecycle phases shown in the UI stepper (order matters). |
|||
_PHASES = [ |
|||
("upload", "Upload"), |
|||
("verify", "Verify"), |
|||
("preflight", "Pre-flight"), |
|||
("backup", "Backup"), |
|||
("flash", "Flash"), |
|||
("commit", "Commit"), |
|||
("reboot", "Reboot"), |
|||
("config", "Config check"), |
|||
] |
|||
|
|||
|
|||
def _now(): |
|||
"""Monotonic seconds — safe for elapsed math (no wall-clock dependency).""" |
|||
return time.monotonic() |
|||
|
|||
|
|||
class FirmwareController: |
|||
def __init__(self, fw_host="127.0.0.1", fw_port=8990, |
|||
staging_dir="/opt/fw_staging", backups_dir="/opt/config_backups", |
|||
db_path="/home/root/db/dynamic_data.db", |
|||
default_file="/var/www/html/config_device.json", |
|||
restart_cmd="systemctl restart dpworldapp.service", |
|||
run_update=None): |
|||
# injectable flash runner (defaults to the real wire client) — lets the |
|||
# flash state machine be unit-tested without a device. |
|||
self._run_update = run_update or fw_client.run_update |
|||
# normalise restart_cmd to an argv list so it is always run without a |
|||
# shell (no injection surface), accepting either a string or a list. |
|||
restart_argv = (list(restart_cmd) if isinstance(restart_cmd, (list, tuple)) |
|||
else shlex.split(restart_cmd)) |
|||
if not restart_argv or not all(isinstance(a, str) and a for a in restart_argv): |
|||
raise ValueError("restart_cmd must yield a non-empty list of non-empty strings") |
|||
self.cfg = dict(fw_host=fw_host, fw_port=fw_port, staging_dir=staging_dir, |
|||
backups_dir=backups_dir, db_path=db_path, |
|||
default_file=default_file, restart_cmd=restart_cmd, |
|||
restart_argv=restart_argv) |
|||
self._lock = threading.RLock() |
|||
self._thread = None |
|||
self._components = [] # list of component dicts (with sent/pct/state) |
|||
self._phase_state = {} # key -> 'pending'|'active'|'done'|'error' |
|||
self._phase_detail = {} # key -> str |
|||
self._state = "idle" |
|||
self._message = "" |
|||
self._error = None |
|||
self._backup = None |
|||
self._slot = {"before": self._detect_slot(), "after": None} |
|||
self._t0 = None # flash start (monotonic) |
|||
self._reset_phases() |
|||
# v1.5.2 B5 (M10/M15): defer _recover_staging to a background daemon thread so |
|||
# sha256 hash of large staged files does not block server startup on TCC8030. |
|||
threading.Thread(target=self._recover_staging, daemon=True).start() |
|||
|
|||
# ---- internal helpers ---------------------------------------------- |
|||
def _recover_staging(self): |
|||
"""If staging_dir already holds recognized components (e.g. the service |
|||
was restarted after an upload), rebuild the staged component list so the |
|||
operator does not have to upload again. One-time sha256 cost on startup.""" |
|||
sdir = self.cfg["staging_dir"] |
|||
if not os.path.isdir(sdir): |
|||
return |
|||
try: |
|||
with open(os.path.join(sdir, _SIDECAR)) as f: |
|||
build_dates = json.load(f) |
|||
except (OSError, ValueError): |
|||
build_dates = {} |
|||
found = [] |
|||
for name in sorted(os.listdir(sdir)): |
|||
path = os.path.join(sdir, name) |
|||
if not os.path.isfile(path): |
|||
continue |
|||
ext = os.path.splitext(name)[1].lower() |
|||
if ext not in _EXT_TO_ROLE: |
|||
continue |
|||
role, sig = _EXT_TO_ROLE[ext] |
|||
try: |
|||
actual_sha = staging.sha256_of(path) |
|||
except OSError: |
|||
continue |
|||
# Fix 3: reject components whose on-disk sha256 does not match the |
|||
# recorded metadata — this catches partial/corrupt files left behind |
|||
# by a mid-extraction failure (disk-full, power-loss, etc.). A |
|||
# component with no metadata entry at all is also rejected: we have |
|||
# no baseline to verify against so we cannot trust it. |
|||
meta = build_dates.get(name) # build_dates loaded from sidecar above |
|||
if isinstance(meta, dict): |
|||
# New incremental sidecar format: {"sha256": "...", "build_date": "..."} |
|||
recorded_sha = meta.get("sha256") |
|||
build_date = meta.get("build_date") |
|||
else: |
|||
# Legacy sidecar format stored build_date string directly (or None). |
|||
# No sha256 was recorded — cannot verify; reject to be safe. |
|||
recorded_sha = None |
|||
build_date = meta # may be a date string or None |
|||
if recorded_sha is None or actual_sha != recorded_sha: |
|||
# Partial / unverifiable file — do not accept it as staged. |
|||
# I-1: log so a rejection (legacy pre-v1.11.5 sidecar OR partial/corrupt |
|||
# file) is NOT silent — otherwise recovery yields an empty set with no |
|||
# explanation and the operator can't tell why a re-upload is needed. |
|||
reason = ("no recorded sha256 (legacy/partial sidecar)" |
|||
if recorded_sha is None else "sha256 mismatch (partial/corrupt)") |
|||
sys.stderr.write( |
|||
"[fw-recover] rejecting staged component %r: %s — re-upload required\n" |
|||
% (name, reason)) |
|||
# Remove it so it doesn't linger on disk. |
|||
try: |
|||
os.remove(path) |
|||
except OSError: |
|||
pass |
|||
continue |
|||
try: |
|||
found.append({"role": role, "signature": sig, "name": name, |
|||
"path": path, "size": os.path.getsize(path), |
|||
"sha256": actual_sha, |
|||
"build_date": build_date, |
|||
"sent": 0, "pct": 0, "state": "pending"}) |
|||
except OSError: |
|||
continue |
|||
if not found: |
|||
return |
|||
found.sort(key=lambda c: _ORDER.get(c["signature"], 9)) |
|||
with self._lock: |
|||
# v1.5.4.5 FW-1/CONC-2 fix: 신규 upload/flash 가 이미 진행 중이면 recovery |
|||
# 결과는 stale — silent skip. 이전엔 background daemon thread 의 _recover_staging |
|||
# 가 disk scan 동안 사용자가 신규 upload 를 시작해도 _components/_state 를 |
|||
# stale recovery 값으로 덮어써 의도하지 않은 firmware 가 flash 될 path 존재. |
|||
if self._state != "idle": |
|||
return |
|||
self._components = found |
|||
self._state = "staged" |
|||
self._set_phase("upload", "done", "recovered %d staged components" % len(found)) |
|||
self._set_phase("verify", "done", "sha256 verified") |
|||
self._message = "recovered %d staged components from disk" % len(found) |
|||
|
|||
def _reset_phases(self): |
|||
self._phase_state = {k: "pending" for k, _ in _PHASES} |
|||
self._phase_detail = {k: "" for k, _ in _PHASES} |
|||
|
|||
def _set_phase(self, key, state, detail=None): |
|||
with self._lock: |
|||
self._phase_state[key] = state |
|||
if detail is not None: |
|||
self._phase_detail[key] = detail |
|||
|
|||
@staticmethod |
|||
def _tier_for(state): |
|||
return {"done": "ok", "active": "warn", "error": "error"}.get(state, "na") |
|||
|
|||
def _detect_slot(self): |
|||
"""Best-effort active A/B slot from the rootfs device (p5=A, p6=B).""" |
|||
try: |
|||
with open("/proc/cmdline") as f: |
|||
cmd = f.read() |
|||
for tok in cmd.split(): |
|||
if tok.startswith("root="): |
|||
dev = tok.split("=", 1)[1] |
|||
if dev.endswith("p5") or dev.endswith("5"): |
|||
return "A" |
|||
if dev.endswith("p6") or dev.endswith("6"): |
|||
return "B" |
|||
return dev.rsplit("/", 1)[-1] |
|||
except OSError: |
|||
pass |
|||
return "?" |
|||
|
|||
# ---- status snapshot ------------------------------------------------ |
|||
def status(self): |
|||
with self._lock: |
|||
sent = sum(c["sent"] for c in self._components) |
|||
total = sum(c["size"] for c in self._components) |
|||
pct = (sent * 100 // total) if total else (100 if self._state == "done" else 0) |
|||
elapsed = (_now() - self._t0) if self._t0 else 0 |
|||
active_sig = next((c["signature"] for c in self._components |
|||
if c["state"] == "active"), None) |
|||
phases = [{ |
|||
"key": k, "label": label, |
|||
"state": self._phase_state[k], |
|||
"tier": self._tier_for(self._phase_state[k]), |
|||
"detail": self._phase_detail[k], |
|||
} for k, label in _PHASES] |
|||
components = [{ |
|||
"role": c["role"], "signature": c["signature"], "name": c["name"], |
|||
"size": c["size"], "sha256": c["sha256"], |
|||
"build_date": c.get("build_date"), |
|||
"sent": c["sent"], "pct": c["pct"], "state": c["state"], |
|||
} for c in self._components] |
|||
return { |
|||
"state": self._state, |
|||
"phases": phases, |
|||
"components": components, |
|||
"overall": {"sent": sent, "total": total, "pct": pct, |
|||
"elapsed_s": round(elapsed, 1)}, |
|||
"active_signature": active_sig, |
|||
"backup": self._backup, |
|||
"slot": dict(self._slot), |
|||
"message": self._message, |
|||
"error": self._error, |
|||
} |
|||
|
|||
# ---- staging -------------------------------------------------------- |
|||
def stage_zip(self, zip_path): |
|||
"""Extract a firmware ZIP, auto-identify components by extension, stage |
|||
them. Returns the ordered component list (with sha256). Resets state. |
|||
|
|||
v1.5.2 B1 (H4 TOCTOU fix): claim 'staging' state under lock before the |
|||
long extraction begins so concurrent upload calls are blocked immediately. |
|||
""" |
|||
with self._lock: |
|||
# #33 fix: also block during 'rebooting' and 'done' — an upload while |
|||
# the device is rebooting (or just completed) can race the in-flight |
|||
# flash/commit lifecycle and clobber staged state. |
|||
if self._state in ("flashing", "staging", "rebooting", "done"): |
|||
raise RuntimeError("a flash or stage is in progress — cannot stage new firmware") |
|||
self._state = "staging" # claim under lock; concurrent callers blocked at this check |
|||
try: |
|||
sdir = self.cfg["staging_dir"] |
|||
staging.clear(sdir) |
|||
os.makedirs(sdir, exist_ok=True) |
|||
found = [] |
|||
build_dates = {} |
|||
total_uncompressed = 0 |
|||
seen = set() |
|||
with zipfile.ZipFile(zip_path) as zf: |
|||
for info in zf.infolist(): |
|||
if info.is_dir(): |
|||
continue |
|||
name = os.path.basename(info.filename) |
|||
ext = os.path.splitext(name)[1].lower() |
|||
if ext not in _EXT_TO_ROLE: |
|||
continue |
|||
role, sig = _EXT_TO_ROLE[ext] |
|||
# I-3 collision guard: two recognized entries sharing a basename |
|||
# would cause the second to silently overwrite the first on disk |
|||
# while both are appended to `found` with the first entry's now-stale |
|||
# sha256 → wrong-image flash / brick risk. Reject the archive before |
|||
# any colliding write occurs. |
|||
if name in seen: |
|||
raise ValueError( |
|||
"duplicate component file name in archive: %r" % name) |
|||
seen.add(name) |
|||
# zip-bomb guard: reject by declared size, cap actual bytes written, |
|||
# and confirm free space before extracting. |
|||
if info.file_size > _MAX_FILE: |
|||
raise ValueError("component '%s' too large (%d bytes)" % (name, info.file_size)) |
|||
total_uncompressed += info.file_size |
|||
if total_uncompressed > _MAX_UNCOMPRESSED: |
|||
raise ValueError("archive uncompressed size exceeds %d bytes" % _MAX_UNCOMPRESSED) |
|||
staging.check_free_space(sdir, info.file_size) |
|||
dest = os.path.join(sdir, name) |
|||
written = 0 |
|||
try: |
|||
with zf.open(info) as src, open(dest, "wb") as out: |
|||
while True: |
|||
chunk = src.read(_MB) |
|||
if not chunk: |
|||
break |
|||
written += len(chunk) |
|||
if written > _MAX_FILE: |
|||
raise ValueError("component '%s' exceeds size cap during extraction" % name) |
|||
out.write(chunk) |
|||
except Exception: |
|||
# Fix 1: delete the partial dest file before propagating so |
|||
# a disk-full / write-error mid-extraction never leaves a |
|||
# truncated component that _recover_staging could pick up and |
|||
# flash as if it were valid. |
|||
try: |
|||
os.remove(dest) |
|||
except OSError: |
|||
pass |
|||
raise |
|||
bd = _zip_build_date(info) |
|||
if bd: |
|||
build_dates[name] = bd |
|||
sha = staging.sha256_of(dest) |
|||
found.append({"role": role, "signature": sig, "name": name, |
|||
"path": dest, "size": os.path.getsize(dest), |
|||
"sha256": sha, |
|||
"build_date": bd, |
|||
"sent": 0, "pct": 0, "state": "pending"}) |
|||
# Fix 2: persist sidecar incrementally so each successfully |
|||
# extracted component has its metadata (sha256 + build_date) |
|||
# on disk immediately. A mid-loop failure therefore never |
|||
# leaves already-extracted files with no sidecar entry; the |
|||
# subsequent _recover_staging sha256 check will match them. |
|||
_sidecar_entry = {"sha256": sha} |
|||
if bd: |
|||
_sidecar_entry["build_date"] = bd |
|||
try: |
|||
_sidecar_path = os.path.join(sdir, _SIDECAR) |
|||
try: |
|||
with open(_sidecar_path) as _sf: |
|||
_cur = json.load(_sf) |
|||
except (OSError, ValueError): |
|||
_cur = {} |
|||
_cur[name] = _sidecar_entry |
|||
with open(_sidecar_path, "w") as _sf: |
|||
json.dump(_cur, _sf) |
|||
except OSError as _e: |
|||
# #27 fix: don't swallow silently — a missing sidecar entry |
|||
# makes _recover_staging reject this component on restart. |
|||
sys.stderr.write( |
|||
"[fw-stage] WARNING: sidecar write failed for %r: %s — " |
|||
"this component may be rejected on restart\n" % (name, _e)) |
|||
if not found: |
|||
raise ValueError("no recognized firmware components in archive " |
|||
"(.rom/.img/.ext4/.dtb)") |
|||
found.sort(key=lambda c: _ORDER.get(c["signature"], 9)) |
|||
missing = [s for s in _ALL_SIGS if s not in [c["signature"] for c in found]] |
|||
with self._lock: |
|||
self._components = found |
|||
self._state = "staged" |
|||
self._error = None |
|||
self._backup = None |
|||
self._t0 = None |
|||
self._reset_phases() |
|||
self._set_phase("upload", "done", "%d MB received" % ( |
|||
sum(c["size"] for c in found) // _MB)) |
|||
self._set_phase("verify", "done", "sha256 computed for %d components" % len(found)) |
|||
self._message = "staged %d components%s" % ( |
|||
len(found), |
|||
"" if not missing else " (missing: %s)" % ",".join(missing)) |
|||
return found |
|||
except Exception: |
|||
with self._lock: |
|||
self._state = "idle" |
|||
raise |
|||
|
|||
# ---- preflight ------------------------------------------------------ |
|||
def preflight(self): |
|||
"""Return a gated checklist. `ok` is True only if all CRITICAL checks pass.""" |
|||
checks = [] |
|||
|
|||
def add(key, label, ok, detail, critical=True): |
|||
checks.append({"key": key, "label": label, "pass": bool(ok), |
|||
"tier": "ok" if ok else ("error" if critical else "warn"), |
|||
"critical": critical, "detail": detail}) |
|||
|
|||
with self._lock: |
|||
comps = list(self._components) |
|||
n = len(comps) |
|||
add("staging", "Components staged", n > 0, |
|||
("%d components: %s" % (n, ", ".join(c["signature"] for c in comps))) |
|||
if n else "no firmware uploaded yet") |
|||
|
|||
# free-space headroom. The components are ALREADY staged on this fs, so we |
|||
# must NOT re-require their size (that double-counts — the bytes are |
|||
# already on disk). dpworldapp buffers the transfer in /tmp (tmpfs), not |
|||
# here, so only modest /opt headroom is needed for the pre-flash config |
|||
# backup + incidental writes. |
|||
headroom = 128 * _MB |
|||
try: |
|||
free = shutil.disk_usage(self.cfg["staging_dir"]).free |
|||
staged = sum(c["size"] for c in comps) |
|||
ok_space = free >= headroom |
|||
detail = ("%d MB free (%d MB already staged)" % (free // _MB, staged // _MB) |
|||
if comps else "%d MB free" % (free // _MB)) |
|||
add("space", "Free space (headroom)", ok_space, detail) |
|||
except OSError as e: |
|||
add("space", "Free space (headroom)", False, str(e)) |
|||
|
|||
# dpworldapp FW port reachable |
|||
ok_port, detail = self._probe_port(self.cfg["fw_host"], self.cfg["fw_port"]) |
|||
add("fw_port", "dpworldapp FW port %d" % self.cfg["fw_port"], ok_port, detail) |
|||
|
|||
# config DB present (non-critical: flash works, restore-check needs it) |
|||
db_ok = os.path.isfile(self.cfg["db_path"]) |
|||
add("db", "Config DB present", db_ok, |
|||
self.cfg["db_path"] if db_ok else "%s not found" % self.cfg["db_path"], |
|||
critical=False) |
|||
|
|||
# active slot (informational) |
|||
slot = self._detect_slot() |
|||
add("slot", "Active slot", True, "system_%s" % slot.lower() |
|||
if slot in ("A", "B") else slot, critical=False) |
|||
|
|||
ok = all(c["pass"] for c in checks if c["critical"]) |
|||
with self._lock: |
|||
self._set_phase("preflight", "done" if ok else "error", |
|||
"all checks pass" if ok else "blocked") |
|||
return {"ok": ok, "checks": checks} |
|||
|
|||
@staticmethod |
|||
def _probe_port(host, port, timeout=2.0): |
|||
try: |
|||
with socket.create_connection((host, port), timeout=timeout): |
|||
return True, "reachable" |
|||
except OSError as e: |
|||
return False, "unreachable (%s)" % e.__class__.__name__ |
|||
|
|||
# ---- flash ---------------------------------------------------------- |
|||
def _flash_worker(self, components): |
|||
try: |
|||
with self._lock: |
|||
self._t0 = _now() |
|||
self._state = "flashing" |
|||
self._message = "backing up config before flash" |
|||
self._set_phase("backup", "active", "backing up config") |
|||
backup = config_safety.backup_config(self.cfg["db_path"], [], |
|||
self.cfg["backups_dir"]) |
|||
with self._lock: |
|||
self._backup = {"path": backup, "when": _readable_dirname(backup)} |
|||
self._set_phase("backup", "done", "saved") |
|||
|
|||
steps = [fw_client.FirmwareStep(c["signature"], c["path"], |
|||
protocol.TIMEOUTS_MS[c["signature"]]) |
|||
for c in components] |
|||
steps.append(fw_client.FirmwareStep( |
|||
protocol.COMMIT_SIGNATURE, None, |
|||
protocol.TIMEOUTS_MS["CPU"], is_message_only=True)) |
|||
|
|||
with self._lock: |
|||
self._message = "flashing" |
|||
self._set_phase("flash", "active", "transferring components") |
|||
|
|||
def progress(sig, sent, total): |
|||
# send_message (CPU commit) never calls this — commit start is |
|||
# detected when the LAST file component reaches 100%. |
|||
with self._lock: |
|||
comps = self._components |
|||
for i, c in enumerate(comps): |
|||
if c["signature"] != sig: |
|||
continue |
|||
c["sent"] = sent |
|||
c["pct"] = (sent * 100 // total) if total else 100 |
|||
c["state"] = "done" if c["pct"] >= 100 else "active" |
|||
# defensively mark earlier components done |
|||
for prev in comps[:i]: |
|||
if prev["state"] != "done": |
|||
prev["state"], prev["sent"], prev["pct"] = "done", prev["size"], 100 |
|||
# last component fully sent → commit/write begins |
|||
if i == len(comps) - 1 and c["pct"] >= 100: |
|||
self._set_phase("flash", "done", "all components sent") |
|||
self._set_phase("commit", "active", "device writing slot & committing") |
|||
break |
|||
|
|||
outcome = self._run_update(self.cfg["fw_host"], self.cfg["fw_port"], |
|||
steps, progress) |
|||
with self._lock: |
|||
for c in self._components: |
|||
if outcome.ok: |
|||
c["state"], c["sent"], c["pct"] = "done", c["size"], 100 |
|||
if outcome.ok: |
|||
self._set_phase("commit", "done", "slot committed") |
|||
self._set_phase("reboot", "active", "device reboots in ~10s") |
|||
# firmware is now committed to the slot → free the staging space |
|||
freed = self._clear_staging() |
|||
with self._lock: |
|||
self._state = "rebooting" |
|||
self._set_phase("flash", "done", |
|||
"components sent · staging freed" if freed |
|||
else "components sent (staging cleanup failed — free /opt manually)") |
|||
self._slot["after"] = "B" if self._slot["before"] == "A" else ( |
|||
"A" if self._slot["before"] == "B" else "?") |
|||
self._message = ("flash OK — device reboots now. Reconnect after " |
|||
"~1 min, then run Config check.") |
|||
else: |
|||
self._set_phase("flash", "error", "failed at %s" % outcome.failed_step) |
|||
with self._lock: |
|||
self._state = "failed" |
|||
self._error = "flash FAILED at step %s" % outcome.failed_step |
|||
self._message = self._error |
|||
except Exception as e: # noqa: BLE001 — surface any failure to the UI |
|||
with self._lock: |
|||
self._state = "failed" |
|||
self._error = "%s: %s" % (e.__class__.__name__, e) |
|||
self._message = self._error |
|||
# mark whichever phase was active as errored |
|||
for k in ("backup", "flash", "commit"): |
|||
if self._phase_state.get(k) == "active": |
|||
self._phase_state[k] = "error" |
|||
|
|||
def start_flash(self): |
|||
with self._lock: |
|||
if self._state in ('flashing', 'staging', 'rebooting', 'done'): |
|||
raise RuntimeError( |
|||
"Cannot flash in state '%s'" % self._state) |
|||
comps = list(self._components) |
|||
if not comps: |
|||
raise RuntimeError("no staged components — upload a firmware ZIP first") |
|||
# #12 fix: reset phases so a retry after a prior failed attempt starts |
|||
# clean — residual 'error' phases from the previous run must not linger. |
|||
self._reset_phases() |
|||
self._state = "flashing" |
|||
self._error = None |
|||
self._thread = threading.Thread(target=self._flash_worker, args=(comps,), |
|||
daemon=True) |
|||
self._thread.start() |
|||
|
|||
def _clear_staging(self): |
|||
"""Remove staged firmware from disk to reclaim space after a successful |
|||
commit (the firmware is already written to the slot). Returns True on |
|||
success. Config backups (a separate dir) are intentionally kept.""" |
|||
try: |
|||
staging.clear(self.cfg["staging_dir"]) |
|||
return True |
|||
except OSError: |
|||
return False |
|||
|
|||
# ---- post-reboot config restore ------------------------------------ |
|||
def restore_check(self, restart=True): |
|||
self._set_phase("config", "active", "checking config") |
|||
try: |
|||
r = config_safety.detect_and_restore(self.cfg["db_path"], |
|||
self.cfg["default_file"], |
|||
self.cfg["backups_dir"]) |
|||
except Exception as e: # noqa: BLE001 — missing/locked/empty DB must not 500 |
|||
reason = "config DB not readable: %s" % e |
|||
self._set_phase("config", "error", "DB unreadable") |
|||
return {"restored": False, "reason": reason, |
|||
"dpworldapp_restarted": False, "error": reason} |
|||
result = {"restored": r.restored, "reason": r.reason, |
|||
"dpworldapp_restarted": False} |
|||
if r.restored and restart: |
|||
try: |
|||
# v1.5.4.5 STAB-2 fix: check=True so non-zero returncode raises |
|||
# CalledProcessError → except branch sets restart_error. 이전 check=False |
|||
# 패턴은 systemctl restart dpworldapp 실패 (returncode != 0) 시에도 except |
|||
# 진입 안 함 → result["dpworldapp_restarted"]=True 로 silent lie 가능. |
|||
# 이제 어떤 실패 (CalledProcessError / TimeoutExpired / OSError) 든 except |
|||
# 통합 처리 후 restart_error 명시. |
|||
subprocess.run(self.cfg["restart_argv"], timeout=30, |
|||
check=True, stdout=subprocess.DEVNULL, |
|||
stderr=subprocess.DEVNULL) |
|||
result["dpworldapp_restarted"] = True |
|||
except Exception as e: # noqa: BLE001 |
|||
result["restart_error"] = str(e) |
|||
# #6 fix: if the restart command FAILED, the phase detail + _message must |
|||
# report the failure — never claim 'dpworldapp restarted'. The flash |
|||
# itself succeeded so state may still go 'done'; only the restart sub-step |
|||
# is reported failed (honesty: no green for an unverified restart). |
|||
restart_failed = bool(result.get("restart_error")) |
|||
with self._lock: |
|||
if r.restored and restart and restart_failed: |
|||
config_detail = ("config restored but dpworldapp restart FAILED: %s" |
|||
% result["restart_error"]) |
|||
else: |
|||
config_detail = ("restored from backup" if r.restored |
|||
else "config preserved") |
|||
self._set_phase("config", "done", config_detail) |
|||
if self._state == "rebooting": |
|||
self._state = "done" |
|||
if r.restored and restart and restart_failed: |
|||
self._message = ("config restored but dpworldapp restart FAILED: %s" |
|||
% result["restart_error"]) |
|||
else: |
|||
self._message = ("config restored & dpworldapp restarted" |
|||
if r.restored else "update complete — config preserved") |
|||
return result |
|||
|
|||
|
|||
def _readable_dirname(path): |
|||
"""Backup paths look like /opt/config_backups/20260601-190437 — return the stamp.""" |
|||
base = os.path.basename(path.rstrip("/")) if path else "" |
|||
return base or path |
|||
@ -0,0 +1,135 @@ |
|||
"""Firmware HTTP routes — thin glue between the web server and the controller. |
|||
|
|||
Kept transport-agnostic and side-effect-free except through the controller so the |
|||
SAME class drives both `server_standalone.py` (now) and NEW_Web_Configurator's |
|||
`server.py` (after merge). Each method returns a plain dict; the server is |
|||
responsible for JSON encoding and status codes via RouteError. |
|||
|
|||
Merge target: fold these handlers into src/server.py's do_GET/do_POST routing. |
|||
""" |
|||
import os |
|||
import shutil |
|||
import sys |
|||
import tempfile |
|||
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 |
|||
# 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 |
|||
_FREE_MARGIN = 64 * _MB # headroom beyond the declared upload size |
|||
|
|||
|
|||
class RouteError(Exception): |
|||
"""Carries an HTTP status so the server can map it without guessing.""" |
|||
def __init__(self, status, message): |
|||
super().__init__(message) |
|||
self.status = status |
|||
self.message = message |
|||
|
|||
|
|||
class FirmwareRoutes: |
|||
def __init__(self, controller, tmp_dir=None): |
|||
self.fw = controller |
|||
self.tmp_dir = tmp_dir or tempfile.gettempdir() |
|||
|
|||
# ---- GET ----------------------------------------------------------- |
|||
def status(self): |
|||
return self.fw.status() |
|||
|
|||
# ---- POST (no body) ------------------------------------------------ |
|||
def preflight(self): |
|||
return self.fw.preflight() |
|||
|
|||
def flash(self): |
|||
try: |
|||
self.fw.start_flash() |
|||
except RuntimeError as e: |
|||
raise RouteError(409, str(e)) |
|||
return {"ok": True} |
|||
|
|||
def restore_check(self): |
|||
return {"ok": True, "result": self.fw.restore_check()} |
|||
|
|||
# ---- POST (streamed ZIP body) -------------------------------------- |
|||
def upload(self, rfile, content_length): |
|||
"""Stream the request body (a firmware ZIP) to a private temp file in |
|||
1 MB chunks, hand it to the controller for extraction, then delete it. |
|||
|
|||
A per-request temp name (mkstemp) is required: ThreadingHTTPServer can |
|||
run uploads concurrently and a shared fixed filename would corrupt |
|||
in-flight transfers.""" |
|||
try: |
|||
n = int(content_length) |
|||
except (TypeError, ValueError): |
|||
raise RouteError(400, "invalid or missing Content-Length") |
|||
if n <= 0: |
|||
raise RouteError(400, "empty upload") |
|||
if n > _MAX_UPLOAD: |
|||
raise RouteError(413, "upload too large (%d bytes, max %d)" % (n, _MAX_UPLOAD)) |
|||
# 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 |
|||
# 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: |
|||
os.makedirs(self.tmp_dir, exist_ok=True) |
|||
free = shutil.disk_usage(self.tmp_dir).free |
|||
except OSError: |
|||
sys.stderr.write("[fw-routes] upload staging area unavailable:\n" + traceback.format_exc()) |
|||
raise RouteError(507, "upload staging area is unavailable") |
|||
if free < n + _FREE_MARGIN: |
|||
# fail fast with a clean, path-free 507 instead of buffering then dying ENOSPC |
|||
raise RouteError(507, "not enough free space to buffer the upload " |
|||
"(need %d bytes, have %d)" % (n + _FREE_MARGIN, free)) |
|||
fd, tmp = tempfile.mkstemp(dir=self.tmp_dir, prefix="dpw_fw_", suffix=".zip") |
|||
try: |
|||
f = os.fdopen(fd, "wb") # f now owns the fd |
|||
except BaseException: |
|||
os.close(fd) # fdopen failed → close the orphaned fd |
|||
try: |
|||
os.remove(tmp) |
|||
except OSError: |
|||
pass |
|||
raise |
|||
remaining = n |
|||
try: |
|||
try: |
|||
with f: |
|||
while remaining > 0: |
|||
chunk = rfile.read(min(_MB, remaining)) |
|||
if not chunk: |
|||
break |
|||
f.write(chunk) |
|||
remaining -= len(chunk) |
|||
except OSError: |
|||
# disk-full / write error while buffering — sanitized, path-free 507. |
|||
sys.stderr.write("[fw-routes] upload buffering failed:\n" + traceback.format_exc()) |
|||
raise RouteError(507, "not enough free space to buffer the upload") |
|||
if remaining > 0: |
|||
raise RouteError(400, "upload truncated (%d bytes missing)" % remaining) |
|||
try: |
|||
comps = self.fw.stage_zip(tmp) |
|||
except ValueError as e: |
|||
raise RouteError(400, str(e)) |
|||
except RuntimeError as e: # flash in progress |
|||
raise RouteError(409, str(e)) |
|||
except OSError: # e.g. insufficient space — message carries a path |
|||
sys.stderr.write("[fw-routes] staging failed:\n" + traceback.format_exc()) |
|||
raise RouteError(507, "not enough free space to stage the firmware") |
|||
except Exception: # bad zip / unexpected — never echo internals to client |
|||
sys.stderr.write("[fw-routes] stage_zip failed:\n" + traceback.format_exc()) |
|||
raise RouteError(400, "could not process the firmware archive") |
|||
finally: |
|||
try: |
|||
os.remove(tmp) |
|||
except OSError: |
|||
pass |
|||
view = [{k: c.get(k) for k in ("role", "signature", "name", "size", "sha256", "build_date")} |
|||
for c in comps] |
|||
return {"ok": True, "components": view} |
|||
@ -0,0 +1,64 @@ |
|||
"""Pure wire protocol for the dpworldapp FW-MMI channel (port 8990). |
|||
|
|||
Frame = ASCII(signature) + 8-byte little-endian size + <size> payload bytes. |
|||
A message-only step is the same frame with size 0 and no payload. |
|||
|
|||
ACK tokens confirmed from deployed firmware behavior: |
|||
success -> device sends exactly "SUCCESS" (7 bytes) |
|||
failure -> device sends exactly "FW_FAIL" (7 bytes) |
|||
Wire framing confirmed: 3-byte ASCII signature + 8-byte little-endian size + body. |
|||
COMPLETE_ACK is kept as a harmless legacy accept (never sent by the real device). |
|||
""" |
|||
import struct |
|||
|
|||
CHUNK = 8192 |
|||
|
|||
# component name -> 3-char signature used on the wire (matches DPWMMI C# tool) |
|||
SIGNATURES = {"bootloader": "BTL", "kernel": "KRN", "rootfs": "RTF", "dtb": "DTB"} |
|||
COMMIT_SIGNATURE = "CPU" |
|||
|
|||
# per-step ACK timeouts in ms, identical to DPWMMI |
|||
TIMEOUTS_MS = {"BTL": 20000, "KRN": 20000, "RTF": 20000, "DTB": 10000, "CPU": 120000} |
|||
|
|||
|
|||
def build_file_header(signature, size): |
|||
if size < 0: |
|||
raise ValueError("size must be non-negative") |
|||
return signature.encode("ascii") + struct.pack("<q", size) |
|||
|
|||
|
|||
def build_message_frame(message): |
|||
return message.encode("ascii") + struct.pack("<q", 0) |
|||
|
|||
|
|||
_FAILURE_TOKENS = ("FAIL", "ERROR", "REJECT") |
|||
|
|||
|
|||
def _contains_token(haystack, token): |
|||
"""Whole-token match: token bounded by non-alphanumeric, non-underscore chars.""" |
|||
i = 0 |
|||
while True: |
|||
i = haystack.find(token, i) |
|||
if i < 0: |
|||
return False |
|||
left_ok = i == 0 or not (haystack[i - 1].isalnum() or haystack[i - 1] == "_") |
|||
end = i + len(token) |
|||
right_ok = end >= len(haystack) or not (haystack[end].isalnum() or haystack[end] == "_") |
|||
if left_ok and right_ok: |
|||
return True |
|||
i = end |
|||
|
|||
|
|||
def classify_ack(text): |
|||
"""Return 'success', 'failure', or 'unknown'. |
|||
|
|||
Device (src/firmware_manager.c) sends exactly "SUCCESS" on success and |
|||
"FW_FAIL" on failure. We also keep generic fallbacks. Success is checked |
|||
first so a frame carrying both is treated as success. |
|||
""" |
|||
upper = text.upper() |
|||
if "SUCCESS" in upper or "COMPLETE_ACK" in upper: |
|||
return "success" |
|||
if "FW_FAIL" in upper or any(_contains_token(upper, t) for t in _FAILURE_TOKENS): |
|||
return "failure" |
|||
return "unknown" |
|||
@ -0,0 +1,61 @@ |
|||
"""Stage firmware files to a persistent dir (e.g. /opt/fw_staging) with hashing.""" |
|||
import hashlib |
|||
import os |
|||
import shutil |
|||
|
|||
_MB = 1024 * 1024 |
|||
|
|||
|
|||
class StagedFile: |
|||
def __init__(self, path, size, sha256): |
|||
self.path = path |
|||
self.size = size |
|||
self.sha256 = sha256 |
|||
|
|||
|
|||
def sha256_of(path): |
|||
h = hashlib.sha256() |
|||
with open(path, "rb") as f: |
|||
for chunk in iter(lambda: f.read(_MB), b""): |
|||
h.update(chunk) |
|||
return h.hexdigest() |
|||
|
|||
|
|||
def check_free_space(dest_dir, required_bytes, margin=64 * _MB): |
|||
os.makedirs(dest_dir, exist_ok=True) |
|||
free = shutil.disk_usage(dest_dir).free |
|||
if free < required_bytes + margin: |
|||
raise OSError( |
|||
"insufficient space in %s: need %d, have %d" |
|||
% (dest_dir, required_bytes + margin, free) |
|||
) |
|||
|
|||
|
|||
def stage(component, src_path, dest_dir): |
|||
os.makedirs(dest_dir, exist_ok=True) |
|||
size = os.path.getsize(src_path) |
|||
check_free_space(dest_dir, size) |
|||
dest = os.path.join(dest_dir, component) |
|||
with open(src_path, "rb") as r, open(dest, "wb") as w: |
|||
for chunk in iter(lambda: r.read(_MB), b""): |
|||
w.write(chunk) |
|||
return StagedFile(dest, os.path.getsize(dest), sha256_of(dest)) |
|||
|
|||
|
|||
def clear(dest_dir): |
|||
"""Empty the staging dir's CONTENTS, keeping the directory itself. |
|||
|
|||
Removing/recreating dest_dir (the old shutil.rmtree(dest_dir)) requires WRITE |
|||
on its PARENT, which systemd ProtectSystem=strict denies: only dest_dir (the |
|||
leaf) is in ReadWritePaths, not its parent /opt → the rmtree raised |
|||
OSError(EROFS). Deleting only the entries inside dest_dir needs write on |
|||
dest_dir alone, which IS granted, and leaves the directory (and its RW |
|||
bind-mount) intact for the subsequent re-stage.""" |
|||
if not os.path.isdir(dest_dir): |
|||
return |
|||
for name in os.listdir(dest_dir): |
|||
p = os.path.join(dest_dir, name) |
|||
if os.path.islink(p) or os.path.isfile(p): |
|||
os.remove(p) |
|||
elif os.path.isdir(p): |
|||
shutil.rmtree(p) |
|||
@ -0,0 +1,285 @@ |
|||
"""Kernel log bundle — produces a .tar.gz of all kernel-related logs. |
|||
|
|||
Sources: |
|||
- systemd journal (kernel-only and full) exported as TEXT via journalctl |
|||
- /opt/log/kernel-follow.log, /opt/log/wifi-focus.log, /opt/log/.current_boot_id |
|||
- /opt/log/boot-history/ (recursive) |
|||
- /opt/log/pstore/ (recursive) |
|||
|
|||
The journald directory /opt/log/journal/ is read-only to this module: never |
|||
delete or modify *.journal binary files; we export to text instead. |
|||
|
|||
v1.4.6.9 H14: Temp workspace uses /tmp (per-service PrivateTmp tmpfs slice — isolated + |
|||
writable under ProtectSystem=strict, where /opt/log parent is NOT in ReadWritePaths). |
|||
Previous "/opt/log default" caused PermissionError on hardened systemd. Override with |
|||
KERNEL_BUNDLE_TEMP_PARENT env var if a disk-backed location is required. |
|||
""" |
|||
from __future__ import annotations |
|||
|
|||
import logging |
|||
import os |
|||
import shutil |
|||
import socket |
|||
import subprocess |
|||
import tarfile |
|||
import tempfile |
|||
from datetime import datetime |
|||
|
|||
log = logging.getLogger(__name__) |
|||
|
|||
OPT_LOG_DIR = os.environ.get("OPT_LOG_DIR", "/opt/log") |
|||
# v1.4.6.9 H14: same systemd ProtectSystem=strict + ReadWritePaths whitelist gap that |
|||
# v1.4.6.6 fixed for log_manager._DOWNLOAD_TEMP_PARENT. /opt/log parent is NOT writable |
|||
# from the python3 process — mkdtemp raises PermissionError. PrivateTmp=yes makes /tmp a |
|||
# per-service tmpfs slice (~1.7GB on .56) — safe and isolated. |
|||
TEMP_PARENT = os.environ.get("KERNEL_BUNDLE_TEMP_PARENT", "/tmp") |
|||
EXPORT_BYTE_CAP = int(os.environ.get("KERNEL_BUNDLE_EXPORT_CAP", str(64 * 1024 * 1024))) |
|||
TEMP_DIR_PREFIX = "kernel-bundle-" |
|||
JOURNALCTL_CMD = os.environ.get("JOURNALCTL_CMD") or shutil.which("journalctl") |
|||
|
|||
|
|||
def _tree_size(root: str) -> int: |
|||
total = 0 |
|||
for dirpath, _dirs, files in os.walk(root): |
|||
for f in files: |
|||
try: |
|||
total += os.path.getsize(os.path.join(dirpath, f)) |
|||
except OSError: |
|||
pass |
|||
return total |
|||
|
|||
|
|||
def _copy_source(src_path: str, dest_parent: str): |
|||
"""Copy src_path (file or dir) under dest_parent. |
|||
|
|||
Returns (included: bool, size_bytes: int, reason_if_skipped: str). |
|||
""" |
|||
if not os.path.exists(src_path): |
|||
return False, 0, "not found" |
|||
try: |
|||
if os.path.isdir(src_path): |
|||
target = os.path.join(dest_parent, os.path.basename(src_path.rstrip("/"))) |
|||
shutil.copytree(src_path, target) |
|||
return True, _tree_size(target), "" |
|||
target = os.path.join(dest_parent, os.path.basename(src_path)) |
|||
shutil.copy2(src_path, target) |
|||
return True, os.path.getsize(target), "" |
|||
except OSError as exc: |
|||
return False, 0, f"copy error: {exc}" |
|||
|
|||
|
|||
_TRUNCATION_MARKER = "[TRUNCATED — exceeded {cap} bytes]\n" |
|||
_ABSENT_MARKER = "[journalctl unavailable: {reason}]\n" |
|||
|
|||
|
|||
def _run_journalctl(journalctl_cmd, args, dest_path: str, cap: int): |
|||
"""Run journalctl with args, stream stdout to dest_path, halt at cap bytes. |
|||
|
|||
Returns (success: bool, bytes_written: int, truncated: bool). |
|||
""" |
|||
if not journalctl_cmd: |
|||
with open(dest_path, "w", encoding="utf-8") as f: |
|||
f.write(_ABSENT_MARKER.format(reason="journalctl not found")) |
|||
return False, 0, False |
|||
return _run_journalctl_with([journalctl_cmd] + list(args), dest_path, cap) |
|||
|
|||
|
|||
def _run_journalctl_with(cmd_list, dest_path: str, cap: int): |
|||
"""Launch cmd_list and stream stdout to dest_path, capped at `cap` bytes.""" |
|||
try: |
|||
proc = subprocess.Popen( |
|||
cmd_list, |
|||
stdout=subprocess.PIPE, |
|||
stderr=subprocess.DEVNULL, |
|||
) |
|||
except OSError as exc: |
|||
with open(dest_path, "w", encoding="utf-8") as f: |
|||
f.write(_ABSENT_MARKER.format(reason=f"spawn failed: {exc}")) |
|||
return False, 0, False |
|||
|
|||
truncated = False |
|||
written = 0 |
|||
try: |
|||
with open(dest_path, "wb") as out: |
|||
while True: |
|||
chunk = proc.stdout.read(64 * 1024) |
|||
if not chunk: |
|||
break |
|||
remaining = cap - written |
|||
if remaining <= 0: |
|||
truncated = True |
|||
proc.kill() |
|||
break |
|||
if len(chunk) > remaining: |
|||
out.write(chunk[:remaining]) |
|||
written += remaining |
|||
truncated = True |
|||
proc.kill() |
|||
break |
|||
out.write(chunk) |
|||
written += len(chunk) |
|||
except OSError as exc: |
|||
# #25 fix: only an I/O error in the stream loop is a real failure. (The |
|||
# proc.wait timeout below is handled separately so it cannot misreport a |
|||
# fully-written, closed file as failed.) |
|||
try: |
|||
proc.kill() |
|||
except Exception: |
|||
pass |
|||
try: |
|||
with open(dest_path, "a", encoding="utf-8") as f: |
|||
f.write(f"\n[journalctl stream error: {exc}]\n") |
|||
except OSError: |
|||
pass # best-effort; don't let error-in-error escape |
|||
return False, written, truncated |
|||
finally: |
|||
# Ensure the stdout pipe FD is always released, regardless of exit path. |
|||
try: |
|||
proc.stdout.close() |
|||
except Exception: |
|||
pass |
|||
|
|||
# #25 fix: reap the process in its OWN try/except. The output is already |
|||
# fully written and the dest file is closed, so a wait timeout here must NOT |
|||
# be reported as failure — just kill the lingering process and keep success. |
|||
try: |
|||
proc.wait(timeout=5) |
|||
except subprocess.TimeoutExpired: |
|||
try: |
|||
proc.kill() |
|||
except Exception: |
|||
pass |
|||
try: |
|||
proc.wait(timeout=1) |
|||
except Exception: |
|||
pass |
|||
|
|||
if truncated: |
|||
with open(dest_path, "a", encoding="utf-8") as f: |
|||
f.write(_TRUNCATION_MARKER.format(cap=cap)) |
|||
|
|||
return True, written, truncated |
|||
|
|||
|
|||
_KERNEL_LOG_NAME = "kernel.log" |
|||
_FULL_JOURNAL_NAME = "full-journal.log" |
|||
|
|||
|
|||
def _journalctl_version(journalctl_cmd) -> str: |
|||
if not journalctl_cmd: |
|||
return "unavailable" |
|||
try: |
|||
out = subprocess.run( |
|||
[journalctl_cmd, "--version"], |
|||
capture_output=True, text=True, timeout=5, |
|||
) |
|||
first = out.stdout.splitlines()[0] if out.stdout else "" |
|||
return first or "unknown" |
|||
except (OSError, subprocess.SubprocessError): |
|||
return "error" |
|||
|
|||
|
|||
def _write_manifest(dest_path: str, entries, journalctl_version: str, hostname: str): |
|||
lines = [ |
|||
"kernel-log-bundle manifest", |
|||
f"generated: {datetime.utcnow().isoformat()}Z", |
|||
f"hostname: {hostname}", |
|||
f"journalctl version: {journalctl_version}", |
|||
"", |
|||
"Entries:", |
|||
] |
|||
for e in entries: |
|||
status = "included" if e["included"] else f"skipped ({e['reason']})" |
|||
extra = " [TRUNCATED]" if e.get("truncated") else "" |
|||
lines.append(f" - {e['name']:<22} {status} size={e['size']}B{extra}") |
|||
with open(dest_path, "w", encoding="utf-8") as f: |
|||
f.write("\n".join(lines) + "\n") |
|||
|
|||
|
|||
def build_kernel_bundle() -> str: |
|||
"""Build the kernel-log .tar.gz; return absolute path. |
|||
|
|||
Caller is responsible for removing the containing temp dir |
|||
(os.path.dirname(returned_path)) after streaming. |
|||
""" |
|||
hostname = socket.gethostname() |
|||
ts = datetime.utcnow().strftime("%Y%m%d-%H%M%S") |
|||
os.makedirs(TEMP_PARENT, exist_ok=True) |
|||
workspace = tempfile.mkdtemp(prefix=TEMP_DIR_PREFIX, dir=TEMP_PARENT) |
|||
try: |
|||
staging = os.path.join(workspace, "staging") |
|||
os.makedirs(staging, exist_ok=True) |
|||
|
|||
entries = [] |
|||
|
|||
# journalctl exports |
|||
for args, name in ( |
|||
(["-k", "--no-pager"], _KERNEL_LOG_NAME), |
|||
(["--no-pager"], _FULL_JOURNAL_NAME), |
|||
): |
|||
dest = os.path.join(staging, name) |
|||
ok, _written, truncated = _run_journalctl(JOURNALCTL_CMD, args, dest, EXPORT_BYTE_CAP) |
|||
entries.append({ |
|||
"name": name, |
|||
"included": True, # the file is always written, even if it just contains the absent marker |
|||
"reason": "" if ok else "journalctl unavailable", |
|||
"size": os.path.getsize(dest) if os.path.exists(dest) else 0, |
|||
"truncated": truncated, |
|||
}) |
|||
|
|||
# Plain sources from OPT_LOG_DIR. Tuple is (src_name_in_opt_log, dest_name_in_bundle). |
|||
plain_sources = [ |
|||
("kernel-follow.log", "kernel-follow.log"), |
|||
("wifi-focus.log", "wifi-focus.log"), |
|||
(".current_boot_id", "current_boot_id.txt"), |
|||
("boot-history", "boot-history"), |
|||
("pstore", "pstore"), |
|||
] |
|||
for src_name, dest_name in plain_sources: |
|||
src_path = os.path.join(OPT_LOG_DIR, src_name) |
|||
included, size, reason = _copy_source(src_path, staging) |
|||
if included and src_name != dest_name: |
|||
try: |
|||
os.rename(os.path.join(staging, src_name), os.path.join(staging, dest_name)) |
|||
except OSError as exc: |
|||
reason = f"rename failed: {exc}" |
|||
included = False |
|||
entries.append({ |
|||
"name": dest_name, "included": included, "reason": reason, |
|||
"size": size, "truncated": False, |
|||
}) |
|||
|
|||
_write_manifest( |
|||
os.path.join(staging, "bundle-info.txt"), |
|||
entries, _journalctl_version(JOURNALCTL_CMD), hostname, |
|||
) |
|||
|
|||
tar_name = f"kernel-logs_{hostname}_{ts}.tar.gz" |
|||
tar_path = os.path.join(workspace, tar_name) |
|||
with tarfile.open(tar_path, "w:gz") as tf: |
|||
for entry in sorted(os.listdir(staging)): |
|||
tf.add(os.path.join(staging, entry), arcname=entry) |
|||
shutil.rmtree(staging, ignore_errors=True) |
|||
return tar_path |
|||
except Exception: |
|||
shutil.rmtree(workspace, ignore_errors=True) |
|||
raise |
|||
|
|||
|
|||
def sweep_stale_temp_dirs() -> int: |
|||
"""Remove leftover kernel-bundle-* dirs under TEMP_PARENT. Returns count removed.""" |
|||
if not os.path.isdir(TEMP_PARENT): |
|||
return 0 |
|||
removed = 0 |
|||
for name in os.listdir(TEMP_PARENT): |
|||
if not name.startswith(TEMP_DIR_PREFIX): |
|||
continue |
|||
path = os.path.join(TEMP_PARENT, name) |
|||
if not os.path.isdir(path): |
|||
continue |
|||
try: |
|||
shutil.rmtree(path) |
|||
removed += 1 |
|||
except OSError as exc: |
|||
log.warning("sweep_stale_temp_dirs: failed to remove %s: %s", path, exc) |
|||
return removed |
|||
@ -0,0 +1,691 @@ |
|||
""" |
|||
log_manager.py — Log File Management Module |
|||
|
|||
Provides log file listing, secure download archive creation, |
|||
and automatic compression for /opt/log/dpworldapp. |
|||
""" |
|||
|
|||
import os |
|||
import re |
|||
import tarfile |
|||
import tempfile |
|||
import time |
|||
import shutil |
|||
import threading |
|||
import logging |
|||
|
|||
LOG_DIR = os.environ.get("LOG_DIR", "/opt/log/dpworldapp") |
|||
# v1.4.6.6: systemd ProtectSystem=strict + ReadWritePaths 화이트리스트가 /opt/log 부재로 |
|||
# `/opt/log/log-download-*` mkdtemp 차단 (.56 라이브 OSError 30). PrivateTmp=yes 격리된 |
|||
# tmpfs /tmp로 default 변경. 운영자가 다른 경로 원하면 LOG_DOWNLOAD_TEMP_PARENT env로 override. |
|||
_DOWNLOAD_TEMP_PARENT = os.environ.get("LOG_DOWNLOAD_TEMP_PARENT", "/tmp") |
|||
_DOWNLOAD_TEMP_PREFIX = "log-download-" |
|||
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB download limit |
|||
ALLOWED_EXTENSIONS = frozenset({'.log', '.tar.gz', '.gz', '.txt'}) |
|||
# Uncompressed rotation suffixes produced by logrotate on devices without |
|||
# the auto-compress daemon — e.g. dpworldapp_2026-05-25.log.0 / .log.1 |
|||
_ROTATION_SUFFIX_RE = re.compile(r'\.log\.\d+$') |
|||
COMPRESS_CHECK_INTERVAL = 5 * 60 # 5 minutes |
|||
|
|||
# Log-management settings live in their own `log_config` board_config key — |
|||
# NOT in `device_config`. The legacy Java app overwrites device_config with |
|||
# its own model and silently drops these Python-only fields. |
|||
LOG_CONFIG_KEYS = ( |
|||
"log_auto_compress", |
|||
"log_auto_cleanup", |
|||
"log_cleanup_max_files", |
|||
"log_cleanup_max_size_mb", |
|||
# v1.4.6.7 C-1: Python 전용 compress threshold (Java schema 부재 → device_config에서 wipe 위험). |
|||
# .56 라이브 monitoring Phase 1에서 device_config로 잘못 진입 노출 (state.js convertNestedToFlat이 |
|||
# 8개 log_* 키 모두 emit인데 LOG_CONFIG_KEYS가 4개만 분리 — 누락 2개). |
|||
"log_compress_size_mb", |
|||
"log_compress_age_days", |
|||
) |
|||
LOG_CONFIG_DEFAULTS = { |
|||
"log_auto_compress": "off", |
|||
"log_auto_cleanup": "off", |
|||
"log_cleanup_max_files": 50, |
|||
"log_cleanup_max_size_mb": 200, |
|||
# v1.4.6.7 C-1: state.js convertNestedToFlat과 default 일치 |
|||
"log_compress_size_mb": 50, |
|||
"log_compress_age_days": 7, |
|||
} |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
# Matches .log and rotated files like .log.0, .log.1, .log.2, etc. |
|||
_LOG_FILE_RE = re.compile(r'\.log(\.\d+)?$') |
|||
_compression_lock = threading.Lock() |
|||
|
|||
|
|||
class CompressionAlreadyRunning(RuntimeError): |
|||
"""Raised when a second compression request starts before the first finishes.""" |
|||
|
|||
|
|||
def _is_log_file(name): |
|||
"""Check if filename is a log file (including rotated ones like .log.0, .log.1).""" |
|||
return bool(_LOG_FILE_RE.search(name)) |
|||
|
|||
|
|||
def _has_allowed_extension(name): |
|||
"""True if `name`'s extension is in ALLOWED_EXTENSIONS, or matches a |
|||
logrotate numeric suffix like '.log.0' / '.log.1'.""" |
|||
if any(name.endswith(ext) for ext in ALLOWED_EXTENSIONS): |
|||
return True |
|||
return bool(_ROTATION_SUFFIX_RE.search(name)) |
|||
|
|||
|
|||
def _make_tar_name(name, mtime): |
|||
""" |
|||
Archive name for a log file: ``<original name>.<log-mtime>.tar.gz``. |
|||
|
|||
The full original name is kept (so ``.log`` and rotated ``.log.N`` are |
|||
treated identically) and the log's mtime is embedded — making the name |
|||
unique, so a log filename reused over time never collides with a prior |
|||
archive. Example: ``dpworldapp_2026-05-21.log.20260521-164801.tar.gz``. |
|||
""" |
|||
ts = time.strftime('%Y%m%d-%H%M%S', time.localtime(mtime)) |
|||
return f"{name}.{ts}.tar.gz" |
|||
|
|||
|
|||
def _verify_archive(tar_path, expected_name): |
|||
"""Return True if tar_path is a valid .tar.gz holding exactly expected_name.""" |
|||
try: |
|||
with tarfile.open(tar_path, 'r:gz') as tar: |
|||
members = tar.getmembers() |
|||
return len(members) == 1 and members[0].name == expected_name |
|||
except Exception: |
|||
return False |
|||
|
|||
|
|||
def _safe_remove(path): |
|||
"""Remove a file, ignoring any error.""" |
|||
try: |
|||
os.remove(path) |
|||
except OSError: |
|||
pass |
|||
|
|||
|
|||
def list_log_files(): |
|||
""" |
|||
List log files in LOG_DIR with metadata. |
|||
Returns dict: { files: [...], total_size: int } |
|||
""" |
|||
if not os.path.isdir(LOG_DIR): |
|||
return {"files": [], "total_size": 0} |
|||
|
|||
files = [] |
|||
total_size = 0 |
|||
|
|||
try: |
|||
names = os.listdir(LOG_DIR) |
|||
except OSError as e: |
|||
raise RuntimeError(f"Failed to read log directory: {e}") |
|||
|
|||
for name in names: |
|||
filepath = os.path.join(LOG_DIR, name) |
|||
try: |
|||
if not os.path.isfile(filepath): |
|||
continue |
|||
stat = os.stat(filepath) |
|||
except OSError: |
|||
# File disappeared between listdir and stat (compress daemon race). |
|||
continue |
|||
size = stat.st_size |
|||
mtime = stat.st_mtime |
|||
|
|||
# Determine file type |
|||
if name.endswith('.tar.gz'): |
|||
ftype = 'tar.gz' |
|||
elif name.endswith('.gz'): |
|||
ftype = 'gz' |
|||
elif _is_log_file(name): |
|||
ftype = 'log' |
|||
elif name.endswith('.txt'): |
|||
ftype = 'txt' |
|||
else: |
|||
ftype = 'other' |
|||
|
|||
files.append({ |
|||
"name": name, |
|||
"size": size, |
|||
"modified": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(mtime)), |
|||
"type": ftype, |
|||
}) |
|||
total_size += size |
|||
|
|||
# Sort by modified descending (newest first) |
|||
files.sort(key=lambda f: f["modified"], reverse=True) |
|||
|
|||
return {"files": files, "total_size": total_size} |
|||
|
|||
|
|||
def validate_filenames(filenames): |
|||
""" |
|||
Validate a list of filenames for security. |
|||
Blocks path traversal, absolute paths, and missing files. |
|||
Returns (valid_paths, error_message). |
|||
""" |
|||
if not filenames or not isinstance(filenames, list): |
|||
return None, "No files specified" |
|||
|
|||
valid_paths = [] |
|||
|
|||
for name in filenames: |
|||
if not isinstance(name, str) or not name.strip(): |
|||
return None, f"Invalid filename: empty or non-string" |
|||
|
|||
# Block path traversal characters |
|||
if '..' in name or '/' in name or '\\' in name: |
|||
return None, f"Invalid filename: {name}" |
|||
|
|||
# Extension whitelist — block .partial, .sh, etc. |
|||
# Also permit uncompressed logrotate suffixes: *.log.0, *.log.1, … |
|||
if not _has_allowed_extension(name): |
|||
return None, f"File extension not allowed: {name}" |
|||
|
|||
# Resolve symlinks and verify it stays within LOG_DIR |
|||
filepath = os.path.realpath(os.path.join(LOG_DIR, name)) |
|||
if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep): |
|||
return None, f"Invalid filename: {name}" |
|||
|
|||
if not os.path.isfile(filepath): |
|||
return None, f"File not found: {name}" |
|||
|
|||
valid_paths.append(filepath) |
|||
|
|||
# Check total size limit |
|||
total = sum(os.path.getsize(p) for p in valid_paths) |
|||
if total > MAX_DOWNLOAD_SIZE: |
|||
return None, f"Total size exceeds {MAX_DOWNLOAD_SIZE // (1024*1024)}MB limit" |
|||
|
|||
return valid_paths, None |
|||
|
|||
|
|||
def create_download_archive(valid_paths): |
|||
""" |
|||
Create a tar.gz archive on disk under _DOWNLOAD_TEMP_PARENT. |
|||
Returns the absolute archive path. Caller is responsible for removing |
|||
os.path.dirname(archive_path) after streaming. |
|||
|
|||
Per-file FileNotFoundError is logged and skipped (resilient to the |
|||
auto-compress daemon renaming/deleting files during the request). |
|||
|
|||
v1.4.6.5 L-D: If ALL files vanish before being added, the workspace is |
|||
cleaned up and FileNotFoundError is raised so the caller can return |
|||
HTTP 404 instead of an empty tar.gz with HTTP 200. |
|||
""" |
|||
os.makedirs(_DOWNLOAD_TEMP_PARENT, exist_ok=True) |
|||
workspace = tempfile.mkdtemp(prefix=_DOWNLOAD_TEMP_PREFIX, dir=_DOWNLOAD_TEMP_PARENT) |
|||
try: |
|||
timestamp = time.strftime("%Y-%m-%d_%H%M%S") |
|||
archive_path = os.path.join(workspace, f"logs_{timestamp}.tar.gz") |
|||
added_count = 0 |
|||
with tarfile.open(archive_path, mode='w:gz') as tar: |
|||
for filepath in valid_paths: |
|||
arcname = os.path.basename(filepath) |
|||
try: |
|||
tar.add(filepath, arcname=arcname) |
|||
added_count += 1 |
|||
except FileNotFoundError: |
|||
# Daemon may have renamed/deleted between validate and archive. |
|||
# Skip; client just gets a smaller archive. |
|||
continue |
|||
if added_count == 0: |
|||
shutil.rmtree(workspace, ignore_errors=True) |
|||
raise FileNotFoundError( |
|||
"All requested files disappeared between validation and archive" |
|||
) |
|||
return archive_path |
|||
except Exception: |
|||
shutil.rmtree(workspace, ignore_errors=True) |
|||
raise |
|||
|
|||
|
|||
def sweep_stale_download_temp_dirs(): |
|||
"""Remove leftover log-download temp dirs from prior server crashes.""" |
|||
if not os.path.isdir(_DOWNLOAD_TEMP_PARENT): |
|||
return 0 |
|||
removed = 0 |
|||
for name in os.listdir(_DOWNLOAD_TEMP_PARENT): |
|||
if not name.startswith(_DOWNLOAD_TEMP_PREFIX): |
|||
continue |
|||
path = os.path.join(_DOWNLOAD_TEMP_PARENT, name) |
|||
if not os.path.isdir(path): |
|||
continue |
|||
try: |
|||
shutil.rmtree(path) |
|||
removed += 1 |
|||
except OSError: |
|||
pass |
|||
return removed |
|||
|
|||
|
|||
def get_active_log_file(): |
|||
""" |
|||
Return the filename of the most recently modified log file (the active one). |
|||
DPWORLDAPP writes to one log file at a time; completed files should be compressed. |
|||
Considers .log and rotated .log.0, .log.1, etc. |
|||
Returns None if no log files exist. |
|||
""" |
|||
if not os.path.isdir(LOG_DIR): |
|||
return None |
|||
|
|||
latest_name = None |
|||
latest_mtime = 0 |
|||
|
|||
try: |
|||
for name in os.listdir(LOG_DIR): |
|||
if not _is_log_file(name): |
|||
continue |
|||
filepath = os.path.join(LOG_DIR, name) |
|||
if not os.path.isfile(filepath): |
|||
continue |
|||
mtime = os.stat(filepath).st_mtime |
|||
if mtime > latest_mtime: |
|||
latest_mtime = mtime |
|||
latest_name = name |
|||
except OSError: |
|||
pass |
|||
|
|||
return latest_name |
|||
|
|||
|
|||
def get_log_stats(): |
|||
""" |
|||
Return disk usage statistics for the log directory. |
|||
Returns dict with compressed/uncompressed counts, sizes, active file, total size, |
|||
and partition capacity (total/used/free bytes for the filesystem hosting LOG_DIR). |
|||
""" |
|||
# Partition capacity — fall back to root if LOG_DIR is absent |
|||
try: |
|||
probe = LOG_DIR if os.path.isdir(LOG_DIR) else "/" |
|||
usage = shutil.disk_usage(probe) |
|||
partition_total = usage.total |
|||
partition_used = usage.used |
|||
partition_free = usage.free |
|||
except (OSError, AttributeError): |
|||
partition_total = partition_used = partition_free = 0 |
|||
|
|||
if not os.path.isdir(LOG_DIR): |
|||
return { |
|||
"total_size": 0, |
|||
"compressed_count": 0, |
|||
"compressed_size": 0, |
|||
"uncompressed_count": 0, |
|||
"uncompressed_size": 0, |
|||
"active_file": None, |
|||
"partition_total": partition_total, |
|||
"partition_used": partition_used, |
|||
"partition_free": partition_free, |
|||
} |
|||
|
|||
active = get_active_log_file() |
|||
compressed_count = 0 |
|||
compressed_size = 0 |
|||
uncompressed_count = 0 |
|||
uncompressed_size = 0 |
|||
total_size = 0 |
|||
|
|||
try: |
|||
for name in os.listdir(LOG_DIR): |
|||
filepath = os.path.join(LOG_DIR, name) |
|||
if not os.path.isfile(filepath): |
|||
continue |
|||
size = os.stat(filepath).st_size |
|||
total_size += size |
|||
|
|||
if name.endswith('.tar.gz') or name.endswith('.gz'): |
|||
compressed_count += 1 |
|||
compressed_size += size |
|||
else: |
|||
uncompressed_count += 1 |
|||
uncompressed_size += size |
|||
except OSError: |
|||
pass |
|||
|
|||
return { |
|||
"total_size": total_size, |
|||
"compressed_count": compressed_count, |
|||
"compressed_size": compressed_size, |
|||
"uncompressed_count": uncompressed_count, |
|||
"uncompressed_size": uncompressed_size, |
|||
"active_file": active, |
|||
"partition_total": partition_total, |
|||
"partition_used": partition_used, |
|||
"partition_free": partition_free, |
|||
} |
|||
|
|||
|
|||
def _compress_non_active_logs(): |
|||
if not _compression_lock.acquire(blocking=False): |
|||
raise CompressionAlreadyRunning("Compression already running") |
|||
try: |
|||
return _compress_non_active_logs_unlocked() |
|||
finally: |
|||
_compression_lock.release() |
|||
|
|||
|
|||
def _compress_non_active_logs_unlocked(): |
|||
""" |
|||
Compress every log file except the active (most recently modified) one. |
|||
|
|||
Handles ``.log`` and rotated ``.log.0``, ``.log.1``, etc. Each archive is |
|||
named ``<logname>.<log-mtime>.tar.gz`` (see _make_tar_name) — unique, so a |
|||
reused log filename never collides with a prior, possibly stale, archive. |
|||
Archives are written to a ``.partial`` temp file and atomically renamed, so |
|||
an interrupted run never leaves a corrupt archive that shadows the log. |
|||
Returns the number of files compressed. |
|||
""" |
|||
if not os.path.isdir(LOG_DIR): |
|||
return 0 |
|||
|
|||
try: |
|||
names = os.listdir(LOG_DIR) |
|||
except OSError as e: |
|||
logger.warning(f"Error listing log directory: {e}") |
|||
return 0 |
|||
|
|||
# Clear leftover .partial archives from any previously interrupted run. |
|||
for name in names: |
|||
if name.endswith('.tar.gz.partial'): |
|||
_safe_remove(os.path.join(LOG_DIR, name)) |
|||
|
|||
active = get_active_log_file() |
|||
compressed_count = 0 |
|||
|
|||
for name in names: |
|||
if not _is_log_file(name): |
|||
continue |
|||
|
|||
# Skip the active file |
|||
if name == active: |
|||
continue |
|||
|
|||
filepath = os.path.join(LOG_DIR, name) |
|||
if not os.path.isfile(filepath): |
|||
continue |
|||
|
|||
try: |
|||
mtime = os.stat(filepath).st_mtime |
|||
except OSError: |
|||
continue |
|||
|
|||
tar_name = _make_tar_name(name, mtime) |
|||
tar_path = os.path.join(LOG_DIR, tar_name) |
|||
|
|||
# This exact version (same name + mtime) already archived? |
|||
if os.path.exists(tar_path): |
|||
if _verify_archive(tar_path, name): |
|||
# Archive is good — drop the now-redundant original. |
|||
_safe_remove(filepath) |
|||
continue |
|||
# Corrupt/partial archive at the final name — discard, recompress. |
|||
_safe_remove(tar_path) |
|||
|
|||
tmp_path = tar_path + '.partial' |
|||
try: |
|||
with tarfile.open(tmp_path, 'w:gz') as tar: |
|||
tar.add(filepath, arcname=name) |
|||
|
|||
if not _verify_archive(tmp_path, name): |
|||
_safe_remove(tmp_path) |
|||
logger.warning(f"Archive verification failed for {name}") |
|||
continue |
|||
|
|||
os.rename(tmp_path, tar_path) # atomic publish |
|||
os.remove(filepath) |
|||
compressed_count += 1 |
|||
logger.info(f"Compressed: {name} -> {tar_name}") |
|||
|
|||
except Exception as e: |
|||
logger.warning(f"Failed to compress {name}: {e}") |
|||
_safe_remove(tmp_path) |
|||
|
|||
return compressed_count |
|||
|
|||
|
|||
def _run_cleanup(max_files, max_size_mb): |
|||
""" |
|||
Delete oldest compressed (.tar.gz / .gz) archives until both thresholds are met: |
|||
- file count <= max_files |
|||
- total compressed size <= max_size_mb (MB) |
|||
Uncompressed and active log files are never touched. |
|||
Returns the number of files deleted. |
|||
""" |
|||
if not os.path.isdir(LOG_DIR): |
|||
return 0 |
|||
|
|||
# v1.11.8 B: defensive count floor — a corrupted/zero max_files from the DB |
|||
# must never delete EVERY archive. Keep at least one. (The size threshold is |
|||
# independent and can still bring the set down if the operator set it tiny.) |
|||
try: |
|||
max_files = max(1, int(max_files)) |
|||
except (TypeError, ValueError): |
|||
max_files = 1 |
|||
|
|||
max_size_bytes = max_size_mb * 1024 * 1024 |
|||
archives = [] |
|||
|
|||
try: |
|||
for name in os.listdir(LOG_DIR): |
|||
if not (name.endswith('.tar.gz') or name.endswith('.gz')): |
|||
continue |
|||
filepath = os.path.join(LOG_DIR, name) |
|||
if not os.path.isfile(filepath): |
|||
continue |
|||
try: |
|||
stat = os.stat(filepath) |
|||
archives.append((stat.st_mtime, stat.st_size, name, filepath)) |
|||
except OSError: |
|||
continue |
|||
except OSError as e: |
|||
logger.warning(f"Cleanup: error listing log directory: {e}") |
|||
return 0 |
|||
|
|||
# Oldest first |
|||
archives.sort(key=lambda a: a[0]) |
|||
|
|||
deleted = 0 |
|||
total_size = sum(a[1] for a in archives) |
|||
|
|||
while archives and (len(archives) > max_files or total_size > max_size_bytes): |
|||
entry = archives.pop(0) |
|||
mtime, size, name, path = entry |
|||
try: |
|||
os.remove(path) |
|||
total_size -= size |
|||
deleted += 1 |
|||
logger.info( |
|||
f"Auto-cleanup: deleted {name} (mtime={mtime:.0f}, size={size})" |
|||
) |
|||
except OSError as e: |
|||
# #24 fix: the file is still on disk — re-append it (so the count is |
|||
# honest) and stop. Continuing would drop it from `archives`, shrinking |
|||
# len(archives) toward the threshold and exiting the count-based loop |
|||
# one early while the file remains. We can't make progress past an |
|||
# un-removable oldest file, so break to avoid a no-progress spin. |
|||
logger.warning(f"Cleanup: failed to delete {name}: {e}") |
|||
archives.append(entry) |
|||
break |
|||
|
|||
return deleted |
|||
|
|||
|
|||
def migrate_log_config(db): |
|||
""" |
|||
Ensure the `log_config` board_config key exists. |
|||
|
|||
Log-management settings used to live in `device_config`, which the legacy |
|||
Java app overwrites with its own model — silently dropping them. They now |
|||
live in their own key. This seeds `log_config` once, from any values still |
|||
present in `device_config`, otherwise from defaults. Idempotent. |
|||
""" |
|||
try: |
|||
if db.config_exists("log_config"): |
|||
return |
|||
device = db.get_config("device_config") or {} |
|||
seeded = { |
|||
key: device.get(key, LOG_CONFIG_DEFAULTS[key]) |
|||
for key in LOG_CONFIG_KEYS |
|||
} |
|||
db.save_config("log_config", seeded) |
|||
logger.info("Migrated log-management settings into the log_config key") |
|||
except Exception as e: |
|||
logger.warning(f"log_config migration error: {e}") |
|||
|
|||
|
|||
def run_auto_compression(db): |
|||
""" |
|||
Compress ALL non-active .log files if auto_compress is enabled. |
|||
Reads settings from the log_config key in DB. |
|||
""" |
|||
try: |
|||
config = db.get_config("log_config") |
|||
if not isinstance(config, dict) or not config: |
|||
return |
|||
|
|||
# Check if auto compress is enabled |
|||
auto_compress = config.get("log_auto_compress", "off") |
|||
if auto_compress != "on": |
|||
return |
|||
|
|||
try: |
|||
count = _compress_non_active_logs() |
|||
if count > 0: |
|||
logger.info(f"Auto-compressed {count} file(s)") |
|||
except CompressionAlreadyRunning: |
|||
logger.info("Auto compression skipped: compression already running") |
|||
|
|||
except Exception as e: |
|||
logger.warning(f"Auto compression error: {e}") |
|||
|
|||
|
|||
def run_auto_cleanup(db): |
|||
""" |
|||
Delete oldest compressed log archives if log_auto_cleanup is enabled |
|||
and either threshold (max_files or max_size_mb) is exceeded. |
|||
Reads settings from the log_config key in DB. Uses safe defaults when keys missing. |
|||
""" |
|||
try: |
|||
config = db.get_config("log_config") |
|||
if not isinstance(config, dict) or not config: |
|||
return |
|||
|
|||
if config.get("log_auto_cleanup", "off") != "on": |
|||
return |
|||
|
|||
try: |
|||
max_files = int(config.get("log_cleanup_max_files", 50)) |
|||
except (TypeError, ValueError): |
|||
max_files = 50 |
|||
try: |
|||
max_size_mb = int(config.get("log_cleanup_max_size_mb", 200)) |
|||
except (TypeError, ValueError): |
|||
max_size_mb = 200 |
|||
|
|||
count = _run_cleanup(max_files, max_size_mb) |
|||
if count > 0: |
|||
logger.info(f"Auto-cleanup deleted {count} archive(s)") |
|||
|
|||
except Exception as e: |
|||
logger.warning(f"Auto cleanup error: {e}") |
|||
|
|||
|
|||
def run_manual_compression(): |
|||
""" |
|||
Manually compress ALL non-active .log files. |
|||
Always runs regardless of auto_compress setting. |
|||
Returns dict with result info. |
|||
""" |
|||
try: |
|||
count = _compress_non_active_logs() |
|||
return { |
|||
"success": True, |
|||
"compressed": count, |
|||
"message": f"Compressed {count} file(s)" if count > 0 else "No files to compress", |
|||
} |
|||
except CompressionAlreadyRunning: |
|||
logger.info("Manual compression skipped: compression already running") |
|||
return { |
|||
"success": False, |
|||
"compressed": 0, |
|||
"message": "Compression already running", |
|||
} |
|||
except Exception as e: |
|||
logger.warning(f"Manual compression error: {e}") |
|||
return { |
|||
"success": False, |
|||
"compressed": 0, |
|||
"message": f"Compression failed: {str(e)}", |
|||
} |
|||
|
|||
|
|||
def delete_log_files(filenames): |
|||
""" |
|||
Delete specified log files with security validation. |
|||
Returns dict with result info. |
|||
""" |
|||
if not filenames or not isinstance(filenames, list): |
|||
return {"success": False, "deleted": 0, "message": "No files specified"} |
|||
|
|||
deleted = 0 |
|||
errors = [] |
|||
|
|||
for name in filenames: |
|||
if not isinstance(name, str) or not name.strip(): |
|||
errors.append(f"Invalid filename: empty or non-string") |
|||
continue |
|||
|
|||
# Block path traversal |
|||
if '..' in name or '/' in name or '\\' in name: |
|||
errors.append(f"Invalid filename: {name}") |
|||
continue |
|||
|
|||
filepath = os.path.realpath(os.path.join(LOG_DIR, name)) |
|||
if not filepath.startswith(os.path.realpath(LOG_DIR) + os.sep): |
|||
errors.append(f"Invalid filename: {name}") |
|||
continue |
|||
|
|||
# v1.3.1: enforce extension whitelist — same set used by validate_filenames |
|||
if not _has_allowed_extension(name): |
|||
errors.append(f"Unsupported extension: {name}") |
|||
continue |
|||
|
|||
if not os.path.isfile(filepath): |
|||
errors.append(f"File not found: {name}") |
|||
continue |
|||
|
|||
try: |
|||
os.remove(filepath) |
|||
deleted += 1 |
|||
logger.info(f"Deleted log file: {name}") |
|||
except OSError as e: |
|||
errors.append(f"Failed to delete {name}: {e}") |
|||
|
|||
message = f"Deleted {deleted} file(s)" |
|||
if errors: |
|||
message += f"; errors: {'; '.join(errors)}" |
|||
|
|||
return { |
|||
"success": deleted > 0 or len(errors) == 0, |
|||
"deleted": deleted, |
|||
"message": message, |
|||
} |
|||
|
|||
|
|||
def start_auto_compress_daemon(db): |
|||
"""Start a daemon thread that runs auto-compression and auto-cleanup periodically.""" |
|||
def _worker(): |
|||
while True: |
|||
try: |
|||
run_auto_compression(db) |
|||
run_auto_cleanup(db) |
|||
except Exception as e: |
|||
logger.warning(f"Auto compress/cleanup daemon error: {e}") |
|||
time.sleep(COMPRESS_CHECK_INTERVAL) |
|||
|
|||
thread = threading.Thread(target=_worker, daemon=True, name="log-auto-compress") |
|||
thread.start() |
|||
logger.info("Auto-compress/cleanup daemon started (interval: 5min)") |
|||
return thread |
|||
@ -0,0 +1,208 @@ |
|||
"""One-shot startup migrations for the Python configurator. |
|||
|
|||
Migrations are gated by the `schema_meta` SQLite table (separate from |
|||
`board_config` to avoid contention with the Java app).""" |
|||
|
|||
# v1.4.6.3 H1: Java CanSpeed Integer + Python enum integer set 정합 |
|||
# mapping값을 string→int로 (migration 후 DB에 string 잔존하던 type confusion 제거) |
|||
_CAN_BAUDRATE_OLD_TO_NEW = { |
|||
"2500": 250, |
|||
"5000": 500, |
|||
"10000": 1000, |
|||
# "1000" left unmapped — ambiguous between old and new units; |
|||
# conservative policy in guide §6.3 keeps it as-is. |
|||
} |
|||
|
|||
|
|||
def migrate_can_baudrate_units(db) -> bool: |
|||
"""Migrate device_config.can_baudrate from old units to new (5/22) units. |
|||
Returns True if the migration ran, False if previously applied.""" |
|||
if db.get_schema_meta("can_baudrate_units_migrated") == "true": |
|||
return False |
|||
|
|||
def _mut(cur): |
|||
cfg = dict(cur) if isinstance(cur, dict) else {} |
|||
new = _CAN_BAUDRATE_OLD_TO_NEW.get(cfg.get("can_baudrate")) |
|||
if new is not None: |
|||
cfg["can_baudrate"] = new |
|||
# Java schema stores can_baudrate as Integer. Any remaining clean |
|||
# digit-string value (e.g. the unmapped "1000") would otherwise drift |
|||
# from that contract → coerce to int. Non-digit/None left untouched. |
|||
v = cfg.get("can_baudrate") |
|||
if isinstance(v, str) and v.strip().isdigit(): |
|||
cfg["can_baudrate"] = int(v.strip()) |
|||
return cfg |
|||
|
|||
db.update_config("device_config", _mut, default={}) |
|||
db.set_schema_meta("can_baudrate_units_migrated", "true") |
|||
return True |
|||
|
|||
|
|||
# v1.4.6.7 C-1: Python 전용 키가 device_config에 잘못 들어가 있던 .56 라이브 Phase 1 노출. |
|||
# LOG_CONFIG_KEYS 확장과 함께 DB에 이미 잔존한 키를 log_config로 이동. |
|||
_LOG_COMPRESS_KEYS = ("log_compress_size_mb", "log_compress_age_days") |
|||
|
|||
|
|||
def migrate_log_compress_split(db) -> bool: |
|||
"""Move `log_compress_size_mb` / `log_compress_age_days` out of `device_config` |
|||
into `log_config`. Existing values in `log_config` win (no overwrite).""" |
|||
if db.get_schema_meta("log_compress_split_migrated") == "true": |
|||
return False |
|||
|
|||
# Snapshot the keys to move (these are Python-only keys dpworldapp doesn't |
|||
# write, so a brief read before the atomic updates is not a race surface; |
|||
# the race that mattered was clobbering OTHER device_config keys on write, |
|||
# which the atomic pop below now avoids). |
|||
dev = db.get_config("device_config") or {} |
|||
if not isinstance(dev, dict): |
|||
db.set_schema_meta("log_compress_split_migrated", "true") |
|||
return True |
|||
|
|||
moved = {k: dev[k] for k in _LOG_COMPRESS_KEYS if k in dev} |
|||
|
|||
if moved: |
|||
# v1.4.6.8 C4: write order — log_config FIRST (additive, no data loss). |
|||
# crash between log_config and device_config: log_config safe, device_config |
|||
# keys still present → next startup can re-migrate (idempotent: `if k not in cur`). |
|||
# v1.9.1 M-1: both writes are now atomic update_config RMW so concurrent |
|||
# writers to OTHER device_config keys are preserved. |
|||
|
|||
# FIRST: additive write to log_config — existing keys win. |
|||
db.update_config( |
|||
"log_config", |
|||
lambda cur: {**(cur if isinstance(cur, dict) else {}), |
|||
**{k: v for k, v in moved.items() |
|||
if k not in (cur if isinstance(cur, dict) else {})}}, |
|||
default={}, |
|||
) |
|||
|
|||
# THEN: atomically pop the log_compress_* keys from CURRENT device_config |
|||
# (reads fresh inside the transaction, so concurrent changes to other keys |
|||
# are preserved — only the two log_compress_* keys are removed). |
|||
db.update_config( |
|||
"device_config", |
|||
lambda cur: {k: v for k, v in (cur if isinstance(cur, dict) else {}).items() |
|||
if k not in _LOG_COMPRESS_KEYS}, |
|||
default={}, |
|||
) |
|||
|
|||
db.set_schema_meta("log_compress_split_migrated", "true") |
|||
return True |
|||
|
|||
|
|||
# v1.4.6.7 C-2: frontend convertNestedToFlat 회귀로 ports가 string으로 저장된 .56 라이브 Phase 4 노출. |
|||
# dha baseline ports 모두 Integer 확정 (verify-before-asserting). 운영 DB normalize. |
|||
_PORT_KEYS = ( |
|||
"protocol_server_port", "update_server_port", "rtcm_server_port", |
|||
"opc_ua_server_port", "modbus_server_port", "lte_server_port", |
|||
) |
|||
|
|||
|
|||
def migrate_port_types(db) -> bool: |
|||
"""Convert any string-typed port in `device_config` to Integer. |
|||
dha baseline confirms Java schema Integer (2026-06-04 verify).""" |
|||
if db.get_schema_meta("port_types_migrated") == "true": |
|||
return False |
|||
|
|||
def _mut(cur): |
|||
cfg = dict(cur) if isinstance(cur, dict) else {} |
|||
for k in _PORT_KEYS: |
|||
v = cfg.get(k) |
|||
if isinstance(v, str) and v.strip().lstrip("-").isdigit(): |
|||
cfg[k] = int(v) |
|||
return cfg |
|||
|
|||
db.update_config("device_config", _mut, default={}) |
|||
db.set_schema_meta("port_types_migrated", "true") |
|||
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. |
|||
""" |
|||
if db.get_schema_meta("contract_canonical_migrated") == "true": |
|||
return False |
|||
|
|||
# device_config: rs485_parity "no" → "none" |
|||
def _mut_device(cur): |
|||
cfg = dict(cur) if isinstance(cur, dict) else {} |
|||
if cfg.get("rs485_parity") == "no": |
|||
cfg["rs485_parity"] = "none" |
|||
return cfg |
|||
|
|||
db.update_config("device_config", _mut_device, default={}) |
|||
|
|||
# protocol_config: byte_order camelCase → space + idt float64 → float |
|||
_BYTE_ORDER_MAP = {"littleSwap": "little swap", "bigSwap": "big swap"} |
|||
|
|||
def _mut_protocol(cur): |
|||
cfg = dict(cur) if isinstance(cur, dict) else {} |
|||
for bo_key in ("two_byte_order", "four_byte_order"): |
|||
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): |
|||
continue |
|||
new_arr = [] |
|||
for entry in arr: |
|||
if isinstance(entry, dict) and entry.get("idt") == "float64": |
|||
entry = dict(entry) |
|||
entry["idt"] = "float" |
|||
new_arr.append(entry) |
|||
cfg[arr_key] = new_arr |
|||
return cfg |
|||
|
|||
db.update_config("protocol_config", _mut_protocol, default={}) |
|||
|
|||
db.set_schema_meta("contract_canonical_migrated", "true") |
|||
return True |
|||
|
|||
|
|||
# AP: seed — begin |
|||
def migrate_seed_ap_config(db) -> bool: |
|||
"""ap_config 키 부재 시 기본값 1회 seed (spec §5.1). 존재하면 no-op.""" |
|||
from network.ap_model import DEFAULT_AP_CONFIG |
|||
if db.get_config("ap_config") is not None: |
|||
return False |
|||
db.save_config("ap_config", dict(DEFAULT_AP_CONFIG)) |
|||
return True |
|||
# AP: seed — end |
|||
|
|||
_MIGRATIONS = ( |
|||
("can_baudrate_units", migrate_can_baudrate_units), |
|||
("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 |
|||
) |
|||
|
|||
|
|||
def apply_all_migrations(db) -> list: |
|||
"""Run all pending migrations in order. Returns list of migration names |
|||
actually applied (for startup logging). |
|||
|
|||
v1.4.6.9 H11: per-step 로깅 — partial-failure 시 어디서 멈췄는지 식별 가능. |
|||
이전에는 마지막 print 직전에 raise 시 어떤 migration이 적용됐는지 unknown. |
|||
Exception은 재전파 (caller server.py가 fail-soft wrap).""" |
|||
applied = [] |
|||
for name, fn in _MIGRATIONS: |
|||
try: |
|||
if fn(db): |
|||
applied.append(name) |
|||
print(f"[migration] {name}: applied", flush=True) |
|||
except Exception: |
|||
print(f"[migration] {name}: failed (re-raise to caller)", flush=True) |
|||
raise |
|||
return applied |
|||
@ -0,0 +1,151 @@ |
|||
# src/network/ap_engine.py |
|||
"""AP apply 오케스트레이션 (spec §7/§8). 렌더 파일 작성 + dpworld-ap-apply.service 트리거 + 상태 persist.""" |
|||
import json, os, threading |
|||
from network.ap_model import ap_intent_from_db |
|||
from network.ap_validator import validate_ap |
|||
from network.ap_renderer import render_hostapd, render_udhcpd |
|||
|
|||
def _default_runner(argv, timeout): |
|||
import subprocess |
|||
try: |
|||
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) |
|||
return (p.returncode, (p.stdout or "") + (p.stderr or "")) |
|||
except (subprocess.SubprocessError, OSError) as e: # _net_run 패리티 — 예외를 rc로 흡수 |
|||
return (1, str(e)) |
|||
|
|||
|
|||
def parse_iw_link_channel(out): |
|||
"""`iw dev wlan0 link` 출력의 freq → (channel, hw_mode). 미연결/파싱불가 → (None, None).""" |
|||
freq = None |
|||
for ln in (out or "").splitlines(): |
|||
t = ln.strip() |
|||
if t.startswith("freq:"): |
|||
try: |
|||
freq = int(float(t.split(":", 1)[1].strip())) |
|||
except ValueError: |
|||
return (None, None) |
|||
break |
|||
if not freq: |
|||
return (None, None) |
|||
if 2412 <= freq <= 2472: |
|||
return ((freq - 2407) // 5, "g") |
|||
if freq == 2484: |
|||
return (14, "g") |
|||
if 5000 < freq < 6000: |
|||
return ((freq - 5000) // 5, "a") |
|||
return (None, None) |
|||
|
|||
|
|||
# country 별 2.4GHz 기본 채널(MCC opt-in 시) |
|||
_DEFAULT_2G = 6 |
|||
|
|||
class ApEngine: |
|||
def __init__(self, ap_dir, state_path, runner=None, |
|||
live_country=None, sta_channel=None, country_pending=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._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 명시)는 옵트인.""" |
|||
sta_ch, sta_hw = self._sta_channel() |
|||
band = intent.get("ap_band", "auto") |
|||
if band == "auto": |
|||
if sta_ch: |
|||
return (sta_ch, sta_hw) # SCC |
|||
return (_DEFAULT_2G, "g") # STA 없음 → 2.4G 기본 |
|||
# ap_channel 0(DEFAULT)=auto 센티넬 → 'or'가 기본/STA 채널을 채움(의도된 동작) |
|||
if band == "2g": |
|||
return (intent.get("ap_channel") or _DEFAULT_2G, "g") |
|||
return (intent.get("ap_channel") or (sta_ch or 36), "a") |
|||
|
|||
def _persist(self, state, intent): |
|||
try: |
|||
with open(self.state_path, "w", encoding="utf-8") as f: |
|||
json.dump({"state": state, "ap_enabled": intent.get("ap_enabled", False)}, f) |
|||
except OSError: |
|||
pass |
|||
|
|||
def _write(self, name, content): |
|||
os.makedirs(self.ap_dir, exist_ok=True) |
|||
try: |
|||
os.chmod(self.ap_dir, 0o700) # 평문 PSK 보호 (네트워크 엔진 기조와 동일) |
|||
except OSError: |
|||
pass |
|||
tmp = os.path.join(self.ap_dir, name + ".tmp") |
|||
dst = os.path.join(self.ap_dir, name) |
|||
try: |
|||
with open(tmp, "w", encoding="utf-8") as f: # non-ASCII SSID safe |
|||
f.write(content) |
|||
os.replace(tmp, dst) |
|||
except OSError: |
|||
try: |
|||
os.remove(tmp) # don't leak a partial .tmp on write failure |
|||
except OSError: |
|||
pass |
|||
raise |
|||
try: |
|||
os.chmod(dst, 0o600) # hostapd-ap0.conf 등 PSK 포함 → 소유자 전용 |
|||
except OSError: |
|||
pass |
|||
|
|||
def apply(self, ap_fields): |
|||
# Serialize: concurrent applies would race the fixed tmp file and |
|||
# double-start dpworld-ap-apply.service. |
|||
with self._lock: |
|||
intent = ap_intent_from_db(ap_fields) |
|||
errors = validate_ap(intent, self._live_country(), self._country_pending()) |
|||
if errors: |
|||
self._persist("REJECTED", intent) |
|||
return {"state": "REJECTED", "errors": errors} |
|||
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())) |
|||
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) |
|||
try: |
|||
with open(marker, "w"): |
|||
pass |
|||
except OSError as exc: |
|||
self._persist("FAILED", intent) |
|||
return {"state": "FAILED", "errors": [f"marker write failed: {exc}"]} |
|||
else: |
|||
# TOCTOU-safe: the marker may vanish between exists() and remove() |
|||
# (concurrent disable) — a successful disable must not 500. |
|||
try: |
|||
os.remove(marker) |
|||
except FileNotFoundError: |
|||
pass |
|||
rc, out = self.runner(["systemctl", "start", "dpworld-ap-apply.service"], 95) # ≥ 유닛 TimeoutStartSec(90) |
|||
if rc != 0: |
|||
self._persist("FAILED", intent) |
|||
detail = (out or "").strip() or "dpworld-ap-apply.service failed" |
|||
return {"state": "FAILED", "errors": [detail]} |
|||
self._persist("COMMITTED", intent) |
|||
return {"state": "COMMITTED", "errors": []} |
|||
|
|||
def status(self): |
|||
"""라이브 AP 상태 종합 (spec §9.1). runner로 iw/systemctl 조회.""" |
|||
marker = os.path.join(self.ap_dir, "ap-enabled") |
|||
ap_enabled = os.path.exists(marker) |
|||
rc_info, _ = self.runner(["iw", "dev", "ap0", "info"], 5) |
|||
ap0_up = (rc_info == 0) |
|||
_rc_h, out_h = self.runner(["systemctl", "is-active", "dpworld-hostapd-ap0.service"], 5) |
|||
hostapd_running = (out_h or "").strip() == "active" |
|||
clients = 0 |
|||
rc_s, out_s = self.runner(["iw", "dev", "ap0", "station", "dump"], 5) |
|||
if rc_s == 0: |
|||
clients = sum(1 for ln in (out_s or "").splitlines() if ln.strip().startswith("Station ")) |
|||
return { |
|||
"ap_enabled": ap_enabled, |
|||
"ap0_up": ap0_up, |
|||
"hostapd_running": hostapd_running, |
|||
"clients": clients, |
|||
"country_pending": bool(self._country_pending()), |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
# src/network/ap_model.py |
|||
"""DB ap_config ↔ normalized intent (spec §5). 별도 board_config 키 — device_config 무관.""" |
|||
|
|||
DEFAULT_AP_CONFIG = { |
|||
"ap_enabled": False, "ap_ssid": "", "ap_passphrase": "", |
|||
"ap_band": "auto", "ap_channel": 0, "ap_hidden": False, |
|||
"ap_ip": "192.168.50.1", |
|||
"dhcp_start": "192.168.50.50", "dhcp_end": "192.168.50.150", "dhcp_lease": 43200, |
|||
} |
|||
|
|||
def _s(v): |
|||
return "" if v is None else str(v).strip() |
|||
|
|||
def _int(v, default): |
|||
try: |
|||
return int(str(v).strip()) |
|||
except (ValueError, TypeError): |
|||
return default |
|||
|
|||
def _int_for_validation(v, default): |
|||
if v is None: |
|||
return None |
|||
try: |
|||
return int(str(v).strip()) |
|||
except (ValueError, TypeError): |
|||
return _s(v) |
|||
|
|||
def _bool(v): |
|||
if isinstance(v, bool): |
|||
return v |
|||
return str(v).strip().lower() in ("1", "true", "yes", "on") |
|||
|
|||
def _field(c, key): |
|||
if isinstance(c, dict) and key in c: |
|||
return c[key] |
|||
return DEFAULT_AP_CONFIG[key] |
|||
|
|||
def _text_field(c, key): |
|||
v = _field(c, key) |
|||
return None if v is None else _s(v) |
|||
|
|||
def ap_intent_from_db(ap_config): |
|||
c = ap_config if isinstance(ap_config, dict) else {} |
|||
return { |
|||
"ap_enabled": _bool(_field(c, "ap_enabled")), |
|||
"ap_ssid": _text_field(c, "ap_ssid") or "", |
|||
"ap_passphrase": _text_field(c, "ap_passphrase") or "", |
|||
"ap_band": (_text_field(c, "ap_band") or "").lower(), |
|||
"ap_channel": _int_for_validation(_field(c, "ap_channel"), 0), |
|||
"ap_hidden": _bool(_field(c, "ap_hidden")), |
|||
"ap_ip": _text_field(c, "ap_ip"), |
|||
"dhcp_start": _text_field(c, "dhcp_start"), |
|||
"dhcp_end": _text_field(c, "dhcp_end"), |
|||
"dhcp_lease": _int(_field(c, "dhcp_lease"), 43200), |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
# 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): |
|||
hidden = 1 if intent.get("ap_hidden") else 0 |
|||
return ( |
|||
"interface=ap0\n" |
|||
"driver=nl80211\n" |
|||
f"ssid={intent['ap_ssid']}\n" |
|||
f"hw_mode={hw_mode}\n" |
|||
f"channel={channel}\n" |
|||
f"country_code={country}\n" |
|||
"ieee80211d=1\n" |
|||
"beacon_int=100\n" |
|||
"wmm_enabled=1\n" |
|||
"auth_algs=1\n" |
|||
"wpa=2\n" |
|||
"wpa_key_mgmt=WPA-PSK\n" |
|||
"rsn_pairwise=CCMP\n" |
|||
f"wpa_passphrase={intent['ap_passphrase']}\n" |
|||
f"ignore_broadcast_ssid={hidden}\n" |
|||
"ctrl_interface=/var/run/hostapd\n" |
|||
) |
|||
|
|||
def render_udhcpd(intent): |
|||
return ( |
|||
"interface ap0\n" |
|||
f"start {intent['dhcp_start']}\n" |
|||
f"end {intent['dhcp_end']}\n" |
|||
"opt subnet 255.255.255.0\n" |
|||
f"opt router {intent['ap_ip']}\n" |
|||
f"opt lease {intent['dhcp_lease']}\n" |
|||
"opt dns 8.8.8.8\n" |
|||
"lease_file /run/udhcpd-ap0.leases\n" |
|||
"pidfile /run/udhcpd-ap0.pid\n" |
|||
) |
|||
@ -0,0 +1,54 @@ |
|||
# src/network/ap_routes.py |
|||
"""AP 라우트 글루 (spec §9). 네트워크 apply와 분리 — AP 필드 자체 화이트리스트.""" |
|||
from network.ap_model import DEFAULT_AP_CONFIG |
|||
|
|||
class RouteError(Exception): |
|||
def __init__(self, status, message): |
|||
super().__init__(message); self.status = status; self.message = message |
|||
|
|||
AP_FIELDS = frozenset(DEFAULT_AP_CONFIG.keys()) |
|||
|
|||
class ApRoutes: |
|||
def __init__(self, engine, db): |
|||
self.engine = engine; self.db = db |
|||
|
|||
def config(self, body): |
|||
if not isinstance(body, dict): |
|||
raise RouteError(400, "body must be an object") |
|||
unknown = sorted(k for k in body if k not in AP_FIELDS) |
|||
if unknown: |
|||
raise RouteError(400, "unknown AP field(s): " + ", ".join(unknown)) |
|||
clean = {k: v for k, v in body.items() if k in AP_FIELDS} |
|||
# Text fields must be strings — a non-string (e.g. list) passphrase |
|||
# would corrupt the rendered hostapd conf (DoS). |
|||
for k in ("ap_ssid", "ap_passphrase", "ap_ip", "dhcp_start", "dhcp_end", "ap_band"): |
|||
if k in clean and not isinstance(clean[k], str): |
|||
raise RouteError(400, f"{k} must be a string") |
|||
|
|||
def _merge(current): |
|||
cur = dict(current) if isinstance(current, dict) else {} |
|||
cur.update(clean) |
|||
return cur |
|||
|
|||
self.db.update_config("ap_config", _merge, default={}) |
|||
return {"ok": True, "saved": sorted(clean.keys())} |
|||
|
|||
def apply(self, body): |
|||
if not isinstance(body, dict): |
|||
raise RouteError(400, "body must be an object") |
|||
cur = self.db.get_config("ap_config") or dict(DEFAULT_AP_CONFIG) |
|||
if body.get("dry_run"): |
|||
# Never leak the WPA2 PSK over the unauthenticated API (response only; |
|||
# storage keeps it — frontend treats a blank field as "keep existing"). |
|||
safe = dict(cur) |
|||
safe.pop("ap_passphrase", None) |
|||
return {"ok": True, "dry_run": True, "config": safe} |
|||
return self.engine.apply(cur) |
|||
|
|||
def status(self): |
|||
st = dict(self.engine.status()) |
|||
cfg = {**DEFAULT_AP_CONFIG, **(self.db.get_config("ap_config") or {})} |
|||
# Strip the WPA2 PSK before returning (unauthenticated API). |
|||
cfg.pop("ap_passphrase", None) |
|||
st["config"] = cfg |
|||
return st |
|||
@ -0,0 +1,71 @@ |
|||
# src/network/ap_validator.py |
|||
"""AP hard-reject 규칙 (spec §5.1/§6/§7). 사용자 표시 메시지는 영어(웹 UI 일관성).""" |
|||
import ipaddress |
|||
|
|||
AP_SSID_MAX = 32 # hostapd SSID 한계 (byte). STA(19, dpworldapp 버퍼)와 다름. |
|||
PSK_MIN, PSK_MAX = 8, 63 # WPA2-PSK passphrase (byte) |
|||
FORBIDDEN = ('"', "\\", "#") # '#' starts an inline comment in hostapd conf |
|||
AP_BANDS = frozenset({"auto", "2g", "5g"}) |
|||
CHANNELS_2G = frozenset(range(1, 14)) |
|||
CHANNELS_5G = frozenset({36, 40, 44, 48, 149, 153, 157, 161, 165}) |
|||
|
|||
def _blen(s): |
|||
return len(s.encode("utf-8")) |
|||
|
|||
def _ip(s): |
|||
try: |
|||
return ipaddress.IPv4Address(s) |
|||
except (ValueError, TypeError): |
|||
return None |
|||
|
|||
def validate_ap(intent, live_country, country_pending): |
|||
e = [] |
|||
if not intent.get("ap_enabled"): |
|||
return e # 비활성이면 필드 검증 생략 |
|||
ssid = str(intent.get("ap_ssid", "")) |
|||
if not ssid: |
|||
e.append("SSID is required.") |
|||
elif _blen(ssid) > AP_SSID_MAX: |
|||
e.append(f"SSID must be at most {AP_SSID_MAX} bytes.") |
|||
if any(c in ssid for c in FORBIDDEN): |
|||
e.append('SSID cannot contain ", \\, or #.') |
|||
psk = str(intent.get("ap_passphrase", "")) |
|||
if not (PSK_MIN <= _blen(psk) <= PSK_MAX): |
|||
e.append(f"Password must be {PSK_MIN}-{PSK_MAX} bytes.") |
|||
if any(c in psk for c in FORBIDDEN): # '#' truncates the hostapd WPA key |
|||
e.append('Password cannot contain ", \\, or #.') |
|||
if any(ord(c) < 0x20 for c in (ssid + psk)): # control chars (e.g. newline) → block hostapd conf injection |
|||
e.append("SSID/password cannot contain control characters (e.g. newline).") |
|||
band = str(intent.get("ap_band", "auto")).lower() |
|||
channel = intent.get("ap_channel", 0) |
|||
if band not in AP_BANDS: |
|||
e.append("AP band must be auto, 2g, or 5g.") |
|||
elif channel not in (0, None): |
|||
if band == "auto": |
|||
e.append("AP channel must be auto when AP band is auto.") |
|||
elif band == "2g" and channel not in CHANNELS_2G: |
|||
e.append("2.4 GHz AP channel is invalid.") |
|||
elif band == "5g" and channel not in CHANNELS_5G: |
|||
e.append("5 GHz AP channel is invalid.") |
|||
ap_ip = _ip(intent.get("ap_ip", "")) |
|||
s = _ip(intent.get("dhcp_start", "")); d = _ip(intent.get("dhcp_end", "")) |
|||
if not ap_ip: |
|||
e.append("AP IP address is invalid.") |
|||
if not s: |
|||
e.append("DHCP start address is invalid.") |
|||
if not d: |
|||
e.append("DHCP end address is invalid.") |
|||
if ap_ip and s and d: |
|||
net = ipaddress.IPv4Network(f"{ap_ip}/24", strict=False) |
|||
if s not in net or d not in net: |
|||
e.append("DHCP range is outside the AP subnet (/24).") |
|||
if int(s) > int(d): |
|||
e.append("DHCP start is greater than DHCP end.") |
|||
if s <= ap_ip <= d: |
|||
e.append("DHCP range overlaps the AP IP.") |
|||
lc = str(live_country or "").upper() |
|||
if country_pending: |
|||
e.append("Reboot required to apply the country code before enabling the AP.") |
|||
elif not (len(lc) == 2 and lc.isalpha()): |
|||
e.append("Cannot enable AP: no valid country regdomain.") |
|||
return e |
|||
@ -0,0 +1,723 @@ |
|||
"""Apply 상태머신 (spec §5/§6). 단일 in-flight, 기준선=적용본(§1.2), DB-first 쓰기, |
|||
엔진 자체 confirm TTL 타이머(§6.1), 크래시 복구, country deferred/즉시(전문가 §6.2).""" |
|||
import json, os, threading, time |
|||
|
|||
from network import netmodel, renderer, snapshot, validator |
|||
|
|||
CONFIRM_TTL_S = 90 # §6.1 |
|||
APPLY_SERVICE_TIMEOUT = 75 |
|||
_ACTIVE = ("VALIDATING", "SNAPSHOT", "WRITING", "APPLYING", "VERIFYING", "ROLLING_BACK") |
|||
|
|||
def _read_file_default(path): |
|||
try: |
|||
with open(path, encoding="utf-8") as f: |
|||
return f.read() |
|||
except OSError: |
|||
return "" |
|||
|
|||
def _restrict(path, mode): |
|||
"""#5/#10: best-effort 권한 제한 — apply_state.json 은 db_fields(wifi_passwd) 미포함이나 |
|||
일관성·심층방어로 owner-only. Windows 에선 효과 제한적이나 raise 하지 않음.""" |
|||
try: |
|||
os.chmod(path, mode) |
|||
except OSError: |
|||
pass |
|||
|
|||
def _default_timer_factory(delay, fn): |
|||
t = threading.Timer(delay, fn) |
|||
t.daemon = True # minor: 미확정 confirm 타이머가 프로세스 종료를 막으면 안 됨 |
|||
return t |
|||
|
|||
class ApplyEngine: |
|||
def __init__(self, db, net_dir, backups_dir, state_path, journal, runner, |
|||
clock=time.monotonic, verify_fn=None, timer_factory=None, |
|||
exists=None, read_file=None): |
|||
self.db = db; self.net_dir = net_dir; self.backups_dir = backups_dir |
|||
self.state_path = state_path; self.journal = journal |
|||
self.runner = runner; self.clock = clock |
|||
from network import verifier as _v |
|||
self.verify_fn = verify_fn or (lambda intent, ifaces, **k: _v.verify(intent, ifaces, runner=runner)) |
|||
self.timer_factory = timer_factory or _default_timer_factory |
|||
self.exists = exists or os.path.exists |
|||
self.read_file = read_file or _read_file_default |
|||
self._lock = threading.Lock() |
|||
self._cur = None # 진행 중/최근 apply 의 state dict |
|||
self._steps = [] # status 폴링용 [{phase,result,detail,t}] |
|||
self._confirm_timer = None |
|||
self._thread = None # apply_async 의 작업 스레드 |
|||
self._seq = 0 # minor: apply_id 동일 초 충돌 방지 (단조 증가, _lock 하 증가) |
|||
self._sleep = time.sleep # I7: 주입 가능한 sleep — 테스트는 no-op 으로 교체 |
|||
self._prior_country_pending = False # I2: _begin 에서 직전 persist 값 보관 |
|||
os.makedirs(backups_dir, exist_ok=True) |
|||
|
|||
# ── helpers ────────────────────────────────────────────── |
|||
def _step(self, phase, result, detail="", apply_id=None): |
|||
self._steps.append({"phase": phase, "result": result, "detail": detail, |
|||
"t": time.strftime("%H:%M:%S")}) |
|||
self.journal.event("apply", phase=phase, action="step", result=result, |
|||
apply_id=apply_id, detail={"detail": detail}) |
|||
|
|||
def _persist(self): |
|||
"""C1: tmp 이름 per-call 고유 (worker/timer 동시 persist 충돌 방지) + fail-soft — |
|||
persist 실패가 apply 흐름/I9 finally 를 죽이면 안 됨 (저널 기록만).""" |
|||
tmp = self.state_path + f".tmp{os.getpid()}-{threading.get_ident()}" |
|||
try: |
|||
with open(tmp, "w", encoding="utf-8") as f: |
|||
json.dump(self._cur, f, ensure_ascii=False) |
|||
os.replace(tmp, self.state_path) |
|||
_restrict(self.state_path, 0o600) # #5/#10: owner-only (심층방어) |
|||
except OSError as e: |
|||
self.journal.event("apply", phase=(self._cur or {}).get("state", "?"), |
|||
action="persist_failed", result="fail", |
|||
apply_id=(self._cur or {}).get("apply_id"), |
|||
detail={"error": str(e)}) |
|||
|
|||
def _claim(self, from_states, to_state): |
|||
"""C1: _lock 하 CAS 전이 — 현재 state 가 from_states 일 때만 to_state 로. 승자만 True. |
|||
confirm vs TTL 타이머처럼 같은 상태를 두 주체가 소비하는 race 의 단일 결정점.""" |
|||
with self._lock: |
|||
if self._cur and self._cur["state"] in from_states: |
|||
self._cur["state"] = to_state |
|||
self._persist() |
|||
return True |
|||
return False |
|||
|
|||
def _boot_id(self): |
|||
return self.read_file("/proc/sys/kernel/random/boot_id").strip() or "unknown" |
|||
|
|||
def _baseline_intent(self): |
|||
"""★ diff 기준선 = 적용본 network_config.json (§1.2). 부재/손상 → 전부-변경 취급. |
|||
DB vs DB 비교 금지 — Save 가 이미 DB 를 갱신하므로 Save→Apply 가 항상 NOOP 이 된다.""" |
|||
try: |
|||
with open(os.path.join(self.net_dir, "network_config.json"), encoding="utf-8") as f: |
|||
return renderer.intent_from_persist(json.load(f)) |
|||
except (OSError, ValueError): |
|||
return netmodel.intent_from_device({}) |
|||
|
|||
def _write_renders(self, intent, wpa_country=None): |
|||
# v1.9: wpa_country 지정 시 wpa conf 만 그 country 로 렌더(split-apply — country 변경을 다른 |
|||
# 변경과 함께 적용할 때 wpa_cli reconfigure 가 새 regdomain 을 라이브로 새지 않게). 나머지 |
|||
# 파일(wifi-country-code 마커·persist)은 intent(NEW) → country 는 reboot 시 firmware 적용. |
|||
wpa_it = intent |
|||
if wpa_country is not None: |
|||
wpa_it = {**intent, "wlan0": {**intent["wlan0"], "country_code": wpa_country}} |
|||
os.makedirs(self.net_dir, exist_ok=True) |
|||
for name, fn in renderer.RENDER_FILES.items(): |
|||
body = fn(wpa_it if name == "wpa_supplicant-wlan0.conf" else intent) |
|||
dst = os.path.join(self.net_dir, name) |
|||
if name == "wifi-country-code" and body == "": |
|||
# I6: country 해제 — 기존 파일이 있으면 제거 (stale country 잔존 차단) |
|||
try: |
|||
os.remove(dst) |
|||
except FileNotFoundError: |
|||
pass |
|||
continue |
|||
tmp = dst + ".tmp" |
|||
with open(tmp, "w", encoding="utf-8", newline="") as f: |
|||
f.write(body) |
|||
os.replace(tmp, dst) |
|||
|
|||
def _db_network_fields(self): |
|||
"""I1: 스냅샷용 DB 필드 — 부재 키도 기록 (fmt 2). 롤백이 present 복원 + absent pop |
|||
하도록 하여, merge 복원이 '없던 키를 부활'시키는 결함을 차단. |
|||
v1.6.0 codex H1: 키 목록은 netmodel.NETWORK_DEV_KEYS 단일 출처에서 파생 — |
|||
net_routes write-allowlist 와 snapshot-keys 간 drift 차단 (정렬로 결정론적 출력).""" |
|||
dev = self.db.get_config("device_config") or {} |
|||
keys = sorted(netmodel.NETWORK_DEV_KEYS) |
|||
return {"fmt": 2, |
|||
"present": {k: dev[k] for k in keys if k in dev}, |
|||
"absent": [k for k in keys if k not in dev]} |
|||
|
|||
def _write_db(self, fields): |
|||
def mut(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, default={}) |
|||
|
|||
def _write_db_restore(self, present, absent): |
|||
"""I1: 롤백 전용 — present 키 복원 + 스냅샷 시점 부재 키 pop.""" |
|||
def mut(cur): |
|||
base = cur if isinstance(cur, dict) else {} |
|||
out = dict(base); out.update(present) |
|||
for k in absent: |
|||
out.pop(k, None) |
|||
return out |
|||
self.db.update_config("device_config", mut, default={}) |
|||
|
|||
def _run(self, argv, timeout, apply_id, must_ok=False): |
|||
t0 = self.clock() |
|||
rc, out = self.runner(argv, timeout) |
|||
self.journal.event("apply", phase="APPLYING", action=" ".join(argv[:3]), |
|||
result="ok" if rc == 0 else "fail", apply_id=apply_id, |
|||
duration_ms=int((self.clock() - t0) * 1000), detail={"rc": rc}) |
|||
if must_ok and rc != 0: |
|||
raise RuntimeError(f"{argv[0]} rc={rc}") |
|||
return rc, out |
|||
|
|||
def _cancel_timer(self): |
|||
if self._confirm_timer is not None: |
|||
try: |
|||
self._confirm_timer.cancel() |
|||
except Exception: # noqa: BLE001 |
|||
pass |
|||
self._confirm_timer = None |
|||
|
|||
# AP: public accessor reused by ApEngine wiring (avoids calling _live_country directly) |
|||
def live_country(self, fast=False): |
|||
return self._live_country(fast=fast) |
|||
|
|||
def _live_country(self, fast=False): |
|||
"""라디오 실효 country (ground truth) — sysfs 모듈 파라미터 우선, iw reg 폴백. |
|||
★ wpa_cli get country 는 wpa conf 렌더값일 뿐 라디오 실효값이 아님 → 사용 금지(거짓 양성). |
|||
하드닝 스크립트 live_country() 와 동일 출처. 미상이면 "" (게이팅 diff 폴백·country_now verify). |
|||
#9: fast=True → iw reg 단발(재시도 없음, hung 차단). 실 apply 경로는 fast=False 재시도.""" |
|||
try: |
|||
if self.exists("/sys/module/wlan/parameters/country_code"): |
|||
s = (self.read_file("/sys/module/wlan/parameters/country_code") or "").strip().upper() |
|||
if len(s) == 2 and s.isalpha() and s != "00": |
|||
return s |
|||
except Exception: # noqa: BLE001 — fail-soft → iw reg 폴백 |
|||
pass |
|||
attempts = 1 if fast else 3 |
|||
for attempt in range(attempts): |
|||
rc, out = self.runner(["iw", "reg", "get"], 5) |
|||
if rc == 0: |
|||
for ln in (out or "").splitlines(): |
|||
t = ln.strip().upper() |
|||
# "country XX:" — phy 실효 라인. global "country 00" 스킵. |
|||
if t.startswith("COUNTRY ") and len(t) >= 10 and t[8:10].isalpha() and t[8:10] != "00": |
|||
return t[8:10] |
|||
if attempt < attempts - 1: |
|||
self._sleep(2) |
|||
return "" |
|||
|
|||
def _read_persisted_pending(self): |
|||
"""I2: persist 된 country_pending 읽기 — fail-soft.""" |
|||
try: |
|||
with open(self.state_path, encoding="utf-8") as f: |
|||
st = json.load(f) |
|||
return bool(st.get("country_pending")) if isinstance(st, dict) else False |
|||
except (OSError, ValueError): |
|||
return False |
|||
|
|||
def _country_gate_errors(self, diff, new_it, country_now, prior_pending, fast=False): |
|||
"""C2/§6.2 게이트 — _run_machine 과 dry_run 공용 (분기 분산 금지). |
|||
returns (errors, country_changed). 게이팅은 라이브 실효 변경 기준, live 미상이면 diff 폴백. |
|||
#9: fast 는 _live_country 로 전달 — dry_run(동기 HTTP)은 fast=True 단일 시도.""" |
|||
errs = [] |
|||
target_cc = new_it["wlan0"]["country_code"] |
|||
live_cc = self._live_country(fast=fast) |
|||
# v1.11.9 Fix 4: when live country is KNOWN, clearing it ('') is symmetric |
|||
# with setting it — any target != live (incl. an empty target) is a real |
|||
# change and must engage the wpa-restart/regdomain verify + country_pending. |
|||
# Previously `bool(target_cc) and ...` short-circuited a clear to False. |
|||
# NOTE: the live-UNKNOWN fallback intentionally keeps `bool(target_cc) and` |
|||
# so a CLEAR with no known live is NOT deferred (v1.9.1 FIX 2a decision; |
|||
# guarded by test_country_gate_errors_clear_not_deferred and the |
|||
# clear→wpa-reconfigure path in test_net_apply_engine). |
|||
if live_cc: |
|||
country_changed = target_cc != live_cc |
|||
else: |
|||
country_changed = bool(target_cc) and any(d["field"] == "wlan0.country_code" for d in diff) |
|||
if prior_pending and not country_now and diff: |
|||
# C2: 보류 country 가 있는 동안 추가 적용 동결 — deferral 분기로 새는 silent commit 차단 |
|||
errs.append("country 변경이 재부팅 대기 중 (§6.2) — 추가 적용은 재부팅 후에, 또는 " |
|||
"'country 지금 적용'(country_now) 동의로 보류분과 함께 적용") |
|||
# v1.9: mixed country+other (not country_now) 는 더이상 거절하지 않는다 — split-apply 로 처리 (호출측 split 판정). |
|||
return errs, country_changed |
|||
|
|||
# ── public API ─────────────────────────────────────────── |
|||
def dry_run(self, new_dev_fields, country_now=False): |
|||
cur = self._baseline_intent() |
|||
try: |
|||
cur_dev = self.db.get_config("device_config") or {} |
|||
except Exception: # noqa: BLE001 — DB 읽기 실패 → 빈 기준 (500 누출 차단) |
|||
return {"diff": [], "errors": ["Cannot read device config"], "warnings": []} |
|||
new = netmodel.intent_from_device({**cur_dev, **new_dev_fields}) |
|||
diff = netmodel.diff_intents(cur, new) |
|||
errs, warns = validator.validate(new) |
|||
errs = list(errs) |
|||
if diff: # minor: 실 적용과 동일 게이트를 미리 노출 — dry_run OK 인데 apply 거절 divergence 방지 |
|||
# #9: dry_run 은 동기 HTTP — fast=True 로 hung wpa_cli HTTP 블록(~19s) 차단 |
|||
try: |
|||
gate_errs, country_changed_dr = self._country_gate_errors(diff, new, country_now, |
|||
self.country_pending(), fast=True) |
|||
except Exception: # noqa: BLE001 — country gate 실패 → 안전 오류 메시지 |
|||
gate_errs = ["Cannot evaluate country gate"] |
|||
errs.extend(gate_errs) |
|||
warnings = list(warns) |
|||
ifaces = netmodel.changed_interfaces(diff) |
|||
if "eth1" in ifaces: warnings.append("eth1_confirm") |
|||
# country_now=True forces a full wpa_supplicant restart for a country |
|||
# change too — surface the same wifi_disrupt warning (honesty). |
|||
if netmodel.wpa_relevant(diff) or ( |
|||
country_now and any(d["field"] == "wlan0.country_code" for d in diff)): |
|||
warnings.append("wifi_disrupt") |
|||
if any(d["field"] == "wlan0.country_code" for d in diff): warnings.append("country_reboot_deferred") |
|||
split_country = (diff and any(d["field"] == "wlan0.country_code" for d in diff) |
|||
and not country_now and len(diff) > 1 and not self.country_pending()) |
|||
if split_country: |
|||
warnings.append("country_split_deferred") |
|||
if any(d["field"] in ("wlan0.dns1", "wlan0.dns2") for d in diff): |
|||
warnings.append("dns_saved_only") # §12-3: dead field — 저장만 됨 |
|||
if self.country_pending(): warnings.append("country_deferred_pending") # §6.2 보류 동반적용 고지 |
|||
return {"diff": diff, "errors": errs, "warnings": warnings} |
|||
|
|||
def _begin(self, force, country_now): |
|||
with self._lock: |
|||
if self._cur and self._cur["state"] in _ACTIVE + ("CONFIRM_WAIT",): |
|||
return {"state": "BUSY", "apply_id": self._cur["apply_id"]} |
|||
# I2: 직전 persist 의 country_pending 보존 — NOOP/FAILED_VALIDATION persist 가 wipe 못 하게 |
|||
self._prior_country_pending = self._read_persisted_pending() |
|||
self._seq += 1 |
|||
apply_id = f"ap-{time.strftime('%Y%m%d-%H%M%S')}-{int(self.clock()) % 1000}-{self._seq}" |
|||
self._steps = [] |
|||
self._cur = {"apply_id": apply_id, "state": "VALIDATING", "force": bool(force), |
|||
"country_now": bool(country_now), |
|||
"started_monotonic": self.clock(), "confirm_deadline_monotonic": None, |
|||
"changed_ifaces": [], "wpa_changed": False, "country_changed": False, |
|||
"country_pending": self._prior_country_pending, |
|||
"snapshot_dir": None, "boot_id": self._boot_id()} |
|||
self._persist() |
|||
return {"state": "STARTED", "apply_id": apply_id} |
|||
|
|||
def apply(self, new_dev_fields, force=False, country_now=False): |
|||
"""동기 실행 — 단위테스트·내부용.""" |
|||
pre = self._begin(force, country_now) |
|||
if pre["state"] == "BUSY": |
|||
return pre |
|||
return self._run_protected(new_dev_fields) |
|||
|
|||
def apply_async(self, new_dev_fields, force=False, country_now=False): |
|||
"""§1.6: apply_id 즉시 반환 후 데몬 스레드 실행 — eth1 IP 변경 시 구 연결로 응답 보장.""" |
|||
pre = self._begin(force, country_now) |
|||
if pre["state"] == "BUSY": |
|||
return pre |
|||
self._thread = threading.Thread(target=self._run_protected, args=(new_dev_fields,), |
|||
daemon=True, name="net-apply") |
|||
self._thread.start() |
|||
return pre # {"state": "STARTED", "apply_id": ...} |
|||
|
|||
def _run_protected(self, new_dev_fields): |
|||
try: |
|||
try: |
|||
return self._run_machine(new_dev_fields) |
|||
except Exception as e: # noqa: BLE001 — 예기치 못한 실패도 롤백 시도 |
|||
aid = self._cur["apply_id"] |
|||
if self._cur.get("snapshot_dir") is None: |
|||
# I3: 스냅샷 전 예외 = 라이브 무접촉 — 거짓 FAILED_CRITICAL/recover 유닛 금지 |
|||
with self._lock: |
|||
self._cur["state"] = "ABORTED" |
|||
self._persist() |
|||
self.journal.event("apply", phase="ABORTED", action="aborted_pre_snapshot", |
|||
result="warn", apply_id=aid, detail={"error": str(e)}) |
|||
self._step("ABORTED", "warn", f"pre-snapshot: {e}", aid) |
|||
return {"state": "ABORTED", "apply_id": aid, "reason": str(e)} |
|||
self._step("ROLLING_BACK", "fail", f"unexpected: {e}", aid) |
|||
claimed = self._claim(_ACTIVE, "ROLLING_BACK") # C1 |
|||
return self._rollback(reason=str(e), claimed=claimed) |
|||
finally: |
|||
# I9: 워커는 절대 active state 로 죽지 않는다 — terminal 보장 (persist 는 fail-soft) |
|||
with self._lock: |
|||
if self._cur and self._cur["state"] in _ACTIVE: |
|||
self._cur["state"] = "FAILED_CRITICAL" |
|||
self.journal.event("apply", phase="FAILED_CRITICAL", |
|||
action="worker_died_mid_state", result="fail", |
|||
apply_id=self._cur.get("apply_id")) |
|||
self._persist() |
|||
|
|||
def _run_machine(self, new_dev_fields): |
|||
cur_dev = self.db.get_config("device_config") or {} |
|||
cur_it = self._baseline_intent() # ★ 적용본 기준 (§1.2) |
|||
new_it = netmodel.intent_from_device({**cur_dev, **new_dev_fields}) |
|||
aid = self._cur["apply_id"] |
|||
country_now = self._cur["country_now"] |
|||
prior_pending = self._prior_country_pending # I2: _begin 에서 보관 |
|||
# VALIDATING |
|||
diff = netmodel.diff_intents(cur_it, new_it) |
|||
if not diff: |
|||
self._cur["state"] = "NOOP"; self._persist() # country_pending 은 prior 보존 (I2) |
|||
self._step("VALIDATING", "ok", "no change (적용본 == DB)", aid) |
|||
return {"state": "NOOP", "apply_id": aid} |
|||
errs, warns = validator.validate(new_it) |
|||
errs = list(errs) |
|||
# §6.2 게이팅은 "라이브 대비 실효 변경" 기준 — 캡처 확정: apply.service(dpworld-network-apply.sh) |
|||
# 가 country 변경 시 wlan 모듈을 리로드(10-20s 단절)하므로, 단절이 실제 발생할 때만 게이트. |
|||
# 첫 적용(baseline 부재)은 live==DB country 라 게이트 비발동. live 미상이면 diff 기준 보수 폴백. |
|||
# C2: 보류 country 동결 게이트 포함 — dry_run 과 공용 헬퍼 (분기 분산 금지). |
|||
gate_errs, country_changed = self._country_gate_errors(diff, new_it, country_now, prior_pending) |
|||
errs.extend(gate_errs) |
|||
if errs: |
|||
self._cur["state"] = "FAILED_VALIDATION"; self._persist() |
|||
self._step("VALIDATING", "fail", "; ".join(errs), aid) |
|||
return {"state": "FAILED_VALIDATION", "apply_id": aid, "errors": errs} |
|||
# v1.9: mixed country+other (not country_now, prior_pending 아님) → split-apply. |
|||
split_country = (country_changed and not country_now and len(diff) > 1 |
|||
and not prior_pending) |
|||
# I6: country 변경(해제 포함)은 wpa conf 헤더가 바뀌므로 wpa reconfigure 필요 |
|||
wpa_changed = netmodel.wpa_relevant(diff) or any(d["field"] == "wlan0.country_code" for d in diff) |
|||
self._cur.update({"changed_ifaces": netmodel.changed_interfaces(diff), |
|||
"wpa_changed": wpa_changed, |
|||
"country_changed": country_changed, |
|||
"country_target": new_it["wlan0"]["country_code"]}) # #7: 롤백 stuck 검증용 |
|||
self._step("VALIDATING", "ok", |
|||
f"{len(diff)} fields" + (" [force]" if self._cur["force"] else ""), aid) # §5.3 force 명시 |
|||
if any(d["field"] in ("wlan0.dns1", "wlan0.dns2") for d in diff): |
|||
self.journal.event("apply", phase="VALIDATING", action="dns_saved_only", result="warn", |
|||
apply_id=aid, detail={"note": "dns1/dns2 는 현 펌웨어 미적용 (§12-3)"}) |
|||
# SNAPSHOT |
|||
self._cur["state"] = "SNAPSHOT"; self._persist() |
|||
snap = snapshot.take(self.net_dir, self.backups_dir, aid, self._db_network_fields()) |
|||
self._cur["snapshot_dir"] = snap; self._persist() |
|||
self._step("SNAPSHOT", "ok", snap, aid) |
|||
# WRITING — DB-first (§4.1) |
|||
self._cur["state"] = "WRITING"; self._persist() |
|||
self._write_db(new_dev_fields) |
|||
if split_country: |
|||
# wpa conf 는 적용본(OLD) country 로 — 비country 적용 시 regdomain 라이브 누출 방지. |
|||
self._write_renders(new_it, wpa_country=cur_it["wlan0"]["country_code"]) |
|||
self._cur["country_pending"] = True |
|||
else: |
|||
self._write_renders(new_it) |
|||
self._step("WRITING", "ok", "db+json+renders" + (" [split]" if split_country else ""), aid) |
|||
# country-only + 미지정 → deferred (§6.2): apply.service 미기동 |
|||
# C2: deferral 은 diff 가 정확히 country 단독일 때만 — 다른 필드가 이 분기로 새어 |
|||
# "COMMITTED 인데 미적용" 이 되는 lockout hole 차단 (혼합은 위 게이트에서 이미 거절) |
|||
if country_changed and not country_now \ |
|||
and len(diff) == 1 and diff[0]["field"] == "wlan0.country_code": |
|||
self._cur.update({"state": "COMMITTED", "country_pending": True}); self._persist() |
|||
self._lkg_bookkeeping(aid) |
|||
self._step("COMMITTED", "ok", "country deferred — reboot required", aid) |
|||
return {"state": "COMMITTED", "apply_id": aid, "country_pending": True} |
|||
# APPLYING (§5 ①②③) |
|||
self._cur["state"] = "APPLYING"; self._persist() |
|||
# DEF-2a: apply.service 는 best-effort — Requires=dpworld-network-seed.service (boot-only |
|||
# oneshot) のため再부팅 후 온디맨드 호출 시 "Dependency failed" rc=1 (.56 실측). |
|||
# 우리의 render 쓰기 + networkctl reload/reconfigure 가 실제 적용 수단이므로 apply.service |
|||
# 실패는 WARN 기록 후 계속. VERIFY 가 실 게이트 — 진짜 실패 시 verify fail → 롤백. |
|||
rc_apply, _ = self._run(["systemctl", "start", "dpworld-network-apply.service"], |
|||
APPLY_SERVICE_TIMEOUT, aid, must_ok=False) |
|||
if rc_apply != 0: |
|||
self.journal.event("apply", phase="APPLYING", action="apply_service_degraded", |
|||
result="warn", apply_id=aid, |
|||
detail={"rc": rc_apply, "note": "seed dependency — non-fatal, networkctl handles apply"}) |
|||
self._run(["networkctl", "reload"], 15, aid) |
|||
for ifc in self._cur["changed_ifaces"]: |
|||
if ifc in ("wlan0", "eth0", "eth1"): |
|||
self._run(["networkctl", "reconfigure", ifc], 15, aid) |
|||
if self._cur["wpa_changed"]: |
|||
rc, _ = self._run(["wpa_cli", "-i", "wlan0", "reconfigure"], 10, aid) |
|||
if rc != 0: |
|||
self._run(["systemctl", "try-restart", "wpa_supplicant@wlan0.service"], 30, aid) |
|||
if country_changed and country_now: |
|||
# §6.2 전문가 즉시 적용: apply.service 가 modprobe.d 갱신+모듈 리로드 수행 (캡처 확정) |
|||
# → wpa 재기동(conf 헤더 country) 후 강화 검증 사다리 |
|||
self._run(["systemctl", "restart", "wpa_supplicant@wlan0.service"], 30, aid) |
|||
target = new_it["wlan0"]["country_code"] |
|||
if not self.exists("/sys/module/wlan"): |
|||
return self._rollback_from(("APPLYING",), "country_now: /sys/module/wlan 부재 (§6.2)") |
|||
if not self.exists("/sys/class/net/wlan0"): |
|||
return self._rollback_from(("APPLYING",), "country_now: wlan0 netdev 부재 (§6.2)") |
|||
live = self._live_country() # iw reg / sysfs — 라디오 실효 regdomain (v1.9.1: wpa_cli 아님) |
|||
if live != target: |
|||
return self._rollback_from(("APPLYING",), |
|||
f"country_now: live {live!r} != 목표 {target!r} (§6.2)") |
|||
# C2: 보류분 동반 적용 확인 — pending 해소 (이후 _persist 들이 False 를 기록) |
|||
self._cur["country_pending"] = False |
|||
self._step("APPLYING", "ok", "", aid) |
|||
# VERIFYING |
|||
self._cur["state"] = "VERIFYING"; self._persist() |
|||
_deferred_cc = cur_it["wlan0"]["country_code"] if split_country else None |
|||
vres = self.verify_fn(new_it, self._cur["changed_ifaces"], deferred_country=_deferred_cc) |
|||
self._step("VERIFYING", vres["result"], json.dumps(vres["checks"], ensure_ascii=False)[:500], aid) |
|||
if vres["result"] == "fail": |
|||
if not self._cur["force"]: |
|||
return self._rollback_from(("VERIFYING",), "verify failed") # C1: claim 후 본문 |
|||
self.journal.event("apply", phase="VERIFYING", action="force_commit", result="warn", |
|||
apply_id=aid, detail={"note": "verify fail 을 force 로 무시 (§5.3)"}) |
|||
# eth1 → CONFIRM_WAIT (§6.1) — 엔진 자체 타이머가 1차 만료 보장 |
|||
if "eth1" in self._cur["changed_ifaces"]: |
|||
self._cur.update({"state": "CONFIRM_WAIT", |
|||
"confirm_deadline_monotonic": self.clock() + CONFIRM_TTL_S}) |
|||
self._persist() |
|||
self._confirm_timer = self.timer_factory(CONFIRM_TTL_S + 1, self.tick) |
|||
self._confirm_timer.start() |
|||
self._step("CONFIRM_WAIT", "ok", f"TTL {CONFIRM_TTL_S}s", aid) |
|||
return {"state": "CONFIRM_WAIT", "apply_id": aid} |
|||
return self._commit() |
|||
|
|||
def _commit(self): |
|||
aid = self._cur["apply_id"] |
|||
self._cancel_timer() |
|||
self._cur.update({"state": "COMMITTED", "confirm_deadline_monotonic": None}); self._persist() |
|||
self._lkg_bookkeeping(aid) # I4: fail-soft — 북키핑 실패가 COMMITTED 를 못 뒤집게 |
|||
self._step("COMMITTED", "ok", "", aid) |
|||
return {"state": "COMMITTED", "apply_id": aid, |
|||
"country_pending": self._cur.get("country_pending", False)} |
|||
|
|||
def _lkg_bookkeeping(self, aid): |
|||
"""I4: LKG 마킹/prune 은 부가 북키핑 — 실패해도 COMMITTED 유지, 저널 warn 만.""" |
|||
try: |
|||
snapshot.mark_last_known_good(self.backups_dir, aid) |
|||
snapshot.prune(self.backups_dir) |
|||
except Exception as e: # noqa: BLE001 |
|||
self.journal.event("apply", phase="COMMITTED", action="lkg_bookkeeping_failed", |
|||
result="warn", apply_id=aid, detail={"error": str(e)}) |
|||
|
|||
def _rollback_from(self, from_states, reason): |
|||
"""C1: from_states→ROLLING_BACK 선점(claim) 후 본문 — 선점 실패여도 본문 자체 가드가 멱등 보장.""" |
|||
claimed = self._claim(from_states, "ROLLING_BACK") |
|||
return self._rollback(reason=reason, claimed=claimed) |
|||
|
|||
def _rollback(self, reason="", claimed=False): |
|||
with self._lock: |
|||
aid = self._cur["apply_id"] |
|||
# C1: 멱등 가드 — 이미 종결된 apply 에 재진입 금지 (double-rollback/commit 후 rollback 차단) |
|||
if self._cur["state"] in ("ROLLED_BACK", "FAILED_CRITICAL", "COMMITTED"): |
|||
return {"state": self._cur["state"], "apply_id": aid} |
|||
if not claimed: |
|||
self._cur["state"] = "ROLLING_BACK"; self._persist() |
|||
self._cancel_timer() |
|||
self._step("ROLLING_BACK", "warn", reason, aid) |
|||
try: |
|||
try: |
|||
snap = self._cur.get("snapshot_dir") |
|||
if not snap or not snapshot.verify(snap): |
|||
raise RuntimeError("snapshot missing/corrupt") |
|||
with open(os.path.join(snap, "db_fields.json"), encoding="utf-8") as _f: |
|||
db_snap = json.load(_f) |
|||
# I1: fmt 2 — present 복원 + absent pop. fmt-less 구형 스냅샷은 전체를 present 로 취급 |
|||
if isinstance(db_snap, dict) and db_snap.get("fmt") == 2: |
|||
present = db_snap.get("present") or {} |
|||
absent = db_snap.get("absent") or [] |
|||
else: |
|||
present, absent = (db_snap if isinstance(db_snap, dict) else {}), [] |
|||
self._write_db_restore(present, absent) # DB-first (§4.1) |
|||
# #4: 빈 baseline 스냅샷(fresh-flash) 감지 — restore_files 가 적용본을 유지하므로 |
|||
# FAILED_CRITICAL 로 에스컬레이트하지 않는다 (운영자 연결 유지가 안전). 저널 warn. |
|||
try: |
|||
with open(os.path.join(snap, "manifest.json"), encoding="utf-8") as _f: |
|||
_man = json.load(_f) |
|||
_empty_baseline = not _man.get("files") |
|||
except (OSError, ValueError): |
|||
_empty_baseline = False |
|||
if _empty_baseline: |
|||
self.journal.event("apply", phase="ROLLING_BACK", |
|||
action="rollback_empty_baseline_kept_renders", result="warn", |
|||
apply_id=aid, |
|||
detail={"note": "빈 baseline 스냅샷 — 적용본 렌더 유지 (eth1 lockout 방지)"}) |
|||
snapshot.restore_files(snap, self.net_dir) |
|||
# 재적용 — rollback re-apply: apply.service 는 best-effort (DEF-2a 동일 이유). |
|||
# rollback re-verify 가 실 게이트 — verify fail → FAILED_CRITICAL (올바른 동작). |
|||
rc_rb_apply, _ = self._run(["systemctl", "start", "dpworld-network-apply.service"], |
|||
APPLY_SERVICE_TIMEOUT, aid, must_ok=False) |
|||
if rc_rb_apply != 0: |
|||
self.journal.event("apply", phase="ROLLING_BACK", action="apply_service_degraded", |
|||
result="warn", apply_id=aid, |
|||
detail={"rc": rc_rb_apply, "note": "rollback re-apply best-effort"}) |
|||
self._run(["networkctl", "reload"], 15, aid) |
|||
for ifc in self._cur["changed_ifaces"]: |
|||
if ifc in ("wlan0", "eth0", "eth1"): |
|||
self._run(["networkctl", "reconfigure", ifc], 15, aid) |
|||
if self._cur["wpa_changed"]: |
|||
self._run(["wpa_cli", "-i", "wlan0", "reconfigure"], 10, aid) |
|||
# 재검증 — §5: 복원이 실제로 살아났는지 확인. |
|||
# A-3 결함 2 fix: 기준 = 복원된 persist. Save-then-Apply drift flow 에선 DB 가 |
|||
# apply 전에 이미 변경 → 스냅샷 db_fields = NEW 값 → 그 기준 재검증은 복원된 |
|||
# 라이브(OLD)와 영원히 불일치 → 가짜 FAILED_CRITICAL. persist 판독 불가 시에만 폴백. |
|||
try: |
|||
with open(os.path.join(self.net_dir, "network_config.json"), |
|||
encoding="utf-8") as f: |
|||
restored_it = renderer.intent_from_persist(json.load(f)) |
|||
except (OSError, ValueError): |
|||
restored_it = netmodel.intent_from_device(present) |
|||
rres = self.verify_fn(restored_it, self._cur["changed_ifaces"]) |
|||
if rres["result"] == "fail": |
|||
raise RuntimeError(f"rollback re-verify failed: {json.dumps(rres['checks'])[:200]}") |
|||
# #7: country 변경 apply 의 롤백은 라디오 실효 country 도 검증해야 한다. |
|||
# verify_fn 은 country 를 보지 않으므로, wlan 모듈 리로드 실패(apply.service |
|||
# best-effort rc unchecked) 시 라디오가 NEW(target) country 에 갇힌 채 ROLLED_BACK |
|||
# 로 거짓 보고될 수 있다. 라이브가 (복원된 OLD 가 아닌) 이번에 적용 시도한 NEW |
|||
# target 그대로면 라디오가 되돌아가지 못한 것 → FAILED_CRITICAL 로 에스컬레이트 |
|||
# (forward country_now 경로의 대칭). NEW==restored 인 deferred-미적용 케이스는 |
|||
# 라디오가 NEW 를 잡은 적이 없으므로 false positive 회피. |
|||
if self._cur.get("country_changed"): |
|||
restored_cc = (restored_it.get("wlan0") or {}).get("country_code", "") |
|||
target_cc = self._cur.get("country_target", "") |
|||
if restored_cc and target_cc and target_cc != restored_cc: |
|||
live_cc = self._live_country() |
|||
if live_cc == target_cc: |
|||
raise RuntimeError( |
|||
f"rollback: radio country stuck at {live_cc} != restored {restored_cc}") |
|||
# v1.9: split-apply 의 speculative country_pending 을 롤백 시 원복 — 롤백은 country 를 |
|||
# 커밋하지 않았으므로(파일=OLD 복원) pending 은 이 시도 이전 값으로 되돌린다. 안 하면 |
|||
# stale country_pending=True 가 freeze 게이트를 영구 발동(reboot 없는데도) → deadlock. |
|||
self._cur["country_pending"] = self._prior_country_pending |
|||
self._cur["state"] = "ROLLED_BACK"; self._persist() |
|||
# 의도된 UX: DB 는 스냅샷(=user-saved) 값 그대로 — persist 와 다르면 실패한 변경이 |
|||
# drift 로 남아 재시도 가능. 저널로 명시 고지 (fail-soft — 롤백 결과 불변). |
|||
try: |
|||
db_it = netmodel.intent_from_device(self.db.get_config("device_config") or {}) |
|||
drift = netmodel.diff_intents(restored_it, db_it) |
|||
if drift: |
|||
self.journal.event("apply", phase="ROLLED_BACK", action="rollback_left_drift", |
|||
result="warn", apply_id=aid, |
|||
detail={"fields": [d["field"] for d in drift], |
|||
"note": "user-saved 변경이 drift 로 남음 — 재시도 가능"}) |
|||
except Exception: # noqa: BLE001 |
|||
pass |
|||
self._step("ROLLED_BACK", "ok", "", aid) |
|||
return {"state": "ROLLED_BACK", "apply_id": aid, "reason": reason} |
|||
except Exception as e: # noqa: BLE001 |
|||
# 마지막 사다리 (§6.2/§7): recover 유닛 1회 — 그래도 안 되면 CRITICAL |
|||
try: |
|||
self._run(["systemctl", "start", "dpworld-net-recover.service"], 60, aid) |
|||
except Exception: # noqa: BLE001 |
|||
pass |
|||
self._cur["state"] = "FAILED_CRITICAL"; self._persist() |
|||
self._step("FAILED_CRITICAL", "fail", str(e), aid) |
|||
return {"state": "FAILED_CRITICAL", "apply_id": aid, "reason": f"{reason}; rollback: {e}"} |
|||
finally: |
|||
# v1.6.0 Task 3.3: 포렌식 번들 — ROLLED_BACK / FAILED_CRITICAL 모두 수집 |
|||
try: |
|||
from network.journal import forensic_bundle |
|||
forensic_bundle(self.backups_dir, aid, lambda a, t=15: self.runner(a, t), |
|||
render_dir=self.net_dir) |
|||
except Exception: # noqa: BLE001 — 포렌식 실패가 롤백 결과를 바꾸면 안 됨 |
|||
pass |
|||
|
|||
def confirm(self, apply_id): |
|||
with self._lock: |
|||
if not self._cur or self._cur["apply_id"] != apply_id \ |
|||
or self._cur["state"] != "CONFIRM_WAIT": |
|||
return {"state": "INVALID", "error": "no matching CONFIRM_WAIT apply"} |
|||
return self._commit() |
|||
|
|||
def tick(self): |
|||
"""CONFIRM_WAIT TTL 검사 — 엔진 타이머(1차)·watchdog·status 폴링(안전망) 공용 (§6.1). |
|||
C1: claim(CAS) 승자만 롤백 본문 실행 — confirm 과의 race 에서 이중 소비 차단.""" |
|||
with self._lock: |
|||
if not (self._cur and self._cur["state"] == "CONFIRM_WAIT"): |
|||
return |
|||
dl = self._cur.get("confirm_deadline_monotonic") |
|||
if dl is None or self.clock() < dl: |
|||
return |
|||
if not self._claim(("CONFIRM_WAIT",), "ROLLING_BACK"): |
|||
return # confirm 이 먼저 소비 (또는 타 tick 선점) |
|||
self._rollback(reason="confirm TTL expired", claimed=True) |
|||
|
|||
def is_busy(self): |
|||
return bool(self._cur and self._cur["state"] in _ACTIVE) |
|||
|
|||
def is_busy_or_confirming(self): |
|||
"""I1a: watchdog 억제용 — CONFIRM_WAIT 중 복구 사다리가 eth1 확인 대기를 침범 금지 (§6.1/§8).""" |
|||
return bool(self._cur and self._cur["state"] in _ACTIVE + ("CONFIRM_WAIT",)) |
|||
|
|||
def rollback_to_lkg(self): |
|||
"""수동 rollback API (§5.1) — last-known-good 스냅샷으로. |
|||
C3: in-flight apply/CONFIRM_WAIT 중엔 BUSY — 진행 중 apply 의 _cur 강탈 금지.""" |
|||
with self._lock: |
|||
if self._cur and self._cur["state"] in _ACTIVE + ("CONFIRM_WAIT",): |
|||
return {"state": "BUSY", "apply_id": self._cur["apply_id"]} |
|||
lkg = snapshot.last_known_good(self.backups_dir) |
|||
if not lkg: |
|||
return {"state": "INVALID", "error": "no last-known-good"} |
|||
self._cur = {"apply_id": f"rb-{lkg}", "state": "ROLLING_BACK", "force": False, |
|||
"country_now": False, |
|||
"started_monotonic": self.clock(), "confirm_deadline_monotonic": None, |
|||
"changed_ifaces": ["wlan0", "eth0", "eth1"], "wpa_changed": True, |
|||
"country_changed": False, "country_pending": False, |
|||
"snapshot_dir": os.path.join(self.backups_dir, lkg), |
|||
"boot_id": self._boot_id()} |
|||
# v1.11.10 Fix 2: a manual LKG rollback never went through _begin(), so |
|||
# self._prior_country_pending may carry a stale True from an earlier |
|||
# apply. _rollback() restores _cur["country_pending"] from it (§v1.9 |
|||
# split-apply unwind) → a stale True permanently jams the freeze gate. |
|||
# Reset it here (inside the lock, mirroring _begin's establishment). |
|||
self._prior_country_pending = False |
|||
self._persist() |
|||
return self._rollback(reason="manual rollback to LKG", claimed=True) |
|||
|
|||
def status(self, apply_id=None): |
|||
# C4: _lock 하 스냅샷 — _begin()/_run_machine() 이 _cur 을 교체하는 것과의 race 차단. |
|||
# watchdog tick_once 가 apply_id 를 읽어 streak 리셋 여부를 판단하므로 |
|||
# 일관된 스냅샷이 필요하다. _lock 은 재진입 불가이나 status() 내부에서 _lock 을 |
|||
# 다시 잡는 호출(country_pending → _read_persisted_pending → open)은 없으므로 안전. |
|||
with self._lock: |
|||
cur_snap = dict(self._cur) if self._cur is not None else None |
|||
steps_snap = list(self._steps) |
|||
# minor: 다른 apply_id 조회는 UNKNOWN — 현재 apply 의 상태를 남의 id 로 오인 금지 |
|||
if apply_id and cur_snap and apply_id != cur_snap.get("apply_id"): |
|||
return {"apply_id": apply_id, "state": "UNKNOWN", "steps": [], |
|||
"confirm_remaining_s": None, "country_pending": self.country_pending()} |
|||
cur = cur_snap or {"state": "IDLE", "apply_id": None} |
|||
remaining = None |
|||
if cur.get("state") == "CONFIRM_WAIT" and cur.get("confirm_deadline_monotonic"): |
|||
remaining = max(0, int(cur["confirm_deadline_monotonic"] - self.clock())) |
|||
return {"apply_id": cur.get("apply_id"), "state": cur.get("state", "IDLE"), |
|||
"steps": steps_snap, "confirm_remaining_s": remaining, |
|||
"country_pending": self.country_pending()} |
|||
|
|||
def country_pending(self): |
|||
if self._cur is not None: |
|||
return bool(self._cur.get("country_pending")) |
|||
return self._read_persisted_pending() |
|||
|
|||
def recover_on_startup(self, block=False): |
|||
"""server 시작 시: ① country_pending 해소 검사 ② 미완료 apply 복구 (§5 크래시 안전). |
|||
|
|||
#1/#3 HIGH: 느린 _rollback(subprocess — apply.service 75s + wpa 45s + recover 60s + |
|||
forensic) 을 동기로 돌리면 HTTP bind/serve_forever 를 분 단위로 막아 운영자 lockout. |
|||
fw_controller 의 startup recovery 데몬 스레드 비대칭을 미러링 — 빠른 판정(파일 read)만 |
|||
인라인, 느린 롤백은 데몬 스레드로 띄우고 즉시 True 반환(복구 INITIATED). |
|||
block=True (테스트) → _rollback 을 인라인 실행해 terminal state 를 동기 검증 가능.""" |
|||
try: |
|||
with open(self.state_path, encoding="utf-8") as _f: |
|||
st = json.load(_f) |
|||
except (OSError, ValueError): |
|||
return False |
|||
if not isinstance(st, dict): # minor: 손상/이형 state 파일 내성 |
|||
return False |
|||
try: |
|||
# ① 재부팅으로 country 가 실제 적용됐으면 pending 해제 — watchdog 영구 억제 방지 (§8) |
|||
# #9: fast=True — hung iw reg 가 startup 을 블록하지 못하게 (단일 시도) |
|||
if st.get("country_pending"): |
|||
target = self._baseline_intent()["wlan0"].get("country_code", "") |
|||
live = self._live_country(fast=True) |
|||
# deferred country 적용 확인: 비움(clear)은 live 가 default(빈/00)로 떨어지면 충족. |
|||
if (target and live == target) or (not target and (not live or live == "00")): |
|||
st["country_pending"] = False |
|||
self._cur = st; self._persist() |
|||
self.journal.event("apply", phase="RECOVER", action="country_pending_cleared", |
|||
result="ok", apply_id=st.get("apply_id"), |
|||
detail={"country": target}) |
|||
# ② 미완료 apply |
|||
state = st.get("state") |
|||
if state in ("VALIDATING", "SNAPSHOT"): |
|||
# 파일·DB 무접촉 단계의 크래시 — 변경 0. 거짓 FAILED_CRITICAL 금지, ABORTED 마킹만 |
|||
# (subprocess 없음 — 인라인 유지) |
|||
st["state"] = "ABORTED" |
|||
self._cur = st; self._persist() |
|||
self.journal.event("apply", phase="RECOVER", action="aborted_no_change", result="warn", |
|||
apply_id=st.get("apply_id"), detail={"from": state}) |
|||
return True |
|||
if state in ("WRITING", "APPLYING", "VERIFYING", "CONFIRM_WAIT", "ROLLING_BACK"): |
|||
self.journal.event("apply", phase="RECOVER", action="startup_found_unfinished", |
|||
result="warn", apply_id=st.get("apply_id"), |
|||
detail={"state": state, "stale_boot": st.get("boot_id") != self._boot_id()}) |
|||
self._cur = st |
|||
self._persist() # transient marker — _cur 세팅을 즉시 영속화 |
|||
if block: |
|||
# 테스트/동기 경로: 인라인 롤백 → terminal state 동기 검증 |
|||
self._rollback(reason=f"startup recovery from {state}") |
|||
return True |
|||
# #1/#3: 느린 롤백을 데몬 스레드로 — HTTP bind 즉시 가능. True=복구 INITIATED. |
|||
self._thread = threading.Thread( |
|||
target=lambda: self._rollback(reason=f"startup recovery from {state}"), |
|||
daemon=True, name="net-startup-recover") |
|||
self._thread.start() |
|||
return True |
|||
return False |
|||
except (TypeError, AttributeError): # minor: 이형 st 필드로 인한 crash 방지 |
|||
return False |
|||
@ -0,0 +1,159 @@ |
|||
# src/network/journal.py |
|||
"""JSONL 네트워크 이벤트 저널 (spec §9). 1줄 1이벤트, 5MB×3 로테이션, psk/password 마스킹. |
|||
v1.6.0 Task 3.3: forensic_bundle() 추가 (적용 실패/롤백/CRITICAL 시 증거 tar.gz 수집).""" |
|||
import io, json, os, re, tarfile, threading, time |
|||
|
|||
_MASK_KEYS = frozenset({"password", "psk", "wifi_passwd"}) |
|||
|
|||
def _restrict(path, mode): |
|||
"""#5/#10: best-effort 권한 제한 — 저널은 마스킹되지만 detail 잔여 민감정보 방어. |
|||
Windows 에선 효과 제한적이나 raise 하지 않음.""" |
|||
try: |
|||
os.chmod(path, mode) |
|||
except OSError: |
|||
pass |
|||
|
|||
def _boot_id(): |
|||
try: |
|||
with open("/proc/sys/kernel/random/boot_id") as f: |
|||
return f.read().strip() |
|||
except OSError: |
|||
return "unknown" |
|||
|
|||
def _uptime(): |
|||
try: |
|||
with open("/proc/uptime") as f: |
|||
return float(f.read().split()[0]) |
|||
except (OSError, ValueError): |
|||
return -1.0 |
|||
|
|||
def _mask(obj): |
|||
if isinstance(obj, dict): |
|||
return {k: ("***" if k in _MASK_KEYS else _mask(v)) for k, v in obj.items()} |
|||
if isinstance(obj, (list, tuple)): # M1: recurse into tuples too |
|||
return [_mask(v) for v in obj] |
|||
return obj |
|||
|
|||
class Journal: |
|||
def __init__(self, path, max_bytes=5 * 1024 * 1024, keep=3): |
|||
self.path = path; self.max_bytes = max_bytes; self.keep = keep |
|||
self._lock = threading.Lock() |
|||
self.last_error = None # I6: stores last OSError message, None if no error |
|||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) |
|||
|
|||
def event(self, category, phase, action, result, apply_id=None, duration_ms=None, detail=None): |
|||
"""I6: must never raise — OSError during rotate/write is caught, stored in last_error.""" |
|||
line = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "uptime": _uptime(), "boot_id": _boot_id(), |
|||
"apply_id": apply_id, "category": category, "phase": phase, "action": action, |
|||
"result": result, "duration_ms": duration_ms, "detail": _mask(detail or {})} |
|||
data = json.dumps(line, ensure_ascii=False) + "\n" |
|||
try: |
|||
with self._lock: |
|||
self._rotate_if_needed(len(data)) |
|||
with open(self.path, "a", encoding="utf-8") as f: |
|||
f.write(data) |
|||
_restrict(self.path, 0o600) # #5/#10: owner-only (생성·append 매 회 best-effort) |
|||
except OSError as e: |
|||
self.last_error = str(e) |
|||
return |
|||
|
|||
def _rotate_if_needed(self, incoming): |
|||
try: |
|||
size = os.path.getsize(self.path) |
|||
except OSError: |
|||
return |
|||
if size + incoming <= self.max_bytes: |
|||
return |
|||
for i in range(self.keep, 0, -1): |
|||
src = self.path if i == 1 else f"{self.path}.{i-1}" |
|||
dst = f"{self.path}.{i}" |
|||
if os.path.exists(src): |
|||
os.replace(src, dst) |
|||
_restrict(dst, 0o600) # #5/#10: 로테이션 파일도 owner-only |
|||
|
|||
def tail(self, n): |
|||
# M2: intentionally reads WITHOUT the lock — diagnostic; torn line tolerated, json.loads skips it. |
|||
# Collect lines from live file + rotated files (newest first) until we have n |
|||
all_lines = [] |
|||
paths = [self.path] + [f"{self.path}.{i}" for i in range(1, self.keep + 1)] |
|||
for p in paths: |
|||
try: |
|||
with open(p, encoding="utf-8") as f: |
|||
all_lines = f.readlines() + all_lines |
|||
except OSError: |
|||
pass |
|||
if len(all_lines) >= n: |
|||
break |
|||
out = [] |
|||
for ln in all_lines[-n:]: |
|||
try: |
|||
out.append(json.loads(ln)) |
|||
except ValueError: |
|||
continue |
|||
return out |
|||
|
|||
|
|||
def forensic_bundle(out_dir, apply_id, runner, render_dir=None, |
|||
max_bytes=2 * 1024 * 1024, keep=5, |
|||
clock=time.monotonic, max_wall_s=40): |
|||
"""apply 실패/롤백/CRITICAL 시 증거 수집 (spec §9). tar.gz, 개당 ≤2MB, 최근 5개. |
|||
|
|||
#13 LOW: 5 외부 cmd × 15s = ~75s 동기 — startup-recovery 블록을 부풀린다. |
|||
per-command timeout 을 8s 로 줄이고, 전체 wall budget(max_wall_s, 기본 40s)을 둔다. |
|||
각 source cmd 전에 경과(clock()-start)가 예산 초과면 수집을 멈추고 partial 노트를 남긴다.""" |
|||
os.makedirs(out_dir, exist_ok=True) |
|||
sources = { |
|||
"dmesg.txt": ["sh", "-c", "dmesg | grep -iE 'wlan|cnss|pci' | tail -200"], |
|||
"units.txt": ["journalctl", "-u", "wpa_supplicant@wlan0", "-u", "systemd-networkd", |
|||
"-u", "dpworld-network-apply", "-u", "dpworld-net-recover", |
|||
"--since", "-10 min", "--no-pager"], |
|||
"networkctl.txt": ["networkctl", "status", "--no-pager"], |
|||
"wpa_status.txt": ["wpa_cli", "-i", "wlan0", "status"], |
|||
"ip.txt": ["sh", "-c", "ip addr; ip route"], |
|||
} |
|||
path = os.path.join(out_dir, f"forensic_{apply_id}.tar.gz") |
|||
budget = max_bytes |
|||
start = clock() |
|||
budget_exceeded = False |
|||
with tarfile.open(path, "w:gz") as tar: |
|||
def add(name, data): |
|||
nonlocal budget |
|||
b = data.encode("utf-8", errors="replace")[:max(0, budget)] |
|||
budget -= len(b) |
|||
info = tarfile.TarInfo(name); info.size = len(b) |
|||
tar.addfile(info, io.BytesIO(b)) |
|||
for name, argv in sources.items(): |
|||
if budget <= 0: |
|||
break |
|||
if clock() - start >= max_wall_s: # #13: wall budget 초과 — 부분 수집 후 중단 |
|||
budget_exceeded = True |
|||
break |
|||
_, out = runner(argv, 8) |
|||
add(name, out) |
|||
if budget_exceeded: |
|||
add("budget_note.txt", "budget exceeded, partial bundle\n") |
|||
if render_dir and os.path.isdir(render_dir) and budget > 0: |
|||
for f in sorted(os.listdir(render_dir)): |
|||
if budget <= 0: |
|||
break |
|||
try: |
|||
with open(os.path.join(render_dir, f), encoding="utf-8", errors="replace") as _rf: |
|||
body = _rf.read() |
|||
except OSError: |
|||
continue |
|||
# C1: 비밀 전수 마스킹 — wpa conf 인용 psk + persist JSON |
|||
# password/psk/wifi_passwd + 비인용 64-hex psk (wpa_passphrase 출력형) |
|||
# M-E: use (?:[^"\\]|\\.)* so JSON-escaped quotes inside a secret don't |
|||
# truncate the match and leak the trailing fragment. |
|||
body = re.sub(r'psk="(?:[^"\\]|\\.)*"', 'psk="***"', body) |
|||
body = re.sub(r'("(?:password|psk|wifi_passwd)"\s*:\s*)"(?:[^"\\]|\\.)*"', |
|||
r'\1"***"', body) |
|||
# wpa_supplicant psk= line: mask to end-of-line so an inner quote can't stop it |
|||
body = re.sub(r'(?m)^(\s*psk=).*$', r'\1***', body) |
|||
add(f"renders/{f}", body) |
|||
bundles = sorted((f for f in os.listdir(out_dir) if f.startswith("forensic_")), |
|||
key=lambda f: os.path.getmtime(os.path.join(out_dir, f)), reverse=True) |
|||
for f in bundles[keep:]: |
|||
try: os.remove(os.path.join(out_dir, f)) |
|||
except OSError: pass |
|||
return path |
|||
@ -0,0 +1,151 @@ |
|||
"""HTTP 라우팅 글루 (spec §5.1) — fw_routes.py 전례. 서버 독립적·dict in/out.""" |
|||
|
|||
from network import netmodel |
|||
|
|||
|
|||
class RouteError(Exception): |
|||
def __init__(self, status, message): |
|||
super().__init__(message); self.status = status; self.message = message |
|||
|
|||
|
|||
def _req_bool(body, name): |
|||
"""v1.6.0 codex H2: 엄격 bool 파서 — JSON 문자열 "false" 가 Python truthiness 로 True 가 되어 |
|||
force/country_now 가 잘못 활성화되는 결함 차단. None=미지정(False), 진짜 bool 만 수용.""" |
|||
v = body.get(name) |
|||
if v is None: |
|||
return False |
|||
if isinstance(v, bool): |
|||
return v |
|||
raise RouteError(400, f"{name} must be a boolean") |
|||
|
|||
|
|||
_WATCHDOG_DEFAULTS = {"watchdog_enabled": "on", "watchdog_auto_recover": "on", |
|||
"watchdog_interval_s": 30} |
|||
|
|||
|
|||
class NetworkRoutes: |
|||
def __init__(self, engine, journal, watchdog, drift_fn, live_fn): |
|||
self.engine = engine; self.journal = journal; self.watchdog = watchdog |
|||
self.drift_fn = drift_fn; self.live_fn = live_fn |
|||
|
|||
def apply(self, body): |
|||
fields = body.get("fields", {}) |
|||
if not isinstance(fields, dict): |
|||
raise RouteError(400, "fields must be an object") |
|||
# v1.6.0 codex H1: 네트워크 키 화이트리스트 — apply 가 임의 device_config 키를 쓰면 |
|||
# 스냅샷(20 네트워크 키)이 못 잡아 롤백으로도 제거 불가. /setting/device 검증을 |
|||
# 우회하는 side-door 차단. dry_run·real apply 양쪽 동일 (divergence 방지). |
|||
unknown = sorted(k for k in fields if k not in netmodel.NETWORK_DEV_KEYS) |
|||
if unknown: |
|||
raise RouteError(400, f"unknown network field(s): {', '.join(unknown)}") |
|||
# v1.11.10 Fix 3: coerce '*_port' string values to int — the legacy app / |
|||
# dpworldapp require Integer ports; the unauthenticated apply path would |
|||
# otherwise persist them as String and break the shared board_config contract. |
|||
for k, v in list(fields.items()): |
|||
if k.endswith("_port") and isinstance(v, str) and v != "": |
|||
try: |
|||
fields[k] = int(v) |
|||
except ValueError: |
|||
raise RouteError(400, f"{k} must be numeric (got {v!r})") |
|||
if _req_bool(body, "dry_run"): # H2: 엄격 bool |
|||
country_now = _req_bool(body, "country_now") |
|||
return {"ok": True, **self.engine.dry_run(fields, country_now=country_now)} |
|||
# §1.6 비동기: STARTED + apply_id 즉시 반환 — 검증/적용 결과는 status 폴링. |
|||
# (eth1 IP 변경 시 networkctl 이후엔 구 연결로 응답 불가하므로 동기 실행 금지) |
|||
res = self.engine.apply_async(fields, force=_req_bool(body, "force"), |
|||
country_now=_req_bool(body, "country_now")) |
|||
if res["state"] == "BUSY": |
|||
raise RouteError(409, f"apply in flight: {res['apply_id']}") |
|||
return {"ok": True, **res} |
|||
|
|||
def status(self, apply_id): |
|||
self.engine.tick() # TTL 검사 — 폴링이 자연 타이머 역할 (§6.1) |
|||
return self.engine.status(apply_id) |
|||
|
|||
def confirm(self, body): |
|||
aid = body.get("apply_id") or "" |
|||
res = self.engine.confirm(aid) |
|||
if res["state"] == "INVALID": |
|||
raise RouteError(409, res.get("error", "invalid confirm")) |
|||
return {"ok": True, **res} |
|||
|
|||
def rollback(self): |
|||
res = self.engine.rollback_to_lkg() |
|||
if res.get("state") == "BUSY": |
|||
raise RouteError(409, res.get("error", "apply in flight — cannot rollback now")) |
|||
if res.get("state") == "INVALID": |
|||
raise RouteError(409, res.get("error", "no LKG")) |
|||
return {"ok": True, **res} |
|||
|
|||
def drift(self): |
|||
# v1.7.0: 경량 미적용(drift) 전용 — drift_fn() (DB read + 파일 read) 만, subprocess 0. |
|||
# 전역 배지가 저빈도 폴링할 수 있게 무거운 state() 와 분리. |
|||
return self.drift_fn() |
|||
|
|||
def state(self): |
|||
self.engine.tick() |
|||
return {**self.live_fn(), "drift": self.drift_fn(), |
|||
"watchdog": self.watchdog.snapshot() if self.watchdog else {}, |
|||
"last_apply": self.engine.status(None), |
|||
"country_pending": self.engine.country_pending()} |
|||
|
|||
def journal_tail(self, limit): |
|||
try: |
|||
n = int(limit or 50) |
|||
except (TypeError, ValueError): # M1: 파싱 불가 limit → 50 폴백 (500 error 금지) |
|||
n = 50 |
|||
return {"events": self.journal.tail(max(1, min(n, 500)))} |
|||
|
|||
# ── #2: watchdog kill-switch (net_config write path) ───── |
|||
def _current_config(self): |
|||
"""효과적 net_config — DB 값에 기본값 merge (operator 미설정 키도 노출).""" |
|||
try: |
|||
cur = self.engine.db.get_config("net_config") or {} |
|||
except Exception: # noqa: BLE001 — DB 조회 실패가 GET 을 500 시키면 안 됨 |
|||
cur = {} |
|||
if not isinstance(cur, dict): |
|||
cur = {} |
|||
return {**_WATCHDOG_DEFAULTS, **cur} |
|||
|
|||
def get_config(self): |
|||
return {"net_config": self._current_config()} |
|||
|
|||
def config(self, body): |
|||
"""#2 운영자 kill-switch: net_config 의 watchdog 키만 쓰기 허용 + critical 재무장. |
|||
엄격 화이트리스트 — 미지 키/이상치는 RouteError(400) 로 즉시 거절.""" |
|||
if not isinstance(body, dict): |
|||
raise RouteError(400, "request body must be a JSON object") |
|||
if body.get("reset_watchdog_critical") is True: |
|||
# critical 재무장 — 다른 키와 동시 전송 금지 (단일 의도) |
|||
extra = [k for k in body if k != "reset_watchdog_critical"] |
|||
if extra: |
|||
raise RouteError(400, f"reset_watchdog_critical must be sent alone (got {extra})") |
|||
if self.watchdog is not None: |
|||
self.watchdog.reset_critical() |
|||
return {"ok": True, "net_config": self._current_config()} |
|||
validated = {} |
|||
for k, v in body.items(): |
|||
if k in ("watchdog_enabled", "watchdog_auto_recover"): |
|||
if v not in ("on", "off"): |
|||
raise RouteError(400, f"{k} must be 'on' or 'off'") |
|||
validated[k] = v |
|||
elif k == "watchdog_interval_s": |
|||
# v1.6.0 codex M2: int(v) 는 float 10.9 를 10 으로 silently truncate — 엄격 거절. |
|||
# bool 은 int 서브클래스이므로 명시 차단. |
|||
if isinstance(v, bool) or not isinstance(v, int): |
|||
raise RouteError(400, "watchdog_interval_s must be an integer") |
|||
iv = v |
|||
if not (10 <= iv <= 300): |
|||
raise RouteError(400, "watchdog_interval_s must be within [10, 300]") |
|||
validated[k] = iv |
|||
else: |
|||
raise RouteError(400, f"unknown key: {k}") |
|||
if not validated: |
|||
raise RouteError(400, "no valid keys to write") |
|||
|
|||
def _mut(cur): |
|||
base = cur if isinstance(cur, dict) else {} |
|||
out = dict(base); out.update(validated) # partial-merge (§4.1 전례) |
|||
return out |
|||
self.engine.db.update_config("net_config", _mut, default={}) |
|||
return {"ok": True, "net_config": self._current_config()} |
|||
@ -0,0 +1,140 @@ |
|||
# src/network/netmodel.py |
|||
"""22항목 집합 (spec §3.4): DB device_config ↔ intent ↔ persist JSON 매핑 + 비교 의미론. |
|||
비교는 dpworldapp 의 네트워크-필드 비교(32-bit (u32) IP 비교) 정합 — §1.2 계약 참조.""" |
|||
|
|||
METRICS = {"wlan0": 100, "eth0": 200, "eth1": 300} |
|||
MAX_PROFILES = 5 |
|||
|
|||
# v1.6.0 codex H1: apply 가 device_config 에 쓸 수 있는 네트워크 키의 단일 출처(single source). |
|||
# net_routes.apply() write-allowlist 와 apply_engine 스냅샷 키가 이 집합에서 파생 — drift 차단. |
|||
# 비-네트워크 키(예: rs485_databits)는 apply 경로로 device_config 를 변형할 수 없다. |
|||
NETWORK_DEV_KEYS = frozenset({ |
|||
"wifi_static", "wifi_ip", "wifi_netmask", "wifi_gateway", "wifi_dns1", "wifi_dns2", |
|||
"wifi_country_code", "WIFI_SSID", "eth_ip", "eth_netmask", "eth_gateway", |
|||
"lte_ip", "lte_netmask", "lte_gateway", "lte_server_ip", "lte_server_port", |
|||
"opc_ua_server_ip", "opc_ua_server_port", "modbus_server_ip", "modbus_server_port", |
|||
}) |
|||
|
|||
def _s(v): |
|||
return "" if v is None else str(v).strip() |
|||
|
|||
def _norm_country(v): |
|||
s = _s(v).upper() |
|||
return s if (len(s) == 2 and s.isalpha()) else ("" if not s else s) |
|||
|
|||
def _ip_u32(s): |
|||
"""strict dotted-quad → u32. strict dotted-quad 의미론: 선행 0/8진/빈 octet 거부. |
|||
'' 은 0 (zero-동치 §1.2). 파싱 불가 → None (호출측이 원문 비교 폴백). |
|||
unicode digits (e.g. '²', '١') are rejected — ASCII-only check.""" |
|||
s = _s(s) |
|||
if s == "": |
|||
return 0 |
|||
parts = s.split(".") |
|||
if len(parts) != 4: |
|||
return None |
|||
val = 0 |
|||
for p in parts: |
|||
# I1: must be ASCII digits only — unicode digits like '²' or '١' rejected |
|||
if not (p.isascii() and p.isdigit()) or (len(p) > 1 and p[0] == "0") or int(p) > 255: |
|||
return None |
|||
val = (val << 8) | int(p) |
|||
return val |
|||
|
|||
def _norm_security(v): |
|||
s = _s(v).lower() |
|||
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 금지.""" |
|||
g = dev.get |
|||
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 가 검출 |
|||
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) |
|||
eth0_static = _ip_u32(g("lte_ip")) != 0 |
|||
eth1_static = _ip_u32(g("eth_ip")) != 0 |
|||
def net(static, ip, mask, gw): |
|||
# §1.2 dhcp zero-out: dpworldapp 은 dhcp 모드 필드를 파싱하지 않음 — DB 잔존값 무시 |
|||
if not static: |
|||
return {"mode": "dhcp", "ip": "", "netmask": "", "gateway": ""} |
|||
return {"mode": "static", "ip": _s(g(ip)), "netmask": _s(g(mask)), "gateway": _s(g(gw))} |
|||
wlan = net(wifi_static, "wifi_ip", "wifi_netmask", "wifi_gateway") |
|||
wlan.update({"dns1": _s(g("wifi_dns1")) if wifi_static else "", |
|||
"dns2": _s(g("wifi_dns2")) if wifi_static else "", |
|||
"country_code": _norm_country(g("wifi_country_code")), "profiles": profiles}) |
|||
eth0 = net(eth0_static, "lte_ip", "lte_netmask", "lte_gateway") |
|||
eth0.update({"server_ip": _s(g("lte_server_ip")), "server_port": g("lte_server_port", "")}) |
|||
eth1 = net(eth1_static, "eth_ip", "eth_netmask", "eth_gateway") |
|||
eth1.update({"opc_ua_server_ip": _s(g("opc_ua_server_ip")), "opc_ua_server_port": g("opc_ua_server_port", ""), |
|||
"modbus_server_ip": _s(g("modbus_server_ip")), "modbus_server_port": g("modbus_server_port", "")}) |
|||
return {"wlan0": wlan, "eth0": eth0, "eth1": eth1} |
|||
|
|||
def effective_profiles(intent): |
|||
"""dpworldapp 렌더 의미론: 첫 빈 SSID 에서 절단 (§3.4). 렌더·비교·wpa 판정 공용.""" |
|||
out = [] |
|||
for p in intent["wlan0"].get("profiles", []): |
|||
if not p["ssid"]: |
|||
break |
|||
out.append(p) |
|||
return out |
|||
|
|||
def gateway_set(c): |
|||
"""[Route] 생성 판정 — u32 != 0 (§3.2: gateway==0 이면 [Route] 생략. '0.0.0.0' 포함).""" |
|||
return _ip_u32(c.get("gateway")) not in (0, None) |
|||
|
|||
def _canon(field, v): |
|||
"""비교용 정규화 — §1.2 의미론.""" |
|||
leaf = field.rsplit(".", 1)[-1] |
|||
s = _s(v) |
|||
if leaf in ("ip", "netmask", "gateway", "dns1", "dns2", "server_ip", |
|||
"opc_ua_server_ip", "modbus_server_ip"): |
|||
u = _ip_u32(s) |
|||
return ("u32", u) if u is not None else ("raw", s) |
|||
if leaf in ("server_port", "opc_ua_server_port", "modbus_server_port"): |
|||
return s # "20111" == 20111 |
|||
if leaf == "country_code": |
|||
return _norm_country(s) |
|||
return s |
|||
|
|||
_SCALARS = [ # 비교·diff 대상 평면 목록 (profiles 는 별도) |
|||
"wlan0.mode", "wlan0.ip", "wlan0.netmask", "wlan0.gateway", "wlan0.dns1", "wlan0.dns2", |
|||
"wlan0.country_code", |
|||
"eth0.mode", "eth0.ip", "eth0.netmask", "eth0.gateway", "eth0.server_ip", "eth0.server_port", |
|||
"eth1.mode", "eth1.ip", "eth1.netmask", "eth1.gateway", |
|||
"eth1.opc_ua_server_ip", "eth1.opc_ua_server_port", |
|||
"eth1.modbus_server_ip", "eth1.modbus_server_port", |
|||
] |
|||
|
|||
def _get(intent, path): |
|||
iface, leaf = path.split(".", 1) |
|||
return intent.get(iface, {}).get(leaf, "") |
|||
|
|||
def diff_intents(a, b): |
|||
"""변경 필드 목록 [{field, old, new}] — UI diff 미리보기·drift 뱃지 공용. |
|||
profiles 는 effective(절단) 기준 비교 — 빈 trailing 행은 변경 아님.""" |
|||
out = [] |
|||
for p in _SCALARS: |
|||
va, vb = _get(a, p), _get(b, p) |
|||
if _canon(p, va) != _canon(p, vb): |
|||
out.append({"field": p, "old": _s(va), "new": _s(vb)}) |
|||
pa = [(x["ssid"], x["password"], x["security"]) for x in effective_profiles(a)] |
|||
pb = [(x["ssid"], x["password"], x["security"]) for x in effective_profiles(b)] |
|||
if pa != pb: |
|||
def _profile_descriptor(tuples): |
|||
return ", ".join(f"{ssid}({sec})" for ssid, _pw, sec in tuples) or "0 profiles" |
|||
old_desc = _profile_descriptor(pa) |
|||
new_desc = _profile_descriptor(pb) |
|||
# password-only change: ssid+security identical but tuples differ → append visible marker |
|||
if old_desc == new_desc: |
|||
new_desc += " (자격증명 변경)" |
|||
out.append({"field": "wlan0.profiles", "old": old_desc, "new": new_desc}) |
|||
return out |
|||
|
|||
def changed_interfaces(diff): |
|||
return sorted({d["field"].split(".", 1)[0] for d in diff}) |
|||
|
|||
def wpa_relevant(diff): |
|||
"""wpa reconfigure 필요 판정 (§5 APPLYING-③): 프로파일 변경.""" |
|||
return any(d["field"] == "wlan0.profiles" for d in diff) |
|||
@ -0,0 +1,105 @@ |
|||
# src/network/renderer.py |
|||
"""intent → dpworldapp byte-호환 렌더 (spec §3.2/§3.4/§4.1). |
|||
캡처(golden)와 1바이트라도 다르면 본 모듈 상수를 캡처에 맞춘다 — 안정성 요건.""" |
|||
import ipaddress, json |
|||
from network.netmodel import METRICS, _s, effective_profiles, gateway_set |
|||
|
|||
def _prefix(netmask): |
|||
return ipaddress.IPv4Network(f"0.0.0.0/{netmask}").prefixlen |
|||
|
|||
def _in_subnet(ip, net_ip, netmask): |
|||
try: |
|||
return ipaddress.IPv4Address(ip) in ipaddress.IPv4Network(f"{net_ip}/{_prefix(netmask)}", strict=False) |
|||
except ValueError: |
|||
return True # 판정 불가 시 host route 미생성 (보수) |
|||
|
|||
def render_network_file(intent, iface): |
|||
c = intent[iface]; m = METRICS[iface] |
|||
out = f"[Match]\nName={iface}\n\n[Network]\n" |
|||
if c["mode"] == "dhcp": |
|||
out += f"DHCP=ipv4\n\n[DHCPv4]\nRouteMetric={m}\n" |
|||
return out |
|||
out += f"Address={c['ip']}/{_prefix(c['netmask'])}\n" |
|||
# gateway_set: u32==0 ('', '0.0.0.0') 은 unset — dpworldapp @6840 과 동일하게 [Route] 생략 (§3.2) |
|||
if gateway_set(c): |
|||
out += f"\n[Route]\nDestination=0.0.0.0/0\nGateway={c['gateway']}\nMetric={m}\n" |
|||
if iface == "eth0": |
|||
sip = _s(c.get("server_ip")) |
|||
if sip and gateway_set(c) and c["mode"] == "static" \ |
|||
and not _in_subnet(sip, c["ip"], c["netmask"]): |
|||
out += f"\n[Route]\nDestination={sip}/32\nGateway={c['gateway']}\n" |
|||
return out |
|||
|
|||
def render_wpa_conf(intent): |
|||
w = intent["wlan0"] |
|||
out = "ctrl_interface=/var/run/wpa_supplicant\nupdate_config=1\n" |
|||
if w.get("country_code"): |
|||
out += f"country={w['country_code']}\n" |
|||
for i, p in enumerate(effective_profiles(intent)[:5]): |
|||
out += f'\nnetwork={{\n ssid="{p["ssid"]}"\n' |
|||
if p["security"] == "none": |
|||
out += " key_mgmt=NONE\n" |
|||
else: |
|||
out += f' psk="{p["password"]}"\n key_mgmt=WPA-PSK\n' |
|||
out += f" priority={5 - i}\n}}\n" |
|||
return out |
|||
|
|||
def render_country_file(intent): |
|||
cc = intent["wlan0"].get("country_code", "") |
|||
return cc # 캡처 확정 (Phase 0): 정확히 2바이트, trailing newline 없음 |
|||
|
|||
def _port_num(v): |
|||
"""캡처 확정: dpworldapp cJSON 은 port 를 JSON number 로 기록 — String DB 값도 캐스팅. |
|||
숫자 아닌 값은 원형 보존 (validator 가 사전 차단).""" |
|||
s = _s(v) |
|||
return int(s) if s.isdigit() else s |
|||
|
|||
def render_persist_json(intent): |
|||
"""network_config.json — 값·타입 정확성 요구, 서식 무관 (§1.3/§4.1).""" |
|||
w, e0, e1 = intent["wlan0"], intent["eth0"], intent["eth1"] |
|||
doc = { |
|||
"wlan0": {"mode": w["mode"], "ip": w["ip"], "netmask": w["netmask"], "gateway": w["gateway"], |
|||
"dns1": w["dns1"], "dns2": w["dns2"], "country_code": w["country_code"], |
|||
"saved_wifi_list": [{"ssid": p["ssid"], "password": p["password"], |
|||
"security": p["security"]} for p in effective_profiles(intent)]}, |
|||
"eth0": {"role": "lte_uplink", "mode": e0["mode"], "ip": e0["ip"], "netmask": e0["netmask"], |
|||
"gateway": e0["gateway"], "server_ip": e0["server_ip"], |
|||
"server_port": _port_num(e0["server_port"])}, |
|||
"eth1": {"role": "local_ethernet", "mode": e1["mode"], "ip": e1["ip"], "netmask": e1["netmask"], |
|||
"gateway": e1["gateway"], |
|||
"opc_ua_server_ip": e1["opc_ua_server_ip"], "opc_ua_server_port": _port_num(e1["opc_ua_server_port"]), |
|||
"modbus_server_ip": e1["modbus_server_ip"], "modbus_server_port": _port_num(e1["modbus_server_port"])}, |
|||
} |
|||
return json.dumps(doc, indent=2, ensure_ascii=False) + "\n" |
|||
|
|||
def intent_from_persist(doc): |
|||
"""network_config.json → intent (watchdog 판정 기준 §8 + golden 역변환). |
|||
I5: null/non-dict tolerant — (doc.get("iface") or {}) for all three ifaces; |
|||
saved_wifi_list entries that are not dicts are filtered out.""" |
|||
w = (doc.get("wlan0") or {}); e0 = (doc.get("eth0") or {}); e1 = (doc.get("eth1") or {}) |
|||
return { |
|||
"wlan0": {"mode": _s(w.get("mode")) or "dhcp", "ip": _s(w.get("ip")), |
|||
"netmask": _s(w.get("netmask")), "gateway": _s(w.get("gateway")), |
|||
"dns1": _s(w.get("dns1")), "dns2": _s(w.get("dns2")), |
|||
"country_code": _s(w.get("country_code")).upper(), |
|||
"profiles": [{"ssid": _s(p.get("ssid")), "password": _s(p.get("password")), |
|||
"security": _s(p.get("security")) or "wpa/wpa2"} |
|||
for p in (w.get("saved_wifi_list") or []) |
|||
if isinstance(p, dict) and _s(p.get("ssid"))]}, |
|||
"eth0": {"mode": _s(e0.get("mode")) or "dhcp", "ip": _s(e0.get("ip")), |
|||
"netmask": _s(e0.get("netmask")), "gateway": _s(e0.get("gateway")), |
|||
"server_ip": _s(e0.get("server_ip")), "server_port": e0.get("server_port", "")}, |
|||
"eth1": {"mode": _s(e1.get("mode")) or "dhcp", "ip": _s(e1.get("ip")), |
|||
"netmask": _s(e1.get("netmask")), "gateway": _s(e1.get("gateway")), |
|||
"opc_ua_server_ip": _s(e1.get("opc_ua_server_ip")), "opc_ua_server_port": e1.get("opc_ua_server_port", ""), |
|||
"modbus_server_ip": _s(e1.get("modbus_server_ip")), "modbus_server_port": e1.get("modbus_server_port", "")}, |
|||
} |
|||
|
|||
RENDER_FILES = { # 파일명 → 렌더 함수 (apply_engine/snapshot 공용 상수) |
|||
"network_config.json": render_persist_json, |
|||
"10-wlan0.network": lambda it: render_network_file(it, "wlan0"), |
|||
"10-eth0.network": lambda it: render_network_file(it, "eth0"), |
|||
"10-eth1.network": lambda it: render_network_file(it, "eth1"), |
|||
"wpa_supplicant-wlan0.conf": render_wpa_conf, |
|||
"wifi-country-code": render_country_file, |
|||
} |
|||
@ -0,0 +1,128 @@ |
|||
"""apply 전 백업 + manifest 해시 검증 + 복원 (spec §5 SNAPSHOT/§6.3). |
|||
스냅샷 = /home/root/network/* 전체 + DB 네트워크 필드(22항목 집합의 원본 dict).""" |
|||
import hashlib, json, os, shutil, time |
|||
|
|||
LKG = "last_known_good" # 파일 내용 = apply_id |
|||
|
|||
def _restrict(path, mode): |
|||
"""#5/#10: best-effort 권한 제한 — psk/password 평문이 world-readable 되지 않도록. |
|||
Windows 에선 os.chmod 가 read-only 비트만 토글하므로 효과 제한적이나 raise 하지 않음.""" |
|||
try: |
|||
os.chmod(path, mode) |
|||
except OSError: |
|||
pass |
|||
|
|||
def _sha(path): |
|||
h = hashlib.sha256() |
|||
with open(path, "rb") as f: |
|||
for chunk in iter(lambda: f.read(65536), b""): |
|||
h.update(chunk) |
|||
return h.hexdigest() |
|||
|
|||
def take(net_dir, backups_dir, apply_id, db_fields): |
|||
dest = os.path.join(backups_dir, apply_id) |
|||
os.makedirs(dest, exist_ok=True) |
|||
_restrict(dest, 0o700) # #5/#10: psk 평문 복사본 디렉토리 — owner-only |
|||
files = {} |
|||
if os.path.isdir(net_dir): |
|||
for name in sorted(os.listdir(net_dir)): |
|||
if os.sep in name or "/" in name or ".." in name: |
|||
continue # 방어: 경로 탈출성 이름 무시 (정상 listdir 결과면 미발동) |
|||
src = os.path.join(net_dir, name) |
|||
if not os.path.isfile(src): |
|||
continue |
|||
dst = os.path.join(dest, name) |
|||
shutil.copy2(src, dst) |
|||
_restrict(dst, 0o600) # #5/#10: wpa conf(psk)/persist(password) 복사본 — owner-only |
|||
files[name] = _sha(dst) |
|||
dbp = os.path.join(dest, "db_fields.json") |
|||
with open(dbp, "w", encoding="utf-8") as f: |
|||
json.dump(db_fields, f, ensure_ascii=False, indent=2) |
|||
_restrict(dbp, 0o600) # #5/#10: wifi_passwd 평문 — owner-only |
|||
manifest = {"apply_id": apply_id, "taken_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), |
|||
"files": files, "db_fields_sha": _sha(dbp)} |
|||
tmp = os.path.join(dest, "manifest.json.tmp") |
|||
with open(tmp, "w", encoding="utf-8") as f: |
|||
json.dump(manifest, f, ensure_ascii=False, indent=2) |
|||
man_path = os.path.join(dest, "manifest.json") |
|||
os.replace(tmp, man_path) |
|||
_restrict(man_path, 0o600) # #5/#10: 일관성 — owner-only |
|||
if not verify(dest): |
|||
raise RuntimeError(f"snapshot self-verify failed: {dest}") |
|||
return dest |
|||
|
|||
def verify(snap_dir): |
|||
try: |
|||
with open(os.path.join(snap_dir, "manifest.json"), encoding="utf-8") as f: |
|||
man = json.load(f) |
|||
except (OSError, ValueError): |
|||
return False |
|||
for name, sha in man.get("files", {}).items(): |
|||
p = os.path.join(snap_dir, name) |
|||
if not os.path.isfile(p) or _sha(p) != sha: |
|||
return False |
|||
dbp = os.path.join(snap_dir, "db_fields.json") |
|||
return os.path.isfile(dbp) and _sha(dbp) == man.get("db_fields_sha") |
|||
|
|||
def restore_files(snap_dir, net_dir, on_empty_keep=True): |
|||
"""렌더/JSON 파일 복원 (per-file tmp+rename) + 저장된 DB 필드 dict 반환. |
|||
호출측(apply_engine)이 DB-first 원칙에 따라 DB 복원을 먼저 수행한 뒤 본 함수를 부른다. |
|||
|
|||
#4 MEDIUM: 스냅샷이 0개 파일을 캡처했다면(fresh-flash: dpworldapp 미시드로 net_dir 가 |
|||
비어있던 baseline) "아무것도 없음" 으로 롤백하는 것이 적용본 유지보다 위험하다 — |
|||
10-eth1.network 가 사라지면 eth1 이 lockout. on_empty_keep=True(기본) 일 때 빈 스냅샷이면 |
|||
삭제 루프를 돌리지 않아 적용된 렌더를 그대로 둔다(운영자 연결 유지).""" |
|||
with open(os.path.join(snap_dir, "manifest.json"), encoding="utf-8") as f: |
|||
man = json.load(f) |
|||
os.makedirs(net_dir, exist_ok=True) |
|||
current = set(os.listdir(net_dir)) if os.path.isdir(net_dir) else set() |
|||
for name in man["files"]: |
|||
if not isinstance(name, str) or os.sep in name or "/" in name or ".." in name: |
|||
continue # 변조 manifest 의 경로 탈출 차단 — net_dir 밖 쓰기 금지 |
|||
src = os.path.join(snap_dir, name); dst = os.path.join(net_dir, name) |
|||
tmp = dst + ".tmp" |
|||
shutil.copy2(src, tmp); os.replace(tmp, dst) |
|||
current.discard(name) |
|||
# 스냅샷 시점에 없던 (apply 가 새로 만든) 파일 제거 — 단 우리 계약 파일만. |
|||
# #4: 빈 스냅샷(man["files"] 비어있음)이면 삭제 루프 생략 — 적용본 유지가 안전. |
|||
if man["files"] or not on_empty_keep: |
|||
from network.renderer import RENDER_FILES |
|||
for name in current: |
|||
if name in RENDER_FILES: |
|||
try: os.remove(os.path.join(net_dir, name)) |
|||
except OSError: pass |
|||
with open(os.path.join(snap_dir, "db_fields.json"), encoding="utf-8") as f: |
|||
return json.load(f) |
|||
|
|||
def mark_last_known_good(backups_dir, apply_id): |
|||
os.makedirs(backups_dir, exist_ok=True) |
|||
tmp = os.path.join(backups_dir, LKG + ".tmp") |
|||
with open(tmp, "w") as f: |
|||
f.write(apply_id) |
|||
os.replace(tmp, os.path.join(backups_dir, LKG)) |
|||
|
|||
def last_known_good(backups_dir): |
|||
try: |
|||
with open(os.path.join(backups_dir, LKG)) as f: |
|||
return f.read().strip() |
|||
except OSError: |
|||
return None |
|||
|
|||
def prune(backups_dir, keep=5): |
|||
lkg = last_known_good(backups_dir) |
|||
entries = [] # I4: per-dir fail-soft — listdir↔getmtime 사이 소멸한 디렉토리 무시 |
|||
for d in os.listdir(backups_dir): |
|||
p = os.path.join(backups_dir, d) |
|||
try: |
|||
if os.path.isdir(p) and not d.startswith("forensic"): |
|||
entries.append((os.path.getmtime(p), d)) |
|||
except (FileNotFoundError, OSError): |
|||
continue |
|||
entries.sort(reverse=True) |
|||
for _, d in entries[keep:]: |
|||
if d == lkg: |
|||
continue # LKG 는 보존 |
|||
try: |
|||
shutil.rmtree(os.path.join(backups_dir, d), ignore_errors=True) |
|||
except (FileNotFoundError, OSError): |
|||
continue |
|||
@ -0,0 +1,141 @@ |
|||
# src/network/validator.py |
|||
"""§5.2 hard rule — dpworldapp 파서/렌더 한계를 데이터로 위반하지 않기.""" |
|||
import ipaddress |
|||
from . import netmodel # v1.11.8 D: shared strict IPv4 (_ip_u32) — device/apply 경로 정합 |
|||
|
|||
ALLOWED_SECURITY = ("wpa/wpa2", "none") |
|||
MAX_SSID = 19 # 20byte 버퍼 snprintf 절단 — 바이트 기준 (§5.2) |
|||
PSK_MIN, PSK_MAX = 8, 19 # wpa 버퍼 — 바이트 기준 |
|||
MAX_PROFILES = 5 |
|||
FORBIDDEN_SSID_CHARS = ('"', "\\") # wpa 렌더 escaping 없음 |
|||
|
|||
def _ip_ok(s): |
|||
try: |
|||
ipaddress.IPv4Address(s); return True |
|||
except ValueError: |
|||
return False |
|||
|
|||
def _netmask_contiguous(netmask): |
|||
"""True iff netmask is a valid contiguous IPv4 netmask (e.g. 255.255.255.0).""" |
|||
try: |
|||
ipaddress.IPv4Network(f"0.0.0.0/{netmask}") |
|||
return True |
|||
except ValueError: |
|||
return False |
|||
|
|||
def _has_forbidden_char(s): |
|||
"""True if string contains '"', '\\', or any control char (ord < 0x20).""" |
|||
return any(ch == '"' or ch == '\\' or ord(ch) < 0x20 for ch in s) |
|||
|
|||
def _port_ok(value): |
|||
"""'' allowed; otherwise must be ASCII digits and 1 <= int <= 65535.""" |
|||
s = str(value) if value != "" else "" |
|||
if s == "": |
|||
return True |
|||
if not (s.isascii() and s.isdigit()): |
|||
return False |
|||
n = int(s) |
|||
return 1 <= n <= 65535 |
|||
|
|||
def validate(intent): |
|||
"""returns (errors:list[str], warnings:list[str]). errors 가 있으면 적용 거부.""" |
|||
errs, warns = [], [] |
|||
w = intent["wlan0"] |
|||
profs = w.get("profiles", []) |
|||
if len(profs) > MAX_PROFILES: |
|||
errs.append(f"wlan0.profiles: 최대 {MAX_PROFILES}개 (현재 {len(profs)})") |
|||
# gap 검출 (§5.2): 첫 빈 SSID 이후의 비어있지 않은 프로파일은 렌더에서 침묵 탈락 → 거부. |
|||
# trailing 빈 슬롯(UI 빈 행)은 허용. |
|||
first_empty = next((i for i, p in enumerate(profs) if not p["ssid"]), None) |
|||
if first_empty is not None: |
|||
for j in range(first_empty + 1, len(profs)): |
|||
if profs[j]["ssid"]: |
|||
errs.append(f"wlan0.profiles[{j}]: gap 금지 — 빈 슬롯[{first_empty}] 뒤 프로파일은 적용되지 않음 (압축 저장 필요)") |
|||
for i, p in enumerate(profs): |
|||
tag = f"wlan0.profiles[{i}]" |
|||
if not p["ssid"]: |
|||
continue # 빈 슬롯 자체는 gap 검사에서 처리 |
|||
ssid_bytes = len(p["ssid"].encode("utf-8")) |
|||
if ssid_bytes > MAX_SSID: |
|||
errs.append(f"{tag}: SSID {ssid_bytes}바이트 > {MAX_SSID} (펌웨어 절단)") |
|||
# C2: SSID 에 ", \, 또는 제어문자 금지 (wpa 렌더 escaping 없음 + injection 방지) |
|||
if _has_forbidden_char(p["ssid"]): |
|||
errs.append(f"{tag}: SSID 에 \", \\\\, 또는 제어문자 금지 (wpa 렌더 escaping 없음)") |
|||
if p["security"] not in ALLOWED_SECURITY: |
|||
errs.append(f"{tag}: security={p['security']!r} 불가 — {ALLOWED_SECURITY} 만 (그 외는 펌웨어가 프로파일 삭제)") |
|||
elif p["security"] == "none": |
|||
if p["password"]: |
|||
errs.append(f"{tag}: security=none 이면 password 는 빈 값 강제") |
|||
else: |
|||
pw_bytes = len(p["password"].encode("utf-8")) |
|||
if not (PSK_MIN <= pw_bytes <= PSK_MAX): |
|||
errs.append(f"{tag}: password {pw_bytes}바이트 — {PSK_MIN}-{PSK_MAX}바이트 필요") |
|||
# C2: password 에 ", \, 또는 제어문자 금지 |
|||
if _has_forbidden_char(p["password"]): |
|||
errs.append(f"{tag}: password 에 \", \\\\, 또는 제어문자 금지 (wpa 렌더 escaping 없음)") |
|||
cc = w.get("country_code", "") |
|||
if cc and not (len(cc) == 2 and cc.isalpha() and cc.isupper()): |
|||
errs.append(f"wlan0.country_code={cc!r}: 2자리 대문자 알파벳") |
|||
for iface in ("wlan0", "eth0", "eth1"): |
|||
c = intent[iface] |
|||
if c["mode"] != "static": |
|||
continue |
|||
if not _ip_ok(c["ip"]): |
|||
errs.append(f"{iface}: static 인데 IP 없음/형식 오류 ({c['ip']!r})") |
|||
continue |
|||
# C1: netmask must be contiguous (also subsumes the format check — a |
|||
# contiguous netmask is necessarily a valid IPv4 address; v1.11.10 Fix 38 |
|||
# removed the unreachable _ip_ok(netmask) follow-up block). |
|||
if not _netmask_contiguous(c["netmask"]): |
|||
errs.append(f"{iface}: netmask 비연속/형식 오류 ({c['netmask']!r})") |
|||
continue |
|||
gw = c.get("gateway", "") |
|||
if gw: # 빈 gateway 는 허용 — [Route] 생략 (§5.2/§12-2) |
|||
if not _ip_ok(gw): |
|||
errs.append(f"{iface}: gateway 형식 오류 ({gw!r})") |
|||
else: |
|||
try: |
|||
net = ipaddress.IPv4Network(f"{c['ip']}/{c['netmask']}", strict=False) |
|||
if ipaddress.IPv4Address(gw) not in net: |
|||
warns.append(f"{iface}: gateway {gw} 가 subnet {net} 밖 — 확인 필요") |
|||
except ValueError: |
|||
pass |
|||
# I3: port rules — eth0.server_port, eth1.opc_ua_server_port, eth1.modbus_server_port |
|||
port_checks = [ |
|||
("eth0", "server_port"), |
|||
("eth1", "opc_ua_server_port"), |
|||
("eth1", "modbus_server_port"), |
|||
] |
|||
for iface, field in port_checks: |
|||
val = intent[iface].get(field, "") |
|||
if not _port_ok(val): |
|||
errs.append(f"{iface}.{field}: 포트 범위 오류 ({val!r}) — 빈 값 또는 1-65535 정수") |
|||
# v1.11.8 D: server_ip / dns IPv4 format reject (이전엔 비교만 하고 형식 미검증). |
|||
# 빈 값 = clear(허용). netmodel._ip_u32 공유 검증기 사용 — 0=빈값, None=형식오류 |
|||
# (device_config 경로 _is_valid_ip 와 동일한 leading-zero strict 규칙). |
|||
ip_format_checks = [ |
|||
("eth0", "server_ip"), |
|||
("eth1", "opc_ua_server_ip"), |
|||
("eth1", "modbus_server_ip"), |
|||
("wlan0", "dns1"), |
|||
("wlan0", "dns2"), |
|||
] |
|||
for iface, field in ip_format_checks: |
|||
val = intent[iface].get(field, "") |
|||
if val in ("", None): |
|||
continue # 빈 값 = clear (허용) |
|||
if netmodel._ip_u32(val) is None: |
|||
errs.append(f"{iface}.{field}: IP 형식 오류 ({val!r})") |
|||
# C1: subnet-overlap loop — wrap each pair in try/except ValueError to never throw |
|||
statics = [(i, intent[i]) for i in ("wlan0", "eth0", "eth1") if intent[i]["mode"] == "static" |
|||
and _ip_ok(intent[i]["ip"]) and _netmask_contiguous(intent[i].get("netmask", ""))] |
|||
for a in range(len(statics)): |
|||
for b in range(a + 1, len(statics)): |
|||
try: |
|||
na = ipaddress.IPv4Network(f"{statics[a][1]['ip']}/{statics[a][1]['netmask']}", strict=False) |
|||
nb = ipaddress.IPv4Network(f"{statics[b][1]['ip']}/{statics[b][1]['netmask']}", strict=False) |
|||
if na.overlaps(nb): |
|||
warns.append(f"{statics[a][0]}/{statics[b][0]}: 서브넷 중복 ({na} ↔ {nb})") |
|||
except ValueError: |
|||
continue |
|||
return errs, warns |
|||
@ -0,0 +1,177 @@ |
|||
"""사후 검증 (spec §5 VERIFYING): 존재→carrier→주소·라우트→wpa_state→gateway ping. |
|||
carrier 없음 = 'config staged' WARN (§5.3). 모든 외부호출 runner 주입.""" |
|||
import os as _os, subprocess, time |
|||
|
|||
from network import netmodel |
|||
|
|||
def _default_runner(argv, timeout=10): |
|||
try: |
|||
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) |
|||
return p.returncode, p.stdout or "" |
|||
except (subprocess.SubprocessError, OSError) as e: |
|||
return 1, str(e) |
|||
|
|||
def _ip_brief(runner): |
|||
"""I8: (table, ok) — ip 명령 실패를 빈 테이블(=전부 staged WARN)로 위장하지 않는다.""" |
|||
rc, out = runner(["ip", "-br", "addr", "show"], 5) |
|||
table = {} |
|||
for ln in out.splitlines(): |
|||
parts = ln.split() |
|||
if len(parts) >= 2: |
|||
addrs = [t.split("/")[0] for t in parts[2:] if "." in t] |
|||
table[parts[0].split("@")[0]] = {"state": parts[1], "addrs": addrs} |
|||
return table, rc == 0 |
|||
|
|||
def verify(intent, changed_ifaces, runner=None, exists=None, wpa_wait_s=45, clock=None, |
|||
settle_s=15, deferred_country=None): |
|||
runner = runner or _default_runner |
|||
exists = exists or _os.path.exists |
|||
clock = clock or time.monotonic |
|||
checks, worst = [], "ok" |
|||
def add(name, result, detail=""): |
|||
nonlocal worst |
|||
checks.append({"name": name, "result": result, "detail": detail}) |
|||
order = {"ok": 0, "warn": 1, "fail": 2} |
|||
if order[result] > order[worst]: |
|||
worst = result |
|||
live, ip_ok = _ip_brief(runner) |
|||
routes_rc, routes = runner(["ip", "route", "show"], 5) |
|||
if not ip_ok: |
|||
# I8: ip 실패 = 라이브 상태 미상 → 명시적 fail + per-iface 라이브 검증 생략 |
|||
add("verifier.ip", "fail", "ip -br addr show 실패 — 라이브 상태 미상 (per-iface 검증 생략)") |
|||
return {"result": worst, "checks": checks} |
|||
if routes_rc != 0: |
|||
add("verifier.ip_route", "fail", "ip route show 실패 — 라우트 검증 불가") |
|||
routes = "" |
|||
for iface in changed_ifaces: |
|||
if iface == "country": # reboot-deferred 변경엔 라이브 검증 없음 |
|||
continue |
|||
c = intent.get(iface, {}) |
|||
if not exists(f"/sys/class/net/{iface}"): |
|||
add(f"{iface}.exists", "fail", "netdev 부재"); continue |
|||
add(f"{iface}.exists", "ok") |
|||
# DEF-1: wlan0 with effective profiles — wpa_state wait loop is the gate regardless of |
|||
# momentary carrier state (wrong password makes wpa drop → carrier DOWN before wpa settles). |
|||
# eth0/eth1 and wlan0 without profiles keep the existing staged-warn short-circuit. |
|||
# I5: effective_profiles 기준 — raw 빈 SSID 행으로 wpa 검증 오발동 금지 |
|||
wlan_gated = (iface == "wlan0" and bool(netmodel.effective_profiles(intent))) |
|||
st = live.get(iface, {}).get("state", "") |
|||
carrier_up = st not in ("NO-CARRIER", "DOWN", "") |
|||
if not carrier_up: |
|||
if wlan_gated: |
|||
# carrier momentarily down during association — fall through to wpa wait loop. |
|||
# do NOT run static address/route settle (no carrier yet). |
|||
# carrier check is deferred: emitted ok/warn AFTER wpa result is known. |
|||
pass # fall through (do NOT continue, do NOT add carrier check yet) |
|||
else: |
|||
add(f"{iface}.carrier", "warn", "carrier 없음 — config staged (§5.3)") |
|||
continue # 라이브 검증 생략 |
|||
else: |
|||
add(f"{iface}.carrier", "ok", st) |
|||
# A-3 결함 1 fix: networkctl reconfigure 는 비동기 — networkd 가 주소/라우트를 |
|||
# 내렸다 다시 올리는 과도기에 1회 샘플링하면 거짓 fail → settle-retry (≈2s 간격 |
|||
# 재조회, settle_s deadline). wpa 대기 루프와 동일한 주입 clock 패턴 (결정론 테스트). |
|||
if c.get("mode") == "static": |
|||
gw = c.get("gateway", "") |
|||
deadline = clock() + settle_s |
|||
# L1: 방어적 반복 cap — 고정 fake clock(deadline 불도달)에서도 무한회전 금지. |
|||
# 프로덕션(time.monotonic) 은 deadline 이 항상 먼저 이김 (cap 미도달). cap≥1 보장. |
|||
addr_ok = c["ip"] in live.get(iface, {}).get("addrs", []) |
|||
route_ok = (not gw) or (f"default via {gw} dev {iface}" in routes) |
|||
for _ in range(max(1, settle_s // 2 + 8)): |
|||
addr_ok = c["ip"] in live.get(iface, {}).get("addrs", []) |
|||
route_ok = (not gw) or (f"default via {gw} dev {iface}" in routes) |
|||
if (addr_ok and route_ok) or clock() >= deadline: |
|||
break |
|||
time.sleep(0 if clock is not time.monotonic else 2) |
|||
live2, ok2 = _ip_brief(runner) |
|||
if ok2: |
|||
live = live2 # 재조회 실패 시 직전 관측 유지 (일시 오류 내성) |
|||
rc2, out2 = runner(["ip", "route", "show"], 5) |
|||
if rc2 == 0: |
|||
routes = out2 |
|||
if addr_ok: |
|||
add(f"{iface}.address", "ok", c["ip"]) |
|||
else: |
|||
add(f"{iface}.address", "fail", |
|||
f"기대 {c['ip']} / 실제 {live.get(iface, {}).get('addrs')} (settle {settle_s}s)") |
|||
if gw: |
|||
if route_ok: |
|||
add(f"{iface}.route", "ok", gw) |
|||
else: |
|||
add(f"{iface}.route", "fail", |
|||
f"default via {gw} dev {iface} 부재 (settle {settle_s}s)") |
|||
if wlan_gated: |
|||
deadline = clock() + wpa_wait_s |
|||
state = "" |
|||
# L1: 방어적 반복 cap — 고정 fake clock 에서도 무한회전 금지 (프로덕션은 deadline 우선). |
|||
for _ in range(max(1, wpa_wait_s // 2 + 8)): |
|||
_, out = runner(["wpa_cli", "-i", "wlan0", "status"], 5) |
|||
state = next((l.split("=", 1)[1] for l in out.splitlines() |
|||
if l.startswith("wpa_state=")), "") |
|||
if state == "COMPLETED" or clock() >= deadline: |
|||
break |
|||
time.sleep(0 if clock is not time.monotonic else 2) |
|||
if state == "COMPLETED": |
|||
# deferred carrier check: association succeeded → emit ok |
|||
if not carrier_up: |
|||
add(f"{iface}.carrier", "ok", "association completed") |
|||
add("wlan0.wpa", "ok") |
|||
# carrier was down before wpa; re-check address now that association succeeded |
|||
if not carrier_up and c.get("mode") == "static": |
|||
live2, ok2 = _ip_brief(runner) |
|||
if ok2: |
|||
live = live2 |
|||
addr_ok = c["ip"] in live.get(iface, {}).get("addrs", []) |
|||
if addr_ok: |
|||
add(f"{iface}.address", "ok", c["ip"]) |
|||
else: |
|||
add(f"{iface}.address", "fail", |
|||
f"기대 {c['ip']} / 실제 {live.get(iface, {}).get('addrs')} (post-wpa)") |
|||
# Fix #14: when carrier was initially down, the route was never |
|||
# verified (the carrier-up settle branch above was skipped). |
|||
# Re-fetch routes and check the default route (mirror ~98-103). |
|||
gw = c.get("gateway", "") |
|||
if gw: |
|||
rc_rt, out_rt = runner(["ip", "route", "show"], 5) |
|||
if rc_rt == 0: |
|||
routes = out_rt |
|||
if f"default via {gw} dev {iface}" in routes: |
|||
add(f"{iface}.route", "ok", gw) |
|||
else: |
|||
add(f"{iface}.route", "fail", |
|||
f"default via {gw} dev {iface} 부재 (post-wpa)") |
|||
else: |
|||
# deferred carrier check: wpa failed → emit warn for the carrier state we saw |
|||
if not carrier_up: |
|||
add(f"{iface}.carrier", "warn", "association 진행 중 — wpa_state 대기") |
|||
add("wlan0.wpa", "fail", f"wpa_state={state or 'unknown'} (wait {wpa_wait_s}s)") |
|||
gw = c.get("gateway", "") |
|||
# minor: ping skip 은 per-iface — 다른 iface 의 fail 이 이 iface 의 ping 을 막지 않는다 |
|||
iface_failed = any(k["result"] == "fail" and k["name"].startswith(iface + ".") for k in checks) |
|||
if gw and c.get("mode") == "static" and not iface_failed: |
|||
ok = False |
|||
for _ in range(3): |
|||
rc, _o = runner(["ping", "-c", "1", "-W", "2", gw], 5) |
|||
if rc == 0: |
|||
ok = True; break |
|||
add(f"{iface}.gw_ping", "ok" if ok else "warn", gw if ok else f"{gw} 무응답 (WARN — AP측 원인 가능)") |
|||
# v1.9: split-apply 시 country 는 deferred — 라이브 regdomain 이 절대 NEW 로 새지 않았음을 단언. |
|||
# ground truth = iw reg get (wpa_cli get country 는 렌더값일 뿐, firmware 주석대로 거짓양성). |
|||
# positive 누출(live==NEW≠OLD)만 fail→롤백; 판독불가/모호는 warn(정상 apply 를 롤백시키지 않음). |
|||
if deferred_country is not None: |
|||
new_cc = netmodel._norm_country(intent.get("wlan0", {}).get("country_code", "")) |
|||
old_cc = netmodel._norm_country(deferred_country) |
|||
rc, out = runner(["iw", "reg", "get"], 5) |
|||
live_cc = "" |
|||
for ln in out.splitlines(): |
|||
s = ln.strip() |
|||
if s.startswith("country ") and len(s) >= 10 and s[8:10].isalpha(): |
|||
live_cc = s[8:10].upper(); break |
|||
if rc != 0 or not live_cc: |
|||
add("country.deferred", "warn", "regdomain 판독 불가 — deferred 미확인 (non-fatal)") |
|||
elif live_cc == new_cc and new_cc != old_cc: |
|||
add("country.deferred", "fail", f"deferred country 가 라이브로 누출: {live_cc} (기대 {old_cc})") |
|||
else: |
|||
add("country.deferred", "ok", f"regdomain {live_cc} 유지 (NEW {new_cc} 는 reboot 후)") |
|||
return {"result": worst, "checks": checks} |
|||
@ -0,0 +1,336 @@ |
|||
"""상시 감시·자가복구 (spec §8). 판정 기준 = network_config.json (적용본). |
|||
틱 30s(튜너블 10-300), 히스테리시스 2, cooldown 5분, 시간당 4회 한도. |
|||
억제: apply busy·CONFIRM_WAIT/seed·apply.service active → 전체 스킵, country-pending → |
|||
apply.service 에스컬레이션"만" 금지 (전면 정지 금지 — 자가복구 목표 약화). engine.tick() 은 |
|||
무조건 호출. 정상 틱은 디스크 무기록(RAM 카운터 + 1h heartbeat). |
|||
gateway ping 은 2틱마다 (interval×2) WARN 로그만.""" |
|||
import hashlib, json, os, subprocess, threading, time |
|||
|
|||
GRACE_S = 120 |
|||
COOLDOWN_S = 300 |
|||
HOURLY_MAX = 4 |
|||
HEARTBEAT_S = 3600 |
|||
APPLY_SERVICE = "dpworld-network-apply.service" |
|||
COMPAT_DPWORLDAPP_MD5 = ("a66c515be8de867e276aa7683c5582f1",) # §9 렌더 계약 검증된 빌드 |
|||
|
|||
# 복구 사다리: 체크 이름 → [단계1, 단계2, ...], 각 단계 = argv 목록 (§8 표) |
|||
_LADDERS = { |
|||
"wlan_module": [[["systemctl", "start", "dpworld-net-recover.service"]]], |
|||
"wpa_state": [[["wpa_cli", "-i", "wlan0", "reconfigure"]], |
|||
[["systemctl", "restart", "wpa_supplicant@wlan0.service"]]], |
|||
# §3.3-5: apply.service 단독은 gateway/metric-only 변경 침묵 누락 → reload 동반 필수 |
|||
"addr_route": [[["networkctl", "reconfigure", "wlan0"]], |
|||
[["systemctl", "start", APPLY_SERVICE], ["networkctl", "reload"]]], |
|||
} |
|||
|
|||
|
|||
def _addrs_in(ip_brief_out): |
|||
"""M2: `ip -br addr show` 출력 → 정확한 주소 목록 (prefix 제거) — substring 오탐 차단 |
|||
(예: 설정 192.168.55.5 가 라이브 192.168.55.54/24 에 substring 매칭되던 결함).""" |
|||
addrs = [] |
|||
for ln in ip_brief_out.splitlines(): |
|||
parts = ln.split() |
|||
addrs.extend(t.split("/")[0] for t in parts[2:]) |
|||
return addrs |
|||
|
|||
|
|||
def _ladder_for(name): |
|||
if name in ("eth0", "eth1"): |
|||
return [[["networkctl", "reconfigure", name]]] # 실패한 iface 그 자체 (§8) |
|||
return _LADDERS.get(name, [[["systemctl", "start", "dpworld-net-recover.service"]]]) |
|||
|
|||
|
|||
class NetworkWatchdog: |
|||
def __init__(self, db, engine, journal, net_dir, clock=time.monotonic, runner=None): |
|||
self.db = db; self.engine = engine; self.journal = journal |
|||
self.net_dir = net_dir; self.clock = clock |
|||
self.runner = runner or (lambda argv, t=20: _run(argv, t)) |
|||
self._fail_streak = {} # check name → 연속 실패 수 |
|||
self._last_recover = {} # check name → monotonic |
|||
self._recover_times = [] # 최근 1h 발동 시각 |
|||
self._critical = False |
|||
self._grace_until = None # start() 시 설정 |
|||
self._last_heartbeat = 0.0 |
|||
self._last_results = {} |
|||
self._tick_count = 0 |
|||
self._carrier_state = {} # iface → bool (transition WARN 용) |
|||
self._ping_state = {} # iface → bool (transition WARN 용) |
|||
self._down_drift_state = {} # M1: iface → bool (DOWN+주소drift transition WARN 용) |
|||
self._wpa_query_state = None # C3: wpa_cli 쿼리 성공 여부 (transition WARN 용) |
|||
self._last_apply_id = None # I1b: 마지막으로 본 apply_id — 변경 시 streak 리셋 |
|||
self._thread = None |
|||
# Fix #32: guard _critical / _recover_times — reset_critical() (config route |
|||
# thread) races the watchdog loop thread that mutates the recovery rate-limit. |
|||
self._lock = threading.Lock() |
|||
|
|||
# ── config (net_config DB key, log_config 전례) ───────── |
|||
def _cfg(self): |
|||
cfg = {} |
|||
try: |
|||
cfg = self.db.get_config("net_config") or {} |
|||
except Exception: # noqa: BLE001 |
|||
pass |
|||
cfg = cfg if isinstance(cfg, dict) else {} # I2: list 등 이형 → 기본값 (thread 사망 금지) |
|||
try: |
|||
interval = max(10, min(300, int(cfg.get("watchdog_interval_s", 30)))) |
|||
except (TypeError, ValueError): |
|||
interval = 30 |
|||
return {"enabled": cfg.get("watchdog_enabled", "on") == "on", |
|||
"auto_recover": cfg.get("watchdog_auto_recover", "on") == "on", |
|||
"interval_s": interval} # §8 튜너블 3종 |
|||
|
|||
# ── 판정 입력 ──────────────────────────────────────────── |
|||
def _intent(self): |
|||
try: |
|||
from network import renderer |
|||
with open(os.path.join(self.net_dir, "network_config.json"), encoding="utf-8") as f: |
|||
return renderer.intent_from_persist(json.load(f)) |
|||
except (OSError, ValueError): |
|||
return None |
|||
|
|||
def _note_carrier(self, ifc, up): |
|||
prev = self._carrier_state.get(ifc) |
|||
self._carrier_state[ifc] = up |
|||
if prev is not False and not up: # transition → WARN 1회 (§8: 복구 대상 아님) |
|||
self.journal.event("watchdog", phase="detect", action=f"{ifc}_no_carrier", |
|||
result="warn", detail={"note": "케이블 미연결 — 복구 안 함"}) |
|||
|
|||
def _note_wpa_query(self, ok): |
|||
"""C3: wpa_cli 쿼리 실패 transition WARN 1회 — _note_carrier 패턴.""" |
|||
prev = self._wpa_query_state |
|||
self._wpa_query_state = ok |
|||
if prev is not False and not ok: |
|||
self.journal.event("watchdog", phase="detect", action="wpa_query_failed", |
|||
result="warn", |
|||
detail={"note": "wpa_cli 무응답 — UNKNOWN 처리, 복구 안 함 (C3)"}) |
|||
|
|||
def _collect_checks(self): |
|||
"""name → bool(healthy). 이름 = wlan_module/wpa_state/addr_route/eth0/eth1. |
|||
테스트에서 통째 주입.""" |
|||
it = self._intent() |
|||
if it is None: |
|||
return {} |
|||
out = {} |
|||
from network import netmodel |
|||
wifi_configured = bool(netmodel.effective_profiles(it)) |
|||
if wifi_configured: |
|||
out["wlan_module"] = os.path.isdir("/sys/module/wlan") and \ |
|||
os.path.exists("/sys/class/net/wlan0") |
|||
rc, txt = self.runner(["wpa_cli", "-i", "wlan0", "status"], 5) |
|||
if rc == 0: |
|||
self._note_wpa_query(True) |
|||
out["wpa_state"] = "wpa_state=COMPLETED" in txt |
|||
else: |
|||
# C3: 쿼리 실패 = UNKNOWN — 키 생략 (unhealthy 오판 → production wpa flap 금지. |
|||
# 부팅 순서 race: wpa_supplicant 제어 소켓 준비 전 watchdog 선기동) |
|||
self._note_wpa_query(False) |
|||
if it["wlan0"]["mode"] == "static": |
|||
rc2, addrs = self.runner(["ip", "-br", "addr", "show", "wlan0"], 5) |
|||
if rc2 != 0: |
|||
# M-D: transient ip(8) failure → UNKNOWN, omit key (mirror wpa_state C3 pattern) |
|||
pass |
|||
else: |
|||
ok = it["wlan0"]["ip"] in _addrs_in(addrs) # M2: 정확 일치 |
|||
if ok and netmodel.gateway_set(it["wlan0"]): # §8: IP + 기본라우트 일치 |
|||
# Fix #13: gate on rc — a transient `ip route show` failure |
|||
# must not register as route-missing (mirror C3 rc-omit at |
|||
# ~125). On rc!=0 leave `ok` as the address-only result. |
|||
rc_rt, routes = self.runner(["ip", "route", "show"], 5) |
|||
if rc_rt == 0: |
|||
ok = f"default via {it['wlan0']['gateway']} dev wlan0" in routes |
|||
out["addr_route"] = ok |
|||
for ifc in ("eth0", "eth1"): |
|||
if it[ifc]["mode"] == "static" and it[ifc]["ip"]: |
|||
rc3, addrs3 = self.runner(["ip", "-br", "addr", "show", ifc], 5) |
|||
# 케이블 미연결/링크 다운은 복구 대상 아님 (§8). .56 실측: 케이블 없는 eth0 은 |
|||
# NO-CARRIER 가 아니라 state=DOWN 으로 나옴 — 가드 누락 시 5분마다 무의미 |
|||
# reconfigure → 시간당 4회 한도 도달 → watchdog CRITICAL 자멸. |
|||
tokens3 = addrs3.split() |
|||
state3 = tokens3[1] if len(tokens3) >= 2 else "" |
|||
if "NO-CARRIER" in addrs3 or state3 == "DOWN": |
|||
self._note_carrier(ifc, False) |
|||
# v1.6.0 codex M1: 복구는 여전히 보류(자멸 방지)하되, DOWN iface 가 stale/wrong |
|||
# IP 를 들고 있으면(intent IP 부재) 가시성만 추가 — transition-only WARN. |
|||
present = _addrs_in(addrs3) |
|||
drift = bool(present) and it[ifc]["ip"] not in present |
|||
prev = self._down_drift_state.get(ifc) |
|||
self._down_drift_state[ifc] = drift |
|||
if drift and not prev: |
|||
self.journal.event("watchdog", phase="detect", |
|||
action=f"{ifc}_down_addr_drift", result="warn", |
|||
detail={"expected": it[ifc]["ip"], "actual": present, |
|||
"state": state3, |
|||
"note": "link DOWN + 주소 불일치 — 복구 보류(케이블/링크 점검)"}) |
|||
self._fail_streak[ifc] = 0 # M3: carrier 부재 — streak 동결 아닌 리셋 |
|||
continue |
|||
self._note_carrier(ifc, True) |
|||
out[ifc] = it[ifc]["ip"] in _addrs_in(addrs3) # per-iface 키 + M2 정확 일치 |
|||
return out |
|||
|
|||
def _ping_targets(self): |
|||
"""gateway ping 대상 — 테스트 주입점.""" |
|||
it = self._intent() |
|||
if it is None: |
|||
return [] |
|||
from network import netmodel |
|||
return [(ifc, it[ifc]["gateway"]) for ifc in ("wlan0", "eth0", "eth1") |
|||
if it[ifc]["mode"] == "static" and netmodel.gateway_set(it[ifc])] |
|||
|
|||
def _gateway_ping_warn(self): |
|||
"""§8 체크 4: 2틱마다 (interval×2) ping — WARN 로그만, 자동복구 절대 금지 (auth-loop 이력 교훈).""" |
|||
for ifc, gw in self._ping_targets(): |
|||
rc, _ = self.runner(["ping", "-c", "1", "-W", "2", "-I", ifc, gw], 5) |
|||
ok = rc == 0 |
|||
prev = self._ping_state.get(ifc) |
|||
self._ping_state[ifc] = ok |
|||
if prev is not False and not ok: # transition 시에만 기록 (스팸 방지) |
|||
self.journal.event("watchdog", phase="detect", action="gw_ping", result="warn", |
|||
detail={"iface": ifc, "gateway": gw, "note": "자동복구 없음 (§8)"}) |
|||
|
|||
def _service_active(self, unit): |
|||
rc, _ = self.runner(["systemctl", "is-active", "--quiet", unit], 5) |
|||
return rc == 0 |
|||
|
|||
def _do_recover(self, name, ladder_idx): |
|||
ladder = _ladder_for(name) |
|||
step = ladder[min(ladder_idx, len(ladder) - 1)] |
|||
for argv in step: |
|||
# §8 억제 5: country deferred 보류 중엔 apply.service 에스컬레이션만 금지 |
|||
if self.engine.country_pending() and APPLY_SERVICE in argv: |
|||
self.journal.event("watchdog", phase="recover", action="skip_apply_service", |
|||
result="warn", detail={"check": name, "reason": "country_pending (§8)"}) |
|||
continue |
|||
rc, out = self.runner(argv, 30) |
|||
self.journal.event("watchdog", phase="recover", action=" ".join(argv), |
|||
result="ok" if rc == 0 else "fail", detail={"check": name, "rc": rc}) |
|||
|
|||
# ── 틱 ────────────────────────────────────────────────── |
|||
def reset_critical(self): |
|||
"""#6: 수동 재무장 — #2 config 라우트의 reset_watchdog_critical 가 호출. |
|||
critical 잠금 + 1h 발동 이력을 비워 자가복구를 즉시 되살린다.""" |
|||
with self._lock: # Fix #32: serialize with the watchdog loop's tick mutations |
|||
self._critical = False |
|||
self._recover_times = [] |
|||
self.journal.event("watchdog", phase="reset", action="critical_reset_manual", |
|||
result="ok", detail={}) |
|||
|
|||
def tick_once(self): |
|||
self.engine.tick() # confirm TTL 안전망 — 억제조건·enabled 와 무관하게 항상 (§6.1) |
|||
cfg = self._cfg() |
|||
now = self.clock() |
|||
# #6: 자동 재무장 — 시간당 한도(HOURLY_MAX)로 _critical 잠긴 뒤 1h 창이 비면 스스로 해제. |
|||
# early-return 전에 평가해야 critical 잠금 상태에서도 재무장이 동작한다. |
|||
with self._lock: # Fix #32: serialize rate-limit state with reset_critical() |
|||
self._recover_times = [t for t in self._recover_times if now - t < 3600] |
|||
rearmed = self._critical and not self._recover_times |
|||
if rearmed: |
|||
self._critical = False |
|||
if rearmed: |
|||
self.journal.event("watchdog", phase="rate_limit", action="critical_auto_rearmed", |
|||
result="ok", detail={}) |
|||
if not cfg["enabled"] or self._critical: |
|||
return |
|||
if self._grace_until is None: |
|||
self._grace_until = now + GRACE_S |
|||
if now < self._grace_until: |
|||
return |
|||
# I1a: CONFIRM_WAIT 포함 억제 — eth1 확인 대기 중 복구 사다리 침범 금지 |
|||
# (is_busy_or_confirming 부재 FakeEngine 호환: is_busy 폴백) |
|||
if getattr(self.engine, "is_busy_or_confirming", self.engine.is_busy)(): |
|||
return # apply 진행/확인 대기 중 일시정지 (§8) |
|||
if self._service_active(APPLY_SERVICE) or \ |
|||
self._service_active("dpworld-network-seed.service"): |
|||
return # dpworldapp 재시작 직후 정당한 재적용과 경합 금지 (§3.3-4) |
|||
# I1b: 새 apply 가 지나가면 구 구성 기준의 streak/transition 상태는 무효 — 리셋 |
|||
status_fn = getattr(self.engine, "status", None) |
|||
if status_fn is not None: |
|||
try: |
|||
aid = status_fn().get("apply_id") |
|||
except Exception: # noqa: BLE001 — status 실패가 watchdog 틱을 막으면 안 됨 |
|||
aid = self._last_apply_id |
|||
if aid != self._last_apply_id: |
|||
self._last_apply_id = aid |
|||
self._fail_streak.clear() |
|||
self._ping_state.clear() |
|||
self._carrier_state.clear() |
|||
self._down_drift_state.clear() # Fix #18: else a stale DOWN+drift |
|||
# warning is silenced for the watchdog's lifetime (transition gate) |
|||
self._tick_count += 1 |
|||
results = self._collect_checks() |
|||
self._last_results = results |
|||
for name, healthy in results.items(): |
|||
if healthy: |
|||
self._fail_streak[name] = 0 |
|||
continue |
|||
self._fail_streak[name] = self._fail_streak.get(name, 0) + 1 |
|||
if self._fail_streak[name] < 2: # 히스테리시스 (§8) |
|||
continue |
|||
if not cfg["auto_recover"]: |
|||
self.journal.event("watchdog", phase="detect", action=name, result="warn", |
|||
detail={"auto_recover": False}) |
|||
continue |
|||
if now - self._last_recover.get(name, -1e9) < COOLDOWN_S: |
|||
continue |
|||
with self._lock: # Fix #32: serialize rate-limit state with reset_critical() |
|||
self._recover_times = [t for t in self._recover_times if now - t < 3600] |
|||
tripped = len(self._recover_times) >= HOURLY_MAX |
|||
if tripped: |
|||
self._critical = True |
|||
recoveries = len(self._recover_times) |
|||
if tripped: |
|||
self.journal.event("watchdog", phase="rate_limit", action="critical_stop", |
|||
result="fail", detail={"recoveries_last_hour": recoveries}) |
|||
return |
|||
ladder_idx = max(0, self._fail_streak[name] - 2) |
|||
self._do_recover(name, ladder_idx) |
|||
self._last_recover[name] = now |
|||
with self._lock: # Fix #32: serialize rate-limit state with reset_critical() |
|||
self._recover_times.append(now) |
|||
if self._tick_count % 2 == 0: |
|||
self._gateway_ping_warn() # 2틱마다 = interval×2 (§8 체크 4) |
|||
if now - self._last_heartbeat >= HEARTBEAT_S: |
|||
self._last_heartbeat = now |
|||
self.journal.event("watchdog", phase="heartbeat", action="tick", result="ok", |
|||
detail={"checks": {k: bool(v) for k, v in results.items()}}) |
|||
|
|||
def _check_fw_md5(self): |
|||
"""§9 펌웨어 drift 감시 — 시작 시 1회: 렌더 계약 검증된 dpworldapp 빌드인지. |
|||
I6: 1MB 청크 read — MemoryMax=48M 하에서 수십 MB 바이너리 전체 read 금지.""" |
|||
try: |
|||
h = hashlib.md5() |
|||
with open("/usr/bin/dpworldapp", "rb") as f: |
|||
for chunk in iter(lambda: f.read(1024 * 1024), b""): |
|||
h.update(chunk) |
|||
md5 = h.hexdigest() |
|||
except OSError: |
|||
return |
|||
if md5 not in COMPAT_DPWORLDAPP_MD5: |
|||
self.journal.event("watchdog", phase="startup", action="fw_md5_drift", result="warn", |
|||
detail={"md5": md5, "note": "렌더 계약 미검증 펌웨어 — golden 재캡처 필요 (§9)"}) |
|||
|
|||
def snapshot(self): |
|||
return {"enabled": self._cfg()["enabled"], "critical": self._critical, |
|||
"fail_streak": dict(self._fail_streak), "last_results": dict(self._last_results)} |
|||
|
|||
def start(self): |
|||
if self._thread: |
|||
return |
|||
self._grace_until = self.clock() + GRACE_S |
|||
self._check_fw_md5() |
|||
def loop(): |
|||
while True: |
|||
try: |
|||
self.tick_once() |
|||
time.sleep(self._cfg()["interval_s"]) |
|||
except Exception: # noqa: BLE001 — watchdog 은 절대 죽지 않는다 |
|||
time.sleep(30) # I2: sleep 도 try 안 — _cfg 예외로 thread 사망/tight-loop 금지 |
|||
self._thread = threading.Thread(target=loop, daemon=True, name="net-watchdog") |
|||
self._thread.start() |
|||
|
|||
|
|||
def _run(argv, timeout): |
|||
try: |
|||
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) |
|||
return p.returncode, p.stdout or "" |
|||
except (subprocess.SubprocessError, OSError) as e: |
|||
return 1, str(e) |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@ -0,0 +1,229 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<meta name="description" content="IoT Device Web Configurator - Network, Protocol, and I/O Configuration"> |
|||
<link rel="icon" href="data:,"> |
|||
<title>IoT Web Configurator</title> |
|||
<!-- v1.4.6.10 ①: 외부 Google Fonts CDN 제거. 본 앱은 LAN 전용 IoT 장비에 직접 접속하는 |
|||
오프라인 환경에서 사용 — 외부 CDN은 (a) DNS 타임아웃까지 매달려 첫 페인트 수초 지연, |
|||
(b) 폰트 폴백으로 디자인 열화 트리거. style.css의 --font-family 폴백 체인이 시스템 폰트 |
|||
(-apple-system / Segoe UI 등)로 안전 동작. Inter / JetBrains Mono를 정식 사용하려면 |
|||
.woff2 파일을 src/static/fonts/에 추가 후 style.css의 @font-face 블록 활성화. --> |
|||
<link rel="stylesheet" href="css/style.css"> |
|||
<!-- v1.5.4: modulepreload hints — browser fetches critical modules in parallel |
|||
before app.js fully parses, reducing import waterfall latency --> |
|||
<link rel="modulepreload" href="js/app.js"> |
|||
<link rel="modulepreload" href="js/state.js"> |
|||
<link rel="modulepreload" href="js/api.js"> |
|||
<link rel="modulepreload" href="js/utils.js"> |
|||
<link rel="modulepreload" href="js/icons.js"> |
|||
<link rel="modulepreload" href="js/constants.js"> |
|||
<link rel="modulepreload" href="js/page-dirty.js"> |
|||
<link rel="modulepreload" href="js/nav-guard.js"> |
|||
<link rel="modulepreload" href="js/pages/home.js"> |
|||
</head> |
|||
<body> |
|||
<!-- v1.5.0 P1: Skip-to-content accessibility (WCAG 2.4.1) --> |
|||
<a href="#main-content" class="skip-link">Skip to main content</a> |
|||
|
|||
<!-- v1.5.0 P1: Sidebar nested 4-group + 13 leaf (v1.11.14: system/network/interface-protocol/firmware) --> |
|||
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Main navigation"> |
|||
<div class="sidebar__header"> |
|||
<a href="#" class="sidebar__logo" data-page="home" id="logo-home"> |
|||
<img class="sidebar__logo-img" src="img/dp-world-logo.svg" alt="DP World"> |
|||
<span class="sidebar__logo-cap">CONFIGURATOR</span> |
|||
</a> |
|||
</div> |
|||
|
|||
<nav class="sidebar__nav" id="sidebar-nav" aria-label="Configuration pages"> |
|||
<!-- Group: System (v1.11.14 — Dashboard + Log) --> |
|||
<div class="nav-group" data-group="system"> |
|||
<button type="button" class="nav-group__toggle" |
|||
aria-expanded="true" aria-controls="leaves-system"> |
|||
<span class="nav-group__icon" id="icon-grp-system"></span> |
|||
<span class="nav-group__label">System</span> |
|||
<span class="nav-group__chevron" id="icon-chev-system" aria-hidden="true"></span> |
|||
</button> |
|||
<ul class="nav-group__leaves" id="leaves-system"> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="home" id="nav-home"> |
|||
<span class="nav-item__icon" id="icon-nav-home"></span> |
|||
<span class="nav-item__label">Dashboard</span> |
|||
</a></li> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="log" id="nav-log"> |
|||
<span class="nav-item__icon" id="icon-nav-log"></span> |
|||
<span class="nav-item__label">Log</span> |
|||
<span class="nav-item__dirty" id="dirty-log" aria-hidden="true"></span> |
|||
</a></li> |
|||
</ul> |
|||
</div> |
|||
|
|||
<!-- Group: Network --> |
|||
<div class="nav-group" data-group="network"> |
|||
<button type="button" class="nav-group__toggle" |
|||
aria-expanded="true" aria-controls="leaves-network"> |
|||
<span class="nav-group__icon" id="icon-grp-network"></span> |
|||
<span class="nav-group__label">Network</span> |
|||
<span class="nav-group__chevron" id="icon-chev-network" aria-hidden="true"></span> |
|||
</button> |
|||
<ul class="nav-group__leaves" id="leaves-network"> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="wifi" id="nav-wifi"> |
|||
<span class="nav-item__icon" id="icon-nav-wifi"></span> |
|||
<span class="nav-item__label">Wi-Fi</span> |
|||
<span class="nav-item__dirty" id="dirty-wifi" aria-hidden="true"></span> |
|||
</a></li> |
|||
<!-- AP: nav leaf --> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="wifi-ap" id="nav-wifi-ap"> |
|||
<span class="nav-item__icon" id="icon-nav-wifi-ap"></span> |
|||
<span class="nav-item__label">Wi-Fi AP</span> |
|||
<span class="nav-item__dirty" id="dirty-wifi-ap" aria-hidden="true"></span> |
|||
</a></li> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="ethernet" id="nav-ethernet"> |
|||
<span class="nav-item__icon" id="icon-nav-ethernet"></span> |
|||
<span class="nav-item__label">Ethernet</span> |
|||
<span class="nav-item__dirty" id="dirty-ethernet" aria-hidden="true"></span> |
|||
</a></li> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="server-setting" id="nav-server-setting"> |
|||
<span class="nav-item__icon" id="icon-nav-server-setting"></span> |
|||
<span class="nav-item__label">Server Setting</span> |
|||
<span class="nav-item__dirty" id="dirty-server-setting" aria-hidden="true"></span> |
|||
</a></li> |
|||
<!-- v1.8.0: Apply & Status 는 Advanced/Debug 보기 전용 (User 모드 숨김). --> |
|||
<li class="advanced-only"><a href="#" class="nav-item nav-item--leaf" data-page="net-apply" id="nav-net-apply"> |
|||
<span class="nav-item__icon" id="icon-nav-net-apply"></span> |
|||
<span class="nav-item__label">Apply & Status</span> |
|||
</a></li> |
|||
</ul> |
|||
</div> |
|||
|
|||
<!-- Group: Interface & Protocol --> |
|||
<div class="nav-group" data-group="interface-protocol"> |
|||
<button type="button" class="nav-group__toggle" |
|||
aria-expanded="true" aria-controls="leaves-interface-protocol"> |
|||
<span class="nav-group__icon" id="icon-grp-interface"></span> |
|||
<span class="nav-group__label">Interface & Protocol</span> |
|||
<span class="nav-group__chevron" id="icon-chev-interface" aria-hidden="true"></span> |
|||
</button> |
|||
<ul class="nav-group__leaves" id="leaves-interface-protocol"> |
|||
<!-- v1.5.0 P3: register→general, NEW sensor-io, can→can-bus --> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="general" id="nav-general"> |
|||
<span class="nav-item__icon" id="icon-nav-general"></span> |
|||
<span class="nav-item__label">General Settings</span> |
|||
<span class="nav-item__dirty" id="dirty-general" aria-hidden="true"></span> |
|||
</a></li> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="sensor-io" id="nav-sensor-io"> |
|||
<span class="nav-item__icon" id="icon-nav-sensor-io"></span> |
|||
<span class="nav-item__label">Sensor I/O</span> |
|||
<span class="nav-item__dirty" id="dirty-sensor-io" aria-hidden="true"></span> |
|||
</a></li> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="can-bus" id="nav-can-bus"> |
|||
<span class="nav-item__icon" id="icon-nav-can-bus"></span> |
|||
<span class="nav-item__label">CAN-BUS</span> |
|||
<span class="nav-item__dirty" id="dirty-can-bus" aria-hidden="true"></span> |
|||
</a></li> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="opcua" id="nav-opcua"> |
|||
<span class="nav-item__icon" id="icon-nav-opcua"></span> |
|||
<span class="nav-item__label">OPC UA</span> |
|||
<span class="nav-item__dirty" id="dirty-opcua" aria-hidden="true"></span> |
|||
</a></li> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="modbus" id="nav-modbus"> |
|||
<span class="nav-item__icon" id="icon-nav-modbus"></span> |
|||
<span class="nav-item__label">Modbus</span> |
|||
<span class="nav-item__dirty" id="dirty-modbus" aria-hidden="true"></span> |
|||
</a></li> |
|||
</ul> |
|||
</div> |
|||
|
|||
<!-- Group: Firmware (v1.11.14 — own collapsible group, leaf=Update) --> |
|||
<div class="nav-group" data-group="firmware"> |
|||
<button type="button" class="nav-group__toggle" |
|||
aria-expanded="true" aria-controls="leaves-firmware"> |
|||
<span class="nav-group__icon" id="icon-grp-firmware"></span> |
|||
<span class="nav-group__label">Firmware</span> |
|||
<span class="nav-group__chevron" id="icon-chev-firmware" aria-hidden="true"></span> |
|||
</button> |
|||
<ul class="nav-group__leaves" id="leaves-firmware"> |
|||
<li><a href="#" class="nav-item nav-item--leaf" data-page="firmware" id="nav-firmware"> |
|||
<span class="nav-item__icon" id="icon-nav-firmware"></span> |
|||
<span class="nav-item__label">Update</span> |
|||
</a></li> |
|||
</ul> |
|||
</div> |
|||
</nav> |
|||
|
|||
<div class="sidebar__footer"> |
|||
<!-- v1.7.0: 전역 미저장/미적용 배지 — 클릭 시 Pending Changes 패널 --> |
|||
<button type="button" class="pending-badge hidden" id="pending-badge" |
|||
data-pending-badge |
|||
aria-label="No pending changes" title="View pending changes"></button> |
|||
<button class="btn btn--primary btn--full" id="btn-save-all"> |
|||
<span>💾</span> Save All |
|||
</button> |
|||
<div class="sidebar__actions"> |
|||
<button class="btn btn--outline btn--sm" id="btn-import"> |
|||
<span>📥</span> Import |
|||
</button> |
|||
<button class="btn btn--outline btn--sm" id="btn-export"> |
|||
<span>📤</span> Export |
|||
</button> |
|||
</div> |
|||
<!-- v1.8.0: Advanced/Debug 보기 토글 — apply 머신(step·watchdog·drift·journal· |
|||
force·Apply&Status·미적용 배지) 노출. 보안 경계 아님(누구나 켤 수 있는 표시 스위치). --> |
|||
<label class="view-toggle" for="advanced-view-toggle"> |
|||
<input type="checkbox" id="advanced-view-toggle" class="view-toggle__input" data-no-dirty> |
|||
<span class="view-toggle__label">Advanced / Debug view</span> |
|||
<span class="view-toggle__hint">Shows apply steps, watchdog, and diagnostics. Not a security boundary.</span> |
|||
</label> |
|||
<div class="sidebar__version"> |
|||
<span class="sidebar__version-name" id="app-name"></span> |
|||
<span class="sidebar__version-num" id="app-version"></span> |
|||
</div> |
|||
</div> |
|||
</aside> |
|||
|
|||
<!-- Mobile Header --> |
|||
<header class="mobile-header" id="mobile-header"> |
|||
<button class="mobile-header__menu" id="btn-menu" aria-label="Open menu">☰</button> |
|||
<span class="mobile-header__title">IoT Configurator</span> |
|||
<button type="button" class="pending-badge hidden" id="pending-badge-mobile" |
|||
data-pending-badge |
|||
aria-label="No pending changes" title="View pending changes"></button> |
|||
<button class="btn btn--primary btn--sm" id="btn-save-mobile" aria-label="Save changes">💾</button> |
|||
</header> |
|||
|
|||
<!-- Overlay for mobile sidebar --> |
|||
<div class="sidebar-overlay" id="sidebar-overlay"></div> |
|||
|
|||
<!-- Main Content --> |
|||
<main class="main" id="main-content" role="main"> |
|||
<!-- Loading Spinner --> |
|||
<div class="loading" id="loading"> |
|||
<div class="loading__spinner"></div> |
|||
<p>Loading configuration...</p> |
|||
</div> |
|||
|
|||
<!-- v1.8.0: Simple Apply host — "Applying…" 진행 + eth1 평이 재접속 배너. |
|||
★ page-container 밖이라 페이지 전환에도 살아남는다(eth1 confirm 지속). --> |
|||
<div class="apply-flow-host" id="apply-flow-host"></div> |
|||
|
|||
<!-- Page Sections (rendered by JS modules) --> |
|||
<div class="page-container" id="page-container" style="display:none;"></div> |
|||
</main> |
|||
|
|||
<!-- Toast Container — v1.7.1 Task 4: live region for screen readers --> |
|||
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="false"></div> |
|||
<!-- M-9: assertive SR-only mirror for error/warning toasts. The polite |
|||
#toast-container governs all visual toasts; this hidden region is only |
|||
populated (via toast.js) for error/warning so AT interrupts immediately. --> |
|||
<div id="toast-sr-assertive" role="alert" aria-live="assertive" class="sr-only"></div> |
|||
|
|||
<!-- Hidden file input for import --> |
|||
<input type="file" id="file-import" accept=".json" style="display:none;"> |
|||
|
|||
<!-- Theme Toggle (floating) --> |
|||
<button class="theme-toggle" id="btn-theme" title="Toggle dark/light mode" aria-label="Toggle theme">🌙</button> |
|||
|
|||
<script type="module" src="js/app.js"></script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,258 @@ |
|||
/** |
|||
* api.js — API Client Module |
|||
* |
|||
* Handles all HTTP communication with the Python backend. |
|||
* Matches the 4-endpoint REST API from the Java Spring Boot server. |
|||
*/ |
|||
|
|||
const API_BASE = 'setting'; |
|||
|
|||
/** |
|||
* GET /setting/get-device |
|||
* @returns {Promise<Object|null>} Device config or null if not set |
|||
*/ |
|||
export async function getDevice() { |
|||
const res = await fetch(`${API_BASE}/get-device`); |
|||
if (res.status === 204) return null; |
|||
if (!res.ok) throw new Error(`Failed to load device config: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /setting/device |
|||
* @param {Object} data - Device configuration object |
|||
* @returns {Promise<{success: boolean, message: string, warnings: string[]}>} |
|||
*/ |
|||
export async function saveDevice(data) { |
|||
const res = await fetch(`${API_BASE}/device`, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify(data), |
|||
}); |
|||
if (!res.ok) { |
|||
const err = await res.json().catch(() => ({ message: `Save failed: ${res.status}` })); |
|||
if (res.status === 400 && Array.isArray(err.errors) && err.errors.length > 0) { |
|||
throw new Error('Invalid value: ' + err.errors.slice(0, 3).join(' · ')); |
|||
} |
|||
throw new Error(err.message || `Save failed: ${res.status}`); |
|||
} |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* GET /setting/get-protocol |
|||
* @returns {Promise<Object|null>} Protocol config or null if not set |
|||
*/ |
|||
export async function getProtocol() { |
|||
const res = await fetch(`${API_BASE}/get-protocol`); |
|||
if (res.status === 204) return null; |
|||
if (!res.ok) throw new Error(`Failed to load protocol config: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /setting/protocol |
|||
* @param {Object} data - Protocol configuration object |
|||
* @returns {Promise<Object>} Saved protocol config (with inactive protocols removed) |
|||
*/ |
|||
export async function saveProtocol(data) { |
|||
const res = await fetch(`${API_BASE}/protocol`, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify(data), |
|||
}); |
|||
if (!res.ok) { |
|||
const err = await res.json().catch(() => ({})); |
|||
if (res.status === 400 && Array.isArray(err.errors) && err.errors.length > 0) { |
|||
throw new Error('Invalid value: ' + err.errors.slice(0, 3).join(' · ')); |
|||
} |
|||
throw new Error(err.message || err.error || `Failed to save protocol config: ${res.status}`); |
|||
} |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* GET /setting/log-files |
|||
* @returns {Promise<Object>} { files: [...], total_size: number } |
|||
*/ |
|||
export async function getLogFiles() { |
|||
const res = await fetch(`${API_BASE}/log-files`); |
|||
if (!res.ok) throw new Error(`Failed to load log files: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /setting/log-download |
|||
* @param {string[]} files - Array of filenames to download |
|||
* @returns {Promise<Blob>} tar.gz archive blob |
|||
*/ |
|||
export async function downloadLogFiles(files) { |
|||
const res = await fetch(`${API_BASE}/log-download`, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ files }), |
|||
}); |
|||
if (!res.ok) { |
|||
const err = await res.json().catch(() => ({ error: `Download failed: ${res.status}` })); |
|||
throw new Error(err.error || `Download failed: ${res.status}`); |
|||
} |
|||
return res.blob(); |
|||
} |
|||
|
|||
/** |
|||
* GET /setting/log-stats |
|||
* @returns {Promise<Object>} Log directory disk usage stats |
|||
*/ |
|||
export async function getLogStats() { |
|||
const res = await fetch(`${API_BASE}/log-stats`); |
|||
if (!res.ok) throw new Error(`Failed to load log stats: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /setting/log-compress |
|||
* @returns {Promise<Object>} { success, compressed, message } |
|||
*/ |
|||
export async function compressLogFiles() { |
|||
const res = await fetch(`${API_BASE}/log-compress`, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: '{}', |
|||
}); |
|||
if (!res.ok) throw new Error(`Compression failed: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /setting/log-delete |
|||
* @param {string[]} files - Array of filenames to delete |
|||
* @returns {Promise<Object>} { success, deleted, message } |
|||
*/ |
|||
export async function deleteLogFilesApi(files) { |
|||
const res = await fetch(`${API_BASE}/log-delete`, { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ files }), |
|||
}); |
|||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* GET /api/mac |
|||
* @returns {Promise<string|null>} WiFi MAC address or null if unavailable |
|||
*/ |
|||
export async function getMac() { |
|||
try { |
|||
const res = await fetch('api/mac'); |
|||
if (!res.ok) return null; |
|||
const data = await res.json(); |
|||
return data.mac || null; |
|||
} catch { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* GET /api/system-status |
|||
* @returns {Promise<Object>} Aggregated device status snapshot |
|||
*/ |
|||
export async function getSystemStatus() { |
|||
const res = await fetch('api/system-status'); |
|||
if (!res.ok) throw new Error(`Failed to load status: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /api/action/test-connections |
|||
* @returns {Promise<{results: Array}>} Per-endpoint reachability results |
|||
*/ |
|||
export async function testConnections() { |
|||
const res = await fetch('api/action/test-connections', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: '{}', |
|||
}); |
|||
if (!res.ok) throw new Error(`Connection test failed: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /api/action/restart-dpworldapp |
|||
* @returns {Promise<{restarted: boolean, running: boolean, pid?: number}>} |
|||
*/ |
|||
export async function restartDpworldapp() { |
|||
const res = await fetch('api/action/restart-dpworldapp', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: '{}', |
|||
}); |
|||
if (!res.ok) throw new Error(`Restart failed: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* GET /api/support-bundle |
|||
* @returns {Promise<Blob>} Diagnostics zip blob |
|||
*/ |
|||
export async function getSupportBundle() { |
|||
const res = await fetch('api/support-bundle'); |
|||
if (!res.ok) throw new Error(`Bundle download failed: ${res.status}`); |
|||
return res.blob(); |
|||
} |
|||
|
|||
// ── Firmware OTA API (v1.5.0 Phase 4a) ───────────────────────────────────────
|
|||
|
|||
/** GET /api/firmware/status → rich status snapshot (drives the whole UI). */ |
|||
export async function getFirmwareStatus() { |
|||
const res = await fetch('api/firmware/status'); |
|||
if (!res.ok) throw new Error(`status ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** POST /api/firmware/preflight → { ok, checks:[...] } */ |
|||
export async function preflightFirmware() { |
|||
const res = await fetch('api/firmware/preflight', { method: 'POST', body: '{}' }); |
|||
if (!res.ok) throw new Error(`preflight failed: ${res.status}`); |
|||
return res.json(); |
|||
} |
|||
|
|||
/** |
|||
* POST /api/firmware/upload (raw ZIP body) → { ok, components:[...] } |
|||
* Uses XHR for upload-progress events. |
|||
* @param {File} file |
|||
* @param {(loaded:number,total:number)=>void} [onProgress] |
|||
*/ |
|||
export function uploadFirmware(file, onProgress) { |
|||
return new Promise((resolve, reject) => { |
|||
const xhr = new XMLHttpRequest(); |
|||
xhr.open('POST', 'api/firmware/upload'); |
|||
xhr.upload.onprogress = (e) => { |
|||
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); |
|||
}; |
|||
xhr.onload = () => { |
|||
let r; |
|||
try { r = JSON.parse(xhr.responseText); } catch { r = {}; } |
|||
if (xhr.status === 200 && r.ok) resolve(r); |
|||
else reject(new Error(r.error || `upload failed (${xhr.status})`)); |
|||
}; |
|||
xhr.onerror = () => reject(new Error('upload network error')); |
|||
xhr.send(file); |
|||
}); |
|||
} |
|||
|
|||
/** POST /api/firmware/flash → { ok } (starts background flash) */ |
|||
export async function startFlash() { |
|||
const res = await fetch('api/firmware/flash', { method: 'POST', body: '{}' }); |
|||
const r = await res.json().catch(() => ({})); |
|||
if (!res.ok) throw new Error(r.error || `flash failed: ${res.status}`); |
|||
return r; |
|||
} |
|||
|
|||
/** POST /api/firmware/restore-check → { ok, result:{restored,reason,...} } */ |
|||
export async function restoreCheck() { |
|||
const res = await fetch('api/firmware/restore-check', { method: 'POST', body: '{}' }); |
|||
const r = await res.json().catch(() => ({})); |
|||
if (!res.ok) throw new Error(r.error || `restore-check failed: ${res.status}`); |
|||
return r; |
|||
} |
|||
@ -0,0 +1,904 @@ |
|||
/** |
|||
* app.js — Main Application Entry Point |
|||
* |
|||
* Orchestrates page navigation, data loading/saving, theming, |
|||
* and import/export functionality. |
|||
*/ |
|||
|
|||
import { getDevice, getProtocol, saveDevice, saveProtocol, getMac } from './api.js'; |
|||
import { APP_NAME, APP_VERSION } from './constants.js'; |
|||
import { showToast } from './toast.js'; |
|||
import { state, setDevice, setProtocol, buildDevicePayload, buildProtocolPayload, commitChangesAsBaseline } from './state.js'; |
|||
import { clearAllFieldErrors, setPageError, clearAllPageErrors, focusErrorField } from './validator.js'; |
|||
import { getDirtyPages, clearAllDirty, markDirty } from './page-dirty.js'; |
|||
import { attachDirtyTracking } from './dirty-tracker.js'; |
|||
import { renderSsidPage } from './pages/ssid.js'; |
|||
import ssidPage from './pages/ssid.js'; |
|||
// v1.5.0 P2: wifi.js (ssid.js rename)
|
|||
import wifiPage from './pages/wifi.js'; |
|||
// v1.5.0 P2 T2: ethernet.js NEW (eth + LTE interface IP)
|
|||
import ethernetPage from './pages/ethernet.js'; |
|||
import { renderNetworkPage } from './pages/network.js'; |
|||
import networkPage from './pages/network.js'; |
|||
// v1.5.0 P2 T3: server-setting.js (network.js rename)
|
|||
import serverSettingPage from './pages/server-setting.js'; |
|||
import { renderIoPage } from './pages/io.js'; |
|||
import ioPage from './pages/io.js'; |
|||
import { renderLogPage } from './pages/log.js'; |
|||
import logPage from './pages/log.js'; |
|||
import { renderRegisterPage } from './pages/register.js'; |
|||
import registerPage from './pages/register.js'; |
|||
// v1.5.0 P3 T1: general-settings.js NEW (Equipment + Protocol + Odo, A/D ports 제거)
|
|||
import generalSettingsPage from './pages/general-settings.js'; |
|||
// v1.5.0 P3 T2: sensor-io.js NEW (RS485 + Analog + Digital ports 통합)
|
|||
import sensorIoPage from './pages/sensor-io.js'; |
|||
// v1.5.0 P3 T3: can-bus.js NEW (CAN bus config + CAN mapping 통합)
|
|||
import canBusPage from './pages/can-bus.js'; |
|||
import { renderModbusPage } from './pages/modbus.js'; |
|||
import modbusPage from './pages/modbus.js'; |
|||
import { renderOpcuaPage } from './pages/opcua.js'; |
|||
import opcuaPage from './pages/opcua.js'; |
|||
import { renderCanPage } from './pages/can.js'; |
|||
import canPage from './pages/can.js'; |
|||
import { renderFirmwarePage } from './pages/firmware.js'; |
|||
import firmwarePage from './pages/firmware.js'; |
|||
import homePage, { setNavigate as setHomeNavigate } from './pages/home.js'; |
|||
// v1.6.0: Network Apply & Status page
|
|||
import netApplyPage from './pages/net-apply.js'; |
|||
import wifiApPage from './pages/wifi-ap.js'; // AP: page import
|
|||
// v1.5.0 P1: Lucide SVG icons + sidebar nested
|
|||
import { icon } from './icons.js'; |
|||
// v1.5.0 P1: nav-guard — navigation dirty modal
|
|||
import { confirmNavigation } from './nav-guard.js'; |
|||
// v1.7.0: Pending Changes — 전역 미저장/미적용 배지 + 패널
|
|||
import { initPending, refreshUnapplied, getUnapplied, applyNeededCta, openPanel as openPendingPanel, renderBadge as renderPendingBadge } from './pending.js'; |
|||
// v1.8.0: Advanced/Debug 보기 토글 상태 (User 기본 / Advanced 노출)
|
|||
import { isAdvanced, setAdvanced, onAdvancedChange, initFromStorage as initViewModeStorage } from './view-mode.js'; |
|||
// v1.8.0: Simple Apply — User 모드 Save=즉시 적용 (네트워크) + eth1 평이 재접속 확인
|
|||
import { runSimpleApply, resumeIfConfirming } from './apply-flow.js'; |
|||
import { confirmModal } from './confirm-modal.js'; |
|||
|
|||
const DEBUG = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; |
|||
let _deviceMac = null; |
|||
|
|||
const PAGE_RENDERERS = { |
|||
ssid: renderSsidPage, |
|||
network: renderNetworkPage, |
|||
io: renderIoPage, |
|||
log: renderLogPage, |
|||
register: renderRegisterPage, |
|||
modbus: renderModbusPage, |
|||
opcua: renderOpcuaPage, |
|||
can: renderCanPage, |
|||
firmware: renderFirmwarePage, |
|||
}; |
|||
|
|||
const PAGES = { |
|||
home: homePage, |
|||
wifi: wifiPage, // v1.5.0 P2: canonical Wi-Fi page id
|
|||
ssid: wifiPage, // v1.5.0 P2 alias — legacy id redirects to wifi
|
|||
'wifi-ap': wifiApPage, // AP: page
|
|||
ethernet: ethernetPage, // v1.5.0 P2 T2: canonical Ethernet page id
|
|||
io: ioPage, // v1.5.0 P2 T2: legacy + RS485/CAN 잔존
|
|||
'server-setting': serverSettingPage, // v1.5.0 P2 T3: canonical Server Setting page id
|
|||
network: serverSettingPage, // v1.5.0 P2 T3 alias — legacy id redirects to server-setting
|
|||
log: logPage, |
|||
general: generalSettingsPage, // v1.5.0 P3 T1: canonical General Settings page id
|
|||
register: generalSettingsPage, // v1.5.0 P3 T1 alias — legacy id redirects to general
|
|||
'sensor-io': sensorIoPage, // v1.5.0 P3 T2: canonical Sensor I/O page id (no legacy alias — genuinely new)
|
|||
'can-bus': canBusPage, // v1.5.0 P3 T3: canonical CAN-BUS page id
|
|||
can: canBusPage, // v1.5.0 P3 T3 alias — legacy id redirects to can-bus
|
|||
modbus: modbusPage, |
|||
opcua: opcuaPage, |
|||
firmware: firmwarePage, |
|||
'net-apply': netApplyPage, // v1.6.0: Network Apply & Status
|
|||
}; |
|||
let activePage = null; |
|||
let detachDirtyTracking = null; |
|||
|
|||
// v1.8.0 Task 4: 네트워크 도메인 페이지 id 집합. Save 시 이 중 하나라도 dirty 였다면
|
|||
// "네트워크 변경" 으로 보고 User 모드에서 자동 apply 를 트리거한다 (netmodel.NETWORK_DEV_KEYS
|
|||
// 의 프론트 대응).
|
|||
// v1.8.1 (review I-1): 'opcua'/'modbus' 추가 — 이 페이지들이 opc_ua_server_ip/port,
|
|||
// modbus_server_ip/port (모두 netmodel.NETWORK_DEV_KEYS) 를 편집한다. 누락 시 OPC-UA/Modbus
|
|||
// 엔드포인트 변경이 User 모드 Save 에서 자동 적용되지 않고(드리프트 폴백은 503/파일부재 시 fail-open)
|
|||
// "저장됨" 토스트만 뜬 채 재부팅 전까지 OS 에 반영 안 되던 결함.
|
|||
const NETWORK_PAGES = new Set(['wifi', 'ssid', 'ethernet', 'server-setting', 'network', 'opcua', 'modbus']); |
|||
|
|||
// v1.8.2 (review prod-safety): 연결을 끊을 수 있는 "파괴적" 네트워크 페이지. eth1(관리 IP)·Wi-Fi
|
|||
// 변경은 인터페이스 재설정으로 현 세션이 끊길 수 있어 User 모드에서도 적용 전 평이한 확인을 둔다.
|
|||
// server-setting/opcua/modbus 는 엔드포인트(목적지)만 바꿔 인터페이스 재설정이 없으므로 비파괴적 —
|
|||
// 확인 없이 즉시 적용(단순 흐름 유지).
|
|||
const DISRUPTIVE_NETWORK_PAGES = new Set(['wifi', 'ssid', 'ethernet', 'network']); |
|||
|
|||
// Config pages owned by the imported device/protocol payload. An Import overwrites
|
|||
// these in-memory; flag them dirty so the nav-guard and beforeunload fire (otherwise
|
|||
// an imported-but-unsaved config is silently lost on navigate/reload). Canonical ids only
|
|||
// (no home/firmware/net-apply — those don't hold importable config).
|
|||
const IMPORTABLE_CONFIG_PAGES = [ |
|||
'wifi', 'ethernet', 'server-setting', 'general', |
|||
'sensor-io', 'can-bus', 'opcua', 'modbus', 'log', |
|||
]; |
|||
|
|||
// ─────────────────────────────────────────────────────────────
|
|||
// v1.5.0 P1: Sidebar nested infrastructure
|
|||
// (sidebar group toggle + localStorage + keyboard nav + icon 주입)
|
|||
// ─────────────────────────────────────────────────────────────
|
|||
|
|||
const SIDEBAR_LS_KEY = 'wc.sidebar.groups'; |
|||
|
|||
/** |
|||
* v1.5.0 P1: Sidebar icon 주입 — 모든 icon-* 슬롯에 inline SVG 채움. |
|||
*/ |
|||
function injectSidebarIcons() { |
|||
const map = { |
|||
// v1.11.15: System group icon — real Lucide 'monitor' glyph (added to icons.js).
|
|||
'icon-grp-system': { name: 'monitor', size: 16 }, |
|||
'icon-chev-system': { name: 'chevron-down', size: 14 }, |
|||
'icon-grp-firmware': { name: 'package', size: 16 }, |
|||
'icon-chev-firmware': { name: 'chevron-down', size: 14 }, |
|||
'icon-grp-network': { name: 'globe', size: 16 }, |
|||
'icon-grp-interface': { name: 'plug-zap', size: 16 }, |
|||
'icon-chev-network': { name: 'chevron-down', size: 14 }, |
|||
'icon-chev-interface': { name: 'chevron-down', size: 14 }, |
|||
'icon-nav-home': { name: 'home', size: 18 }, |
|||
'icon-nav-wifi': { name: 'wifi', size: 16 }, // v1.5.0 P2
|
|||
'icon-nav-ethernet': { name: 'ethernet-port', size: 16 }, // v1.5.0 P2 T2
|
|||
'icon-nav-server-setting': { name: 'server', size: 16 }, // v1.5.0 P2 T3
|
|||
'icon-nav-general': { name: 'sliders-horizontal', size: 16 }, // v1.5.0 P3 T6
|
|||
'icon-nav-sensor-io': { name: 'cable', size: 16 }, // v1.5.0 P3 T6
|
|||
'icon-nav-can-bus': { name: 'route', size: 16 }, // v1.5.0 P3 T6
|
|||
// icon-nav-register / icon-nav-can removed — nav entries migrated to general/can-bus (P3 T6)
|
|||
'icon-nav-opcua': { name: 'link', size: 16 }, |
|||
'icon-nav-modbus': { name: 'share-2', size: 16 }, |
|||
'icon-nav-log': { name: 'file-text', size: 18 }, |
|||
'icon-nav-firmware': { name: 'package', size: 18 }, |
|||
'icon-nav-net-apply': { name: 'plug-zap', size: 16 }, // v1.6.0
|
|||
'icon-nav-wifi-ap': { name: 'wifi', size: 16 }, // AP: nav icon
|
|||
}; |
|||
Object.entries(map).forEach(([id, spec]) => { |
|||
const el = document.getElementById(id); |
|||
if (el) el.innerHTML = icon(spec.name, { size: spec.size }); |
|||
}); |
|||
} |
|||
|
|||
function loadSidebarState() { |
|||
try { |
|||
const raw = localStorage.getItem(SIDEBAR_LS_KEY); |
|||
return raw ? JSON.parse(raw) : { system: true, network: true, 'interface-protocol': true, firmware: true }; |
|||
} catch (e) { |
|||
return { system: true, network: true, 'interface-protocol': true, firmware: true }; |
|||
} |
|||
} |
|||
|
|||
function saveSidebarState(state) { |
|||
try { |
|||
localStorage.setItem(SIDEBAR_LS_KEY, JSON.stringify(state)); |
|||
} catch (e) { /* ignore quota */ } |
|||
} |
|||
|
|||
function applySidebarState(state) { |
|||
document.querySelectorAll('.nav-group__toggle').forEach(btn => { |
|||
const groupEl = btn.closest('.nav-group'); |
|||
if (!groupEl) return; |
|||
const groupKey = groupEl.dataset.group; |
|||
const expanded = state[groupKey] !== false; // default expanded
|
|||
btn.setAttribute('aria-expanded', String(expanded)); |
|||
}); |
|||
} |
|||
|
|||
function initSidebarGroups() { |
|||
const state = loadSidebarState(); |
|||
applySidebarState(state); |
|||
|
|||
document.querySelectorAll('.nav-group__toggle').forEach(btn => { |
|||
btn.addEventListener('click', e => { |
|||
e.preventDefault(); |
|||
const expanded = btn.getAttribute('aria-expanded') === 'true'; |
|||
const newState = !expanded; |
|||
btn.setAttribute('aria-expanded', String(newState)); |
|||
const groupEl = btn.closest('.nav-group'); |
|||
if (groupEl) { |
|||
state[groupEl.dataset.group] = newState; |
|||
saveSidebarState(state); |
|||
} |
|||
}); |
|||
|
|||
btn.addEventListener('keydown', e => { |
|||
if (e.key === 'Enter' || e.key === ' ') { |
|||
e.preventDefault(); |
|||
btn.click(); |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
function initSidebarKeyboardNav() { |
|||
const sidebar = document.getElementById('sidebar-nav'); |
|||
if (!sidebar) return; |
|||
|
|||
sidebar.addEventListener('keydown', e => { |
|||
const target = e.target; |
|||
if (!target.classList || !target.classList.contains('nav-item')) return; |
|||
|
|||
const allLeaves = Array.from(sidebar.querySelectorAll( |
|||
'.nav-item:not(.nav-item--disabled):not([style*="display:none"])' |
|||
)); |
|||
const idx = allLeaves.indexOf(target); |
|||
if (idx < 0) return; |
|||
|
|||
if (e.key === 'ArrowDown') { |
|||
e.preventDefault(); |
|||
const next = allLeaves[(idx + 1) % allLeaves.length]; |
|||
if (next) next.focus(); |
|||
} else if (e.key === 'ArrowUp') { |
|||
e.preventDefault(); |
|||
const prev = allLeaves[(idx - 1 + allLeaves.length) % allLeaves.length]; |
|||
if (prev) prev.focus(); |
|||
} else if (e.key === 'Escape') { |
|||
e.preventDefault(); |
|||
const groupEl = target.closest('.nav-group'); |
|||
if (!groupEl) return; |
|||
const btn = groupEl.querySelector('.nav-group__toggle'); |
|||
if (btn) { |
|||
btn.setAttribute('aria-expanded', 'false'); |
|||
const st = loadSidebarState(); |
|||
st[groupEl.dataset.group] = false; |
|||
saveSidebarState(st); |
|||
btn.focus(); |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// ─── Initialization ─────────────────────────────────────────
|
|||
document.addEventListener('DOMContentLoaded', async () => { |
|||
initTheme(); |
|||
initVersion(); |
|||
initViewMode(); |
|||
initNavigation(); |
|||
// v1.7.1 Task 2: dashboard issue deep-links navigate via showPage (Dashboard is
|
|||
// read-only, so nav-guard is unnecessary here).
|
|||
setHomeNavigate((pageId) => showPage(pageId)); |
|||
initSidebar(); |
|||
injectSidebarIcons(); |
|||
initSidebarGroups(); |
|||
initSidebarKeyboardNav(); |
|||
initActions(); |
|||
initUnsavedWarning(); |
|||
await loadAllData(); |
|||
updateTabVisibility(); |
|||
showPage('home'); |
|||
// v1.7.0: Pending Changes 초기화 — 배지 wiring + 미적용 캐시·폴링.
|
|||
// baseline 은 loadAllData 의 setDevice/setProtocol 에서 이미 잡혔다.
|
|||
initPending({ |
|||
rerender: () => showPage(state.currentPage), |
|||
navigate: (p) => showPage(p), |
|||
saveAll: () => handleSaveAll({ skipConfirm: true }), |
|||
}); |
|||
// ★ C1 (v1.8.1): 진행 중 apply 를 새 로드에서 이어받는다. eth1 관리 IP 변경은 현 연결을 끊어
|
|||
// 재접속(=새 페이지 로드)을 강제하는데, 그 새 로드엔 runSimpleApply 호출 지점이 없어
|
|||
// CONFIRM_WAIT 배너가 사라지고 확정 못 하면 TTL 만료 → 자동 롤백 → 운영자(새 IP) 단절.
|
|||
// 모드 무관(배너는 #apply-flow-host = page-container 밖, 평이 언어). fire-and-forget.
|
|||
resumeIfConfirming(); |
|||
}); |
|||
|
|||
// ─── Data Loading ───────────────────────────────────────────
|
|||
async function loadAllData() { |
|||
try { |
|||
if (DEBUG) console.log('[App] Loading data from API...'); |
|||
const [device, protocol, mac] = await Promise.all([ |
|||
getDevice(), |
|||
getProtocol(), |
|||
getMac(), |
|||
]); |
|||
if (DEBUG) console.log('[App] Raw device response:', device); |
|||
if (DEBUG) console.log('[App] Raw protocol response:', protocol); |
|||
if (DEBUG) console.log('[App] Device MAC:', mac); |
|||
_deviceMac = mac; |
|||
setDevice(device); |
|||
setProtocol(protocol); |
|||
state.isDirty = false; |
|||
if (DEBUG) console.log('[App] Data loaded. state.device:', state.device); |
|||
if (DEBUG) console.log('[App] Data loaded. state.protocol:', state.protocol); |
|||
} catch (e) { |
|||
console.error('[App] Failed to load config:', e); |
|||
showToast('Failed to load configuration. Check server connection.', 'error'); |
|||
// Use defaults
|
|||
setDevice(null); |
|||
setProtocol(null); |
|||
} finally { |
|||
document.getElementById('loading').style.display = 'none'; |
|||
document.getElementById('page-container').style.display = 'block'; |
|||
} |
|||
} |
|||
|
|||
// ─── Page Navigation ────────────────────────────────────────
|
|||
function showPage(pageId) { |
|||
// v1.8.0 Task 3: Apply & Status 는 Advanced 전용. User 모드 진입 시 Dashboard 로 리다이렉트
|
|||
// (직접 deeplink·이전 모드에서 머문 경우 모두 차단). nav 숨김(.advanced-only)과 이중 가드.
|
|||
if (pageId === 'net-apply' && !isAdvanced()) { |
|||
pageId = 'home'; |
|||
} |
|||
const prevPage = state.currentPage; // v1.7.0: net-apply 이탈/진입 drift 재계산용
|
|||
// Collect current page data before destroying DOM (prevents data loss on tab switch).
|
|||
// v1.5.5.7: 현재 page 가 dirty 일 때만 collect — Discard 직후 state 복원이
|
|||
// 이 collector 에 의해 DOM 의 stale 값으로 덮어쓰여 무효화되던 결함 차단.
|
|||
// Save / dirty 아닌 nav / initial load 모두 정상 (collector 자체가 noop 또는
|
|||
// handleSaveAll 안의 collectAllPagesData 가 별도로 호출).
|
|||
if ( |
|||
state.pageDirty[state.currentPage] && |
|||
window.__pageCollectors?.[state.currentPage] |
|||
) { |
|||
window.__pageCollectors[state.currentPage](); |
|||
} |
|||
|
|||
// 신규 인터페이스: destroy 호출
|
|||
if (activePage?.destroy) { |
|||
activePage.destroy(); |
|||
} |
|||
if (detachDirtyTracking) { |
|||
detachDirtyTracking(); |
|||
detachDirtyTracking = null; |
|||
} |
|||
|
|||
state.currentPage = pageId; |
|||
|
|||
// Update nav active state
|
|||
document.querySelectorAll('.nav-item').forEach(el => { |
|||
el.classList.toggle('active', el.dataset.page === pageId); |
|||
}); |
|||
|
|||
// Render page
|
|||
const container = document.getElementById('page-container'); |
|||
container.innerHTML = ''; |
|||
container.style.animation = 'none'; |
|||
void container.offsetHeight; |
|||
container.style.animation = ''; |
|||
|
|||
const page = PAGES[pageId]; |
|||
if (page && page.render && page.mount) { |
|||
// 신규 인터페이스
|
|||
page.render(container); |
|||
page.mount(container); |
|||
activePage = page; |
|||
} else { |
|||
// 레거시: 함수 직접 호출
|
|||
const renderer = PAGE_RENDERERS[pageId]; |
|||
if (renderer) renderer(container); |
|||
activePage = null; |
|||
} |
|||
|
|||
detachDirtyTracking = attachDirtyTracking(container, pageId); |
|||
|
|||
// v1.7.0: net-apply 진입/이탈 시 미적용(drift) 재계산 — 그 페이지에서 Apply/confirm/rollback 하면
|
|||
// drift 가 변하므로 전역 배지를 최신화한다. (apply_engine 이벤트 훅 없이 페이지 전환으로 흡수.)
|
|||
if (pageId === 'net-apply' || prevPage === 'net-apply') { |
|||
try { refreshUnapplied(); } catch (_) {} |
|||
} |
|||
|
|||
// Close mobile sidebar
|
|||
document.getElementById('sidebar').classList.remove('open'); |
|||
document.getElementById('sidebar-overlay').classList.remove('show'); |
|||
} |
|||
|
|||
function initNavigation() { |
|||
document.querySelectorAll('.nav-item').forEach(el => { |
|||
el.addEventListener('click', async (e) => { |
|||
e.preventDefault(); |
|||
// Block disabled leaves (e.g. Firmware placeholder)
|
|||
if (el.classList.contains('disabled') || el.classList.contains('nav-item--disabled')) return; |
|||
const pageId = el.dataset.page; |
|||
const currentPage = state.currentPage; |
|||
// v1.5.0 P1: nav-guard — confirm navigation if current page is dirty
|
|||
if (currentPage && currentPage !== pageId) { |
|||
const ok = await confirmNavigation(currentPage); |
|||
if (!ok) return; |
|||
} |
|||
showPage(pageId); |
|||
}); |
|||
}); |
|||
document.getElementById('logo-home')?.addEventListener('click', async (e) => { |
|||
e.preventDefault(); |
|||
const currentPage = state.currentPage; |
|||
if (currentPage && currentPage !== 'home') { |
|||
const ok = await confirmNavigation(currentPage); |
|||
if (!ok) return; |
|||
} |
|||
showPage('home'); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Update sidebar tab visibility based on protocol settings. |
|||
* Called when Register page changes protocol or can_input. |
|||
*/ |
|||
export function updateTabVisibility() { |
|||
const protocol = state.protocol?.protocol || 'NONE'; |
|||
const canInput = state.protocol?.can_input || 'off'; |
|||
|
|||
const modbusTab = document.getElementById('nav-modbus'); |
|||
const opcuaTab = document.getElementById('nav-opcua'); |
|||
const canTab = document.getElementById('nav-can-bus'); |
|||
|
|||
if (modbusTab) modbusTab.classList.toggle('disabled', protocol !== 'MODBUS'); |
|||
if (opcuaTab) opcuaTab.classList.toggle('disabled', protocol !== 'OPC_UA'); |
|||
if (canTab) canTab.classList.toggle('disabled', canInput !== 'on'); |
|||
} |
|||
|
|||
// ─── Mobile Sidebar ─────────────────────────────────────────
|
|||
function initSidebar() { |
|||
const btn = document.getElementById('btn-menu'); |
|||
const sidebar = document.getElementById('sidebar'); |
|||
const overlay = document.getElementById('sidebar-overlay'); |
|||
|
|||
if (btn) { |
|||
btn.addEventListener('click', () => { |
|||
sidebar.classList.toggle('open'); |
|||
overlay.classList.toggle('show'); |
|||
}); |
|||
} |
|||
if (overlay) { |
|||
overlay.addEventListener('click', () => { |
|||
sidebar.classList.remove('open'); |
|||
overlay.classList.remove('show'); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// ─── Unsaved Changes Warning ────────────────────────────────
|
|||
function initUnsavedWarning() { |
|||
window.addEventListener('beforeunload', (e) => { |
|||
if (state.isDirty) { |
|||
e.preventDefault(); |
|||
e.returnValue = ''; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// ─── Actions: Save, Import, Export ──────────────────────────
|
|||
function initActions() { |
|||
// Save All
|
|||
const saveBtn = document.getElementById('btn-save-all'); |
|||
const saveMobile = document.getElementById('btn-save-mobile'); |
|||
if (saveBtn) saveBtn.addEventListener('click', handleSaveAll); |
|||
if (saveMobile) saveMobile.addEventListener('click', handleSaveAll); |
|||
|
|||
// Import
|
|||
const importBtn = document.getElementById('btn-import'); |
|||
const fileInput = document.getElementById('file-import'); |
|||
if (importBtn) importBtn.addEventListener('click', () => fileInput?.click()); |
|||
if (fileInput) fileInput.addEventListener('change', handleImport); |
|||
|
|||
// Export
|
|||
const exportBtn = document.getElementById('btn-export'); |
|||
if (exportBtn) exportBtn.addEventListener('click', handleExport); |
|||
} |
|||
|
|||
/** |
|||
* Validate ALL pages' state before saving. |
|||
* Iterates PAGES map calling each page's validate(). |
|||
* Returns array of error objects: { field, page, message } |
|||
*/ |
|||
function validateBeforeSave() { |
|||
const allErrors = []; |
|||
|
|||
clearAllFieldErrors(); |
|||
clearAllPageErrors(); |
|||
|
|||
const seen = new Set(); |
|||
for (const page of Object.values(PAGES)) { |
|||
if (page && typeof page.validate === 'function' && !seen.has(page)) { |
|||
seen.add(page); |
|||
allErrors.push(...page.validate()); |
|||
} |
|||
} |
|||
|
|||
if (allErrors.length > 0) { |
|||
// Mark sidebar error indicators
|
|||
const errorPages = new Set(allErrors.map(e => e.page).filter(Boolean)); |
|||
errorPages.forEach(pageId => setPageError(pageId, true)); |
|||
|
|||
// Navigate to first error page and focus field
|
|||
const firstError = allErrors[0]; |
|||
if (firstError.page && firstError.page !== state.currentPage) { |
|||
showPage(firstError.page); |
|||
} |
|||
// Focus after potential page switch (give DOM time to render)
|
|||
setTimeout(() => focusErrorField(firstError), 50); |
|||
} |
|||
|
|||
return allErrors; |
|||
} |
|||
|
|||
let _saving = false; // Global lock to prevent concurrent saves
|
|||
|
|||
/** |
|||
* v1.7.0 — Save All 확인 단계를 Pending Changes 패널로 위임. |
|||
* |
|||
* 기존 showSaveAllConfirm 의 단순 "페이지명 나열 + Save/Cancel" 모달을 풍부한 Pending 패널 |
|||
* (필드별 old→new diff + per-page/전체 되돌리기 + 전체 저장)로 대체한다. 실제 저장은 패널의 |
|||
* [전체 저장] 버튼 → 주입된 saveAll (= handleSaveAll({skipConfirm:true})) 이 수행한다. |
|||
* |
|||
* handleSaveAll({skipConfirm:false}) 계약 보존: 이 경로는 패널을 열고 false 를 반환(이번 호출은 |
|||
* 직접 저장하지 않음 — 사용자가 패널에서 확정). skipConfirm:true 경로는 종전대로 즉시 저장. |
|||
*/ |
|||
function showSaveAllConfirm(_dirtyPages) { |
|||
openPendingPanel(); |
|||
return Promise.resolve(false); |
|||
} |
|||
|
|||
async function saveSelfContainedPages(dirtyPagesBeforeSave) { |
|||
for (const pageId of dirtyPagesBeforeSave) { |
|||
const page = PAGES[pageId]; |
|||
if (page && typeof page.saveSelfContained === 'function') { |
|||
await page.saveSelfContained(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* v1.5.4.3 U1 fix: exported + returns boolean (true on full success, false otherwise). |
|||
* skipConfirm=true skips the dirty-pages confirm modal — used by nav-guard's Save button |
|||
* (which is itself a confirm-level dialog; double-modal is the wrong UX). |
|||
*/ |
|||
export async function handleSaveAll({ skipConfirm = false } = {}) { |
|||
// Prevent concurrent save operations
|
|||
if (_saving) return false; |
|||
|
|||
// v1.5.0 P4b T3: Show confirm modal listing dirty pages if pageDirty matrix available
|
|||
if (!skipConfirm) { |
|||
const dirtyPages = getDirtyPages(); |
|||
if (dirtyPages.length > 0) { |
|||
const proceed = await showSaveAllConfirm(dirtyPages); |
|||
if (!proceed) return false; |
|||
} |
|||
} |
|||
|
|||
_saving = true; |
|||
let saveSucceeded = false; |
|||
|
|||
const btn = document.getElementById('btn-save-all'); |
|||
const mobileBtn = document.getElementById('btn-save-mobile'); |
|||
if (btn) { btn.disabled = true; btn.innerHTML = '<span>⌛</span> Saving...'; } |
|||
if (mobileBtn) mobileBtn.disabled = true; |
|||
|
|||
try { |
|||
// Collect ALL pages' form data before saving (not just current page)
|
|||
collectAllPagesData(); |
|||
|
|||
// Validate ALL pages' state
|
|||
const errors = validateBeforeSave(); |
|||
if (errors.length > 0) { |
|||
const errorPages = new Set(errors.map(e => e.page).filter(Boolean)); |
|||
const msg = errorPages.size > 1 |
|||
? `Validation failed in ${errorPages.size} pages: ${errors[0].message}` |
|||
: `Validation failed: ${errors[0].message}`; |
|||
showToast(msg, 'warning'); |
|||
return false; |
|||
} |
|||
|
|||
const dirtyPagesBeforeSave = getDirtyPages(); |
|||
const devicePayload = buildDevicePayload(); |
|||
const protocolPayload = buildProtocolPayload(); |
|||
|
|||
// Save sequentially (not parallel) to avoid SQLite DB locking conflicts.
|
|||
// v1.4.6.9 H7: 두 save의 성공 여부를 분리해 partial-failure 시 정확한 상태 보고.
|
|||
// 이전 패턴은 saveDevice 결과만 check하고 saveProtocol throw도 deviceRes false로 보였음 —
|
|||
// 또 saveProtocol throw 시 catch 분기로 빠져서 state.isDirty가 그대로 유지 (정상)이지만,
|
|||
// saveProtocol이 200으로 success:false 응답하면 그게 silent 통과되던 path를 명시 처리.
|
|||
const deviceRes = await saveDevice(devicePayload); |
|||
const protocolRes = await saveProtocol(protocolPayload); |
|||
|
|||
const deviceOk = !!(deviceRes && deviceRes.success === true); |
|||
const protocolOk = !!(protocolRes && protocolRes.success === true); |
|||
|
|||
if (deviceOk && protocolOk) { |
|||
await saveSelfContainedPages(dirtyPagesBeforeSave); |
|||
clearAllPageErrors(); |
|||
const warnings = [ |
|||
...(Array.isArray(deviceRes.warnings) ? deviceRes.warnings : []), |
|||
...(Array.isArray(protocolRes.warnings) ? protocolRes.warnings : []), |
|||
]; |
|||
// v1.8.0 Task 4: clearAllDirty 전에 "네트워크 페이지가 dirty 였는지" 포착.
|
|||
// v1.8.2: 파괴적(eth1/Wi-Fi) 변경 여부도 함께 포착 — User 모드 적용 전 확인 게이트용.
|
|||
const dirtyNetworkPages = dirtyPagesBeforeSave.filter(p => NETWORK_PAGES.has(p)); |
|||
const networkPageWasDirty = dirtyNetworkPages.length > 0; |
|||
const disruptiveChange = dirtyNetworkPages.some(p => DISRUPTIVE_NETWORK_PAGES.has(p)); |
|||
clearAllDirty(); |
|||
// v1.5.5.6: Save 성공 → 현재 state 를 새 Discard baseline 으로 채택
|
|||
commitChangesAsBaseline(); |
|||
// v1.7.0: Save(DB) 직후 미적용(drift) 재계산 — 네트워크 키 저장은 미적용 카운트를 올린다.
|
|||
// v1.7.1 fix: 미적용>0 이면 Apply-needed CTA 만 표시 (이중 토스트 방지).
|
|||
// CTA 자체가 "Saved. Apply needed …" 를 전달하므로 generic success toast 는 억제.
|
|||
// 미적용==0 (또는 drift 조회 실패) 이면 generic toast 를 그대로 표시.
|
|||
let needApply = false; |
|||
try { |
|||
await refreshUnapplied(); |
|||
needApply = getUnapplied() > 0; |
|||
} catch (_) { /* drift 조회 실패는 Save 성공에 영향 주지 않음 */ } |
|||
|
|||
// v1.8.1 (review I-1/minor): 자동 apply 판정은 "이번 Save 가 네트워크 페이지를 건드렸는가"
|
|||
// 로 결정론적으로 한다. 이전엔 `|| needApply`(전역 drift 캐시)도 OR 했으나, 그 캐시는
|
|||
// 이번 Save 와 무관할 수 있어 (이전 미적용 잔여) 비네트워크 Save 에 apply 가 오발하거나,
|
|||
// 반대로 drift 조회 실패(503/파일부재) 시 네트워크 Save 를 놓쳤다. needApply 는 이제
|
|||
// Advanced CTA 메시징 용도로만 쓴다.
|
|||
const networkChanged = networkPageWasDirty; |
|||
|
|||
const warnHead = () => { |
|||
const head = warnings.slice(0, 3).join(' · '); |
|||
const tail = warnings.length > 3 ? ` (+${warnings.length - 3} more)` : ''; |
|||
return `Saved with warnings: ${head}${tail}`; |
|||
}; |
|||
|
|||
if (!isAdvanced() && networkChanged) { |
|||
// v1.8.0 ★ User 모드: Save=즉시 적용. 기존 apply 라우트(fields:{}) 자동 트리거 +
|
|||
// "적용 중…"→평이 결과. eth1 관리 IP 변경 시 평이 재접속 확인 배너(절대 숨기지 않음).
|
|||
if (warnings.length > 0) showToast(warnHead(), 'warning'); |
|||
// v1.8.1 (review minor): 적용 종료 후 drift 재계산 — Advanced 배지/요약의
|
|||
// stale 'Unapplied N'(이미 적용됐는데 미적용으로 표시) 방지.
|
|||
const triggerApply = () => runSimpleApply({ |
|||
onResult: () => { |
|||
refreshUnapplied() |
|||
.then(() => { try { renderPendingBadge(); } catch (_) {} }) |
|||
.catch(() => {}); |
|||
}, |
|||
}); // fire-and-forget — 자체 진행/결과 토스트 + eth1 배너
|
|||
if (disruptiveChange) { |
|||
// v1.8.2 (review prod-safety): 파괴적 변경(eth1/Wi-Fi)은 수동 flow 의 명시 Apply
|
|||
// 클릭 게이트가 사라졌으므로, 적용 전 평이 확인을 둔다. 취소 시 저장은 유지하고
|
|||
// 적용만 미룬다(재부팅 시 반영). 비파괴적 변경은 종전대로 즉시 적용.
|
|||
confirmModal({ |
|||
title: 'Apply network changes now?', |
|||
message: "Saving will reconfigure this device's network connection. " |
|||
+ "This page may briefly lose its connection while the change is applied.", |
|||
confirmLabel: 'Apply now', |
|||
cancelLabel: 'Save only', |
|||
}).then((ok) => { |
|||
if (ok) triggerApply(); |
|||
else showToast('Saved. Network changes take effect on the next reboot, ' |
|||
+ 'or Save again to apply now.', 'info'); |
|||
}); |
|||
} else { |
|||
triggerApply(); |
|||
} |
|||
} else if (isAdvanced() && needApply) { |
|||
// Advanced 모드: 기존 v1.7.1 동작 보존 — Apply-needed CTA (수동 Apply&Status 동선).
|
|||
// v1.8.1 (review I-2): 이 분기도 저장 경고를 노출(이전엔 CTA 만 떠 warnings 누락 — User 분기와 비대칭).
|
|||
if (warnings.length > 0) showToast(warnHead(), 'warning'); |
|||
applyNeededCta(); |
|||
} else if (warnings.length > 0) { |
|||
showToast(warnHead(), 'warning'); |
|||
} else { |
|||
// 비네트워크 Save (또는 변경 없음): "저장됨 ✓".
|
|||
showToast('Configuration saved successfully.', 'success'); |
|||
} |
|||
saveSucceeded = true; |
|||
} else { |
|||
// v1.4.6.9 H7: 어느 쪽이 실패했는지 명시. state.isDirty는 그대로 유지 (재시도 가능).
|
|||
const failedParts = []; |
|||
if (!deviceOk) failedParts.push('device'); |
|||
if (!protocolOk) failedParts.push('protocol'); |
|||
const msg = (deviceRes?.message || protocolRes?.message |
|||
|| `Save failed: ${failedParts.join(' + ')}`); |
|||
showToast(msg, 'warning'); |
|||
} |
|||
} catch (e) { |
|||
console.error('Save failed:', e); |
|||
showToast('Save failed — ' + e.message, 'error'); |
|||
} finally { |
|||
_saving = false; |
|||
if (btn) { btn.disabled = false; btn.innerHTML = '<span>💾</span> Save All'; } |
|||
if (mobileBtn) mobileBtn.disabled = false; |
|||
} |
|||
return saveSucceeded; |
|||
} |
|||
|
|||
/** |
|||
* Collect form data from ALL pages that have been rendered and registered. |
|||
* Each page module registers a collector via window.__pageCollectors. |
|||
* Only the currently displayed page will have DOM elements; others use state as-is. |
|||
*/ |
|||
function collectAllPagesData() { |
|||
if (!window.__pageCollectors) return; |
|||
// Collect from the currently displayed page (it has active DOM elements)
|
|||
if (window.__pageCollectors[state.currentPage]) { |
|||
window.__pageCollectors[state.currentPage](); |
|||
} |
|||
} |
|||
|
|||
// Expose for page modules to register their data collectors
|
|||
window.__pageCollectors = {}; |
|||
|
|||
function handleImport(e) { |
|||
const file = e.target.files[0]; |
|||
if (!file) return; |
|||
|
|||
const reader = new FileReader(); |
|||
reader.onload = (ev) => { |
|||
try { |
|||
const data = JSON.parse(ev.target.result); |
|||
|
|||
// Schema validation: ensure recognizable format
|
|||
const isFormatA = data.device && typeof data.device === 'object'; |
|||
const isFormatB = data.wifi || data.ssid_list || data.eth; |
|||
const isFormatC = 'wifi_ip' in data || 'eth_ip' in data || 'WIFI_SSID' in data; |
|||
if (!isFormatA && !isFormatB && !isFormatC) { |
|||
showToast('Import failed — Unrecognized configuration file format.', 'error'); |
|||
return; |
|||
} |
|||
|
|||
// Confirm before overwriting — show meta info if available
|
|||
let confirmMsg = 'Current settings will be overwritten with the imported file.'; |
|||
if (data.meta) { |
|||
const m = data.meta; |
|||
const parts = []; |
|||
if (m.equipment) parts.push(`Equipment: ${m.equipment}`); |
|||
if (m.protocol) parts.push(`Protocol: ${m.protocol}`); |
|||
if (m.mac) parts.push(`MAC: ${m.mac}`); |
|||
if (m.exported_at) parts.push(`Exported: ${m.exported_at.slice(0, 19).replace('T', ' ')}`); |
|||
if (parts.length) confirmMsg += `\n\n${parts.join('\n')}`; |
|||
} |
|||
confirmMsg += '\n\nContinue?'; |
|||
if (!confirm(confirmMsg)) { |
|||
return; |
|||
} |
|||
|
|||
// v1.4.6.8 C3: showPage가 새 DOM replace 전에 current-page collector를 호출하므로
|
|||
// import으로 막 set된 state.device/protocol을 stale DOM 값이 overwrite. import 동안
|
|||
// collector를 일시 detach → showPage(→mount()) 가 새 collector 등록 → 안전 복원.
|
|||
const pageId = state.currentPage; |
|||
const prevCollector = window.__pageCollectors ? window.__pageCollectors[pageId] : null; |
|||
if (window.__pageCollectors) { |
|||
window.__pageCollectors[pageId] = null; |
|||
} |
|||
try { |
|||
if (isFormatA) { |
|||
// Format 1: Web configurator export format { device: {...}, protocol: {...} }
|
|||
setDevice(data.device); |
|||
if (data.protocol) setProtocol(data.protocol); |
|||
} else if (isFormatB) { |
|||
// Format 2: Already nested device-only (no wrapper)
|
|||
setDevice(data); |
|||
} else { |
|||
// Format 3: Flat format from Java app.jar (wifi_ip, WIFI_SSID, etc.)
|
|||
setDevice(data); |
|||
} |
|||
clearAllPageErrors(); |
|||
showPage(state.currentPage); // Re-render — mount() 가 collector 재등록
|
|||
updateTabVisibility(); |
|||
// Imported config is unsaved — flag dirty so nav-guard/beforeunload fire
|
|||
// (otherwise the imported-but-unsaved config is silently lost).
|
|||
state.isDirty = true; |
|||
IMPORTABLE_CONFIG_PAGES.forEach(p => markDirty(p)); |
|||
showToast('Configuration imported successfully.', 'success'); |
|||
} catch (importErr) { |
|||
// 안전 복원 (mount() 가 호출 안 된 path)
|
|||
if (window.__pageCollectors && prevCollector) { |
|||
window.__pageCollectors[pageId] = prevCollector; |
|||
} |
|||
throw importErr; |
|||
} |
|||
} catch (err) { |
|||
showToast('Import failed — Invalid JSON file format.', 'error'); |
|||
} |
|||
}; |
|||
reader.readAsText(file); |
|||
e.target.value = ''; // Reset
|
|||
} |
|||
|
|||
function handleExport() { |
|||
collectAllPagesData(); |
|||
|
|||
const devicePayload = buildDevicePayload(); |
|||
const protocolPayload = buildProtocolPayload(); |
|||
|
|||
// Equipment & protocol come from the Register page (state.protocol)
|
|||
// Keep raw values for meta (informational), sanitize separately for filename
|
|||
const equipmentRaw = state.protocol?.equipment || 'STS'; |
|||
const protocolRaw = state.protocol?.protocol || 'NONE'; |
|||
|
|||
// Sanitize for filename safety: keep [A-Za-z0-9_-], cap at 32 chars
|
|||
const sanitizeForFile = (s, fallback) => { |
|||
const cleaned = String(s).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32); |
|||
return cleaned || fallback; |
|||
}; |
|||
const equipmentForFile = sanitizeForFile(equipmentRaw, 'STS'); |
|||
const protocolForFile = sanitizeForFile(protocolRaw, 'NONE'); |
|||
|
|||
// MAC suffix: last 4 hex chars (uppercase, no colons), padded to 4
|
|||
const macSuffix = _deviceMac |
|||
? _deviceMac.replace(/[^0-9a-fA-F]/g, '').slice(-4).toUpperCase().padStart(4, '0') |
|||
: 'XXXX'; |
|||
|
|||
// Single timestamp shared by dateStr (filename) and exported_at (meta)
|
|||
const now = new Date(); |
|||
const dateStr = now.toISOString().slice(0, 10); |
|||
|
|||
const data = { |
|||
meta: { |
|||
mac: _deviceMac, |
|||
equipment: equipmentRaw, |
|||
protocol: protocolRaw, |
|||
exported_at: now.toISOString(), |
|||
}, |
|||
device: devicePayload, |
|||
protocol: protocolPayload, |
|||
}; |
|||
|
|||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); |
|||
const url = URL.createObjectURL(blob); |
|||
const a = document.createElement('a'); |
|||
a.href = url; |
|||
a.download = `${equipmentForFile}_${protocolForFile}_${macSuffix}_${dateStr}.json`; |
|||
a.click(); |
|||
URL.revokeObjectURL(url); |
|||
showToast('Configuration exported successfully.', 'success'); |
|||
} |
|||
|
|||
// ─── Theme ──────────────────────────────────────────────────
|
|||
function initTheme() { |
|||
const saved = localStorage.getItem('theme') || 'dark'; |
|||
document.documentElement.setAttribute('data-theme', saved); |
|||
updateThemeIcon(saved); |
|||
|
|||
document.getElementById('btn-theme')?.addEventListener('click', () => { |
|||
const current = document.documentElement.getAttribute('data-theme'); |
|||
const next = current === 'dark' ? 'light' : 'dark'; |
|||
document.documentElement.setAttribute('data-theme', next); |
|||
localStorage.setItem('theme', next); |
|||
updateThemeIcon(next); |
|||
}); |
|||
} |
|||
|
|||
function updateThemeIcon(theme) { |
|||
const btn = document.getElementById('btn-theme'); |
|||
if (btn) btn.textContent = theme === 'dark' ? '🌙' : '☀️'; |
|||
} |
|||
|
|||
// ─── App Version ────────────────────────────────────────────
|
|||
function initVersion() { |
|||
const nameEl = document.getElementById('app-name'); |
|||
if (nameEl) nameEl.textContent = APP_NAME; |
|||
const verEl = document.getElementById('app-version'); |
|||
if (verEl) verEl.textContent = APP_VERSION; |
|||
} |
|||
|
|||
// ─── v1.8.0: Advanced/Debug view ────────────────────────────
|
|||
/** |
|||
* Advanced/Debug 보기 토글 초기화. |
|||
* - localStorage 에서 hydrate → body.advanced 클래스 반영(.advanced-only/.user-only CSS 구동). |
|||
* - 사이드바 footer 토글 wire (change → setAdvanced). |
|||
* - 변경 시: body 클래스 갱신 + 현재 페이지 재렌더(Dashboard watchdog 게이팅) + |
|||
* pending 배지 재렌더(미적용 카운트 모드 분기). User 모드에서 net-apply 에 머물러 있으면 home 으로 리다이렉트. |
|||
*/ |
|||
function initViewMode() { |
|||
initViewModeStorage(); |
|||
reflectAdvancedClass(); |
|||
|
|||
const toggle = document.getElementById('advanced-view-toggle'); |
|||
if (toggle) { |
|||
toggle.checked = isAdvanced(); |
|||
toggle.addEventListener('change', () => setAdvanced(toggle.checked)); |
|||
} |
|||
|
|||
onAdvancedChange(() => { |
|||
reflectAdvancedClass(); |
|||
const tog = document.getElementById('advanced-view-toggle'); |
|||
if (tog) tog.checked = isAdvanced(); |
|||
// User 모드 진입 시 Advanced 전용 페이지(net-apply)에 머물러 있으면 Dashboard 로 이동.
|
|||
if (!isAdvanced() && state.currentPage === 'net-apply') { |
|||
showPage('home'); |
|||
} else { |
|||
// 현재 페이지 재렌더 — Dashboard watchdog 카드 등 모드 종속 표면 갱신.
|
|||
showPage(state.currentPage); |
|||
} |
|||
// 전역 배지 재렌더 — User=미저장만 / Advanced=미저장+미적용.
|
|||
try { renderPendingBadge(); } catch (_) {} |
|||
}); |
|||
} |
|||
|
|||
/** body.advanced 클래스를 현재 모드에 맞춰 토글 (CSS .advanced-only/.user-only 구동). */ |
|||
function reflectAdvancedClass() { |
|||
document.body.classList.toggle('advanced', isAdvanced()); |
|||
} |
|||
@ -0,0 +1,307 @@ |
|||
/** |
|||
* apply-flow.js — v1.8.0 Task 4 ★ Simple Apply (User 모드, 네트워크) |
|||
* |
|||
* Spec §1.2 / §4: User 모드에서 Save 성공 + 네트워크 변경이면 **자동으로 apply 를 트리거**해 |
|||
* "적용 중… → 적용됨 ✓" 한 동작으로 만든다. 상태머신 step·§용어·watchdog·journal 은 노출하지 않는다. |
|||
* |
|||
* - 기존 라우트 그대로 사용: POST /api/network/apply {dry_run:false, fields:{}} (drift-apply). |
|||
* - 경량 status 폴링 (net-apply.js epoch 패턴 미러) → terminal 시 평이한 결과 1줄. |
|||
* - terminal 매핑(엔진 status → User 카피): |
|||
* COMMITTED → "Network settings applied." (success) |
|||
* NOOP → "Saved — nothing to apply." (success) |
|||
* ROLLED_BACK → "Could not apply — reverted to the previous settings." (warning) |
|||
* FAILED_VALIDATION → "Could not apply — settings rejected. Reverted." (warning) |
|||
* FAILED_CRITICAL → "Apply failed — device needs attention. (Advanced view for details)" (error) |
|||
* |
|||
* ★ 안전 불변식: eth1(웹/SSH 접속 경로) 관리 IP 변경은 스스로 끊길 수 있어 confirm-or-rollback 이 |
|||
* 안전상 필수 → User 모드에서도 **반드시 노출**(평이한 언어). 절대 숨기지 않는다(숨기면 lockout). |
|||
* 재접속 후 확정 = 기존 delegated `data-net-confirm` 계약 재사용. |
|||
* |
|||
* 순환 import 회피: view-mode 를 import 하지 않는다(이 모듈은 호출 주체가 User 모드 판정 후 부른다). |
|||
*/ |
|||
|
|||
import { showToast } from './toast.js'; |
|||
import { escapeHtml } from './utils.js'; |
|||
|
|||
const SIMPLE_POLL_MS = 1000; // 경량 폴링 주기
|
|||
const TERMINAL = ['COMMITTED', 'ROLLED_BACK', 'FAILED_CRITICAL', 'FAILED_VALIDATION', 'NOOP']; |
|||
|
|||
/** |
|||
* 폴링용 setTimeout 래퍼. Node(테스트/헤드리스)에선 unref 해 진행 중 poll 타이머가 |
|||
* 런타임 종료를 막지 않게 한다(브라우저 setTimeout 은 number 반환 → unref 없음 → no-op). |
|||
* 운영(브라우저) 동작에는 영향 없음. |
|||
*/ |
|||
function _schedule(fn) { |
|||
const t = setTimeout(fn, SIMPLE_POLL_MS); |
|||
if (t && typeof t === 'object' && typeof t.unref === 'function') t.unref(); |
|||
return t; |
|||
} |
|||
|
|||
// epoch 가드 — 중복 호출/재방문 시 stale tick 자가 폐기 (net-apply.js I-2 패턴).
|
|||
let _epoch = 0; |
|||
let _timer = null; |
|||
let _currentApplyId = null; |
|||
let _hostBound = null; // eth1 confirm 위임 리스너가 바인딩된 host
|
|||
let _inFlight = false; // I-2: 진행 중 재진입 가드 — 동시 두 번째 apply POST 차단
|
|||
|
|||
/** Test-only / 정리: 진행 중 폴링 중단 + 상태 리셋. */ |
|||
export function _resetApplyFlow() { |
|||
_epoch++; |
|||
if (_timer) { clearTimeout(_timer); _timer = null; } |
|||
_currentApplyId = null; |
|||
_inFlight = false; |
|||
// I-4: 진행/eth1 배너 DOM 도 정리 — 중단 후 stale "Keep this address" 버튼 잔존(잘못된 확정) 방지.
|
|||
_clearHost(_hostBound || (typeof document !== 'undefined' |
|||
? document.getElementById('apply-flow-host') : null)); |
|||
if (_hostBound) { |
|||
_hostBound.removeEventListener('click', _onConfirmClick); |
|||
_hostBound = null; |
|||
} |
|||
} |
|||
|
|||
/** 현재 User-mode 적용 흐름이 진행 중인지 (net-apply.js 가 이중 폴링 회피 판정에 사용). */ |
|||
export function isInFlight() { |
|||
return _inFlight === true; |
|||
} |
|||
|
|||
// 비-terminal(진행) 상태 — resume 판정용.
|
|||
const IN_FLIGHT_STATES = ['VALIDATING', 'SNAPSHOT', 'WRITING', 'APPLYING', 'VERIFYING', 'CONFIRM_WAIT']; |
|||
|
|||
/** |
|||
* ★ C1 (v1.8.1): 재접속/새 로드 후 진행 중 apply 를 이어받아 eth1 confirm 배너를 다시 띄운다. |
|||
* eth1 관리 IP 변경은 본질적으로 현 연결을 끊어 **새 페이지 로드**를 강제한다 — 그 새 로드엔 |
|||
* runSimpleApply 호출 지점이 없어(=Save 시점에만) CONFIRM_WAIT 배너가 사라지고, 확정 못 하면 |
|||
* TTL 만료 → 자동 롤백 → 운영자(새 IP 접속 중) 단절. 이를 막기 위해 app.js init / 모드전환 / |
|||
* net-apply 이탈 시 본 함수를 호출 — 빈 id 로 현재 apply 상태를 조회해 진행 중이면 폴링 재개. |
|||
* net-apply.js(Advanced 페이지)가 활성일 땐 그쪽이 소유하므로, 그 페이지가 정리(destroy)될 때 |
|||
* 다시 본 함수로 핸드백한다. |
|||
* @param {Object} [opts] |
|||
* @param {HTMLElement} [opts.host] |
|||
* @returns {Promise<string|null>} 재개한 state (없으면 null) |
|||
*/ |
|||
export async function resumeIfConfirming(opts = {}) { |
|||
if (_inFlight) return null; // 이미 본 모듈이 흐름을 소유 중
|
|||
const host = opts.host || (typeof document !== 'undefined' |
|||
? document.getElementById('apply-flow-host') : null); |
|||
let s; |
|||
try { |
|||
// 빈 id → 엔진은 현재/마지막 apply 를 반환(apply_engine.status(None) — UNKNOWN 가드 우회).
|
|||
s = await _api('api/network/apply/status?id='); |
|||
} catch (e) { |
|||
return null; // apply 없음 / 엔드포인트 불가 — 재개할 것 없음
|
|||
} |
|||
if (!s || !IN_FLIGHT_STATES.includes(s.state)) return null; |
|||
_inFlight = true; |
|||
_currentApplyId = s.apply_id || null; |
|||
if (host && _hostBound !== host) { |
|||
if (_hostBound) _hostBound.removeEventListener('click', _onConfirmClick); |
|||
host.addEventListener('click', _onConfirmClick); |
|||
_hostBound = host; |
|||
} |
|||
if (s.state === 'CONFIRM_WAIT') _renderEth1Banner(host, s.confirm_remaining_s); |
|||
else _renderApplying(host); |
|||
_poll(host, null); // terminal 까지 폴링 이어감 (fire-and-forget)
|
|||
return s.state; |
|||
} |
|||
|
|||
async function _api(path, opts) { |
|||
const res = await fetch(path, opts); |
|||
const body = await res.json().catch(() => ({})); |
|||
if (!res.ok) throw new Error(body.error || `${path}: ${res.status}`); |
|||
return body; |
|||
} |
|||
|
|||
/** |
|||
* 엔진 terminal status → User 평이 결과 { kind, text }. |
|||
* kind: 'success' | 'warning' | 'error' (toast type 와 동일). |
|||
* @param {string} state 엔진 terminal state |
|||
* @param {Object} [status] 전체 status payload (country_pending 등 부가 신호 — 선택) |
|||
*/ |
|||
export function mapTerminal(state, status) { |
|||
switch (state) { |
|||
case 'COMMITTED': |
|||
// v1.8.1: country-code 변경은 deferred(재부팅 후 적용). 엔진은 COMMITTED+country_pending
|
|||
// 으로 알린다 — "적용됨" 으로 오인시키지 말 것(운영자가 재부팅 필요를 모름).
|
|||
if (status && status.country_pending) { |
|||
return { kind: 'success', text: 'Saved. The Wi-Fi region change takes effect after the next reboot.' }; |
|||
} |
|||
return { kind: 'success', text: 'Network settings applied.' }; |
|||
case 'NOOP': |
|||
return { kind: 'success', text: 'Saved — nothing to apply.' }; |
|||
case 'ROLLED_BACK': |
|||
return { kind: 'warning', text: 'Could not apply — reverted to the previous settings.' }; |
|||
case 'FAILED_VALIDATION': |
|||
return { kind: 'warning', text: 'Could not apply — the settings were rejected. Reverted to the previous settings.' }; |
|||
case 'FAILED_CRITICAL': |
|||
return { kind: 'error', text: 'Apply failed — the device needs attention. Turn on Advanced / Debug view for details.' }; |
|||
default: |
|||
// M-6: 알 수 없는/비terminal 엔진 상태를 사용자 카피에 그대로 노출하지 않는다(평이 언어 정책).
|
|||
return { kind: 'warning', text: 'Apply finished with an unexpected result. Turn on Advanced / Debug view for details.' }; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* eth1 관리 IP 변경 평이 재접속 배너 (CONFIRM_WAIT). ★ 절대 숨기지 않음. |
|||
* delegated `data-net-confirm` 계약 재사용 — 재접속 후 [Keep this address] 로 확정. |
|||
* @param {HTMLElement} host |
|||
* @param {number|string} remaining 남은 초 |
|||
*/ |
|||
function _renderEth1Banner(host, remaining) { |
|||
if (!host) return; |
|||
// M-9: 배너가 이미 떠 있으면 카운트다운 숫자만 textContent 로 교체(전체 재빌드 회피 —
|
|||
// 버튼/포커스 보존). textContent 경로는 XSS 안전이라 escape 불필요.
|
|||
const existing = host.querySelector('[data-eth1-reconnect]'); |
|||
if (existing) { |
|||
const countEl = existing.querySelector('[data-eth1-count]'); |
|||
if (countEl) countEl.textContent = String(remaining ?? '?'); |
|||
return; |
|||
} |
|||
const secs = escapeHtml(String(remaining ?? '?')); |
|||
const applyId = escapeHtml(_currentApplyId || ''); |
|||
host.innerHTML = ` |
|||
<div class="info-banner info-banner--warning apply-flow__eth1" data-eth1-reconnect role="alert"> |
|||
<strong>Connection address changed.</strong> |
|||
If you changed this device's IP, this page will lose its connection. |
|||
Reconnect at <span class="text-mono">http://<new IP>:9090</span> within
|
|||
<span data-eth1-count>${secs}</span>s and keep this address — otherwise it reverts automatically. |
|||
<button class="btn btn--primary btn--sm" data-net-confirm="${applyId}">Keep this address</button> |
|||
</div>`; |
|||
} |
|||
|
|||
/** 진행 중 표시 (단순 스피너 1줄 — step/§용어 없음). */ |
|||
function _renderApplying(host) { |
|||
if (!host) return; |
|||
host.innerHTML = ` |
|||
<div class="info-banner apply-flow__progress" role="status" aria-live="polite"> |
|||
Applying network settings… |
|||
</div>`; |
|||
} |
|||
|
|||
/** host 비우기 (terminal·정리 시). */ |
|||
function _clearHost(host) { |
|||
if (host) host.innerHTML = ''; |
|||
} |
|||
|
|||
// eth1 [Keep this address] 위임 핸들러 — 기존 confirm 라우트 호출. 재렌더에도 살아남음.
|
|||
function _onConfirmClick(event) { |
|||
const btn = event.target.closest && event.target.closest('[data-net-confirm]'); |
|||
if (!btn) return; |
|||
const applyId = btn.getAttribute('data-net-confirm') || _currentApplyId; |
|||
_doConfirm(applyId); |
|||
} |
|||
|
|||
async function _doConfirm(applyId) { |
|||
try { |
|||
await _api('api/network/apply/confirm', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ apply_id: applyId || _currentApplyId }), |
|||
}); |
|||
// v1.11.10 (review #28): the confirm IS the terminal user action — invalidate the
|
|||
// still-running poll BEFORE clearing the host. Without this, the next status tick
|
|||
// reads the now-COMMITTED apply and fires a 2nd (duplicate) success toast on top of
|
|||
// this confirm toast. Bumping _epoch makes that tick fail its `epoch !== _epoch`
|
|||
// guard and resolve null silently; we then terminate the flow ourselves so a later
|
|||
// Save can apply again (the running tick no longer reaches the in-flight reset).
|
|||
_epoch++; |
|||
if (_timer) { clearTimeout(_timer); _timer = null; } |
|||
_inFlight = false; |
|||
// M-7: 확정 성공 시 배너 즉시 제거 — 다음 폴링 tick(최대 1s) 을 기다리지 않는다.
|
|||
_clearHost(_hostBound); |
|||
showToast('Address kept — network settings applied.', 'success'); |
|||
} catch (e) { |
|||
showToast('Could not keep the address — ' + e.message, 'error'); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* User 모드 단순 적용 실행. |
|||
* @param {Object} [opts] |
|||
* @param {HTMLElement} [opts.host] 진행/eth1 배너를 그릴 컨테이너 (기본: #apply-flow-host) |
|||
* @param {Function} [opts.onResult] terminal 결과 콜백 ({kind,text}) — 테스트/추가 UI 용 |
|||
* @returns {Promise<{kind,text}|null>} terminal 결과 (시작 실패 시 null) |
|||
*/ |
|||
export async function runSimpleApply(opts = {}) { |
|||
const host = opts.host || document.getElementById('apply-flow-host') || null; |
|||
const onResult = typeof opts.onResult === 'function' ? opts.onResult : null; |
|||
|
|||
// I-2: 이미 적용이 진행 중이면 두 번째 apply 를 시작하지 않는다(동시 엔진 세션/혼란 방지).
|
|||
// _saving 가드(handleSaveAll)가 1차 방어이나, 동기 이중 호출까지 막기 위해
|
|||
// 가드 직후 즉시 in-flight 로 표시(첫 await 이전).
|
|||
if (_inFlight) { |
|||
showToast('Network settings are already being applied — please wait.', 'info'); |
|||
return null; |
|||
} |
|||
_inFlight = true; |
|||
|
|||
// eth1 confirm 위임 리스너를 host 에 1회 바인딩 (재방문 누적 방지).
|
|||
if (host && _hostBound !== host) { |
|||
if (_hostBound) _hostBound.removeEventListener('click', _onConfirmClick); |
|||
host.addEventListener('click', _onConfirmClick); |
|||
_hostBound = host; |
|||
} |
|||
|
|||
let started; |
|||
try { |
|||
started = await _api('api/network/apply', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ dry_run: false, fields: {} }), |
|||
}); |
|||
} catch (e) { |
|||
_inFlight = false; // 시작 실패 → 가드 해제(재시도 가능)
|
|||
// eth1-safety (v1.8.1): 409 "apply in flight" 는 이미 apply(가능성으로 eth1 CONFIRM_WAIT)가
|
|||
// 대기 중이라는 뜻 — 죽은 에러 대신 그 흐름을 이어받아 confirm 배너를 띄운다.
|
|||
const resumed = await resumeIfConfirming({ host }); |
|||
if (resumed) return null; |
|||
showToast('Could not start applying network settings — ' + e.message, 'error'); |
|||
return null; |
|||
} |
|||
_currentApplyId = started.apply_id || null; |
|||
_renderApplying(host); |
|||
|
|||
return _poll(host, onResult); |
|||
} |
|||
|
|||
function _poll(host, onResult) { |
|||
if (_timer) { clearTimeout(_timer); _timer = null; } |
|||
const epoch = ++_epoch; |
|||
|
|||
return new Promise((resolve) => { |
|||
async function tick() { |
|||
let s; |
|||
try { |
|||
s = await _api(`api/network/apply/status?id=${encodeURIComponent(_currentApplyId || '')}`); |
|||
} catch (e) { |
|||
// 폴링 일시 실패 — eth1 IP 변경 중 단절 가능. epoch 유효하면 재시도.
|
|||
if (epoch === _epoch) _timer = _schedule(tick); |
|||
return; |
|||
} |
|||
if (epoch !== _epoch) { resolve(null); return; } // stale generation
|
|||
|
|||
if (s.state === 'CONFIRM_WAIT') { |
|||
_renderEth1Banner(host, s.confirm_remaining_s); |
|||
_timer = _schedule(tick); |
|||
return; |
|||
} |
|||
|
|||
if (TERMINAL.includes(s.state)) { |
|||
_epoch++; // 이후 어떤 tick 도 무효화
|
|||
_timer = null; |
|||
_inFlight = false; // I-2: 진행 종료 — 다음 Save=apply 허용
|
|||
_clearHost(host); |
|||
const result = mapTerminal(s.state, s); |
|||
showToast(result.text, result.kind); |
|||
if (onResult) onResult(result); |
|||
resolve(result); |
|||
return; |
|||
} |
|||
|
|||
// 진행 중 — 단순 표시 유지 후 재예약.
|
|||
_renderApplying(host); |
|||
if (epoch === _epoch) _timer = _schedule(tick); |
|||
} |
|||
// 첫 tick 즉시 (테스트·반응성 — net-apply 는 1s 지연이나 여기선 즉시 첫 조회).
|
|||
tick(); |
|||
}); |
|||
} |
|||
@ -0,0 +1,70 @@ |
|||
/** |
|||
* crud-table.js — Shared CRUD Table Component |
|||
* |
|||
* Reusable dynamic table with add/delete row functionality. |
|||
* Used by modbus.js, opcua.js, can.js to eliminate code duplication (C-1 fix). |
|||
*/ |
|||
|
|||
import { escapeHtml } from '../utils.js'; |
|||
|
|||
/** |
|||
* Create a CRUD table inside a container element. |
|||
* |
|||
* @param {HTMLElement} tbody - The <tbody> element to populate |
|||
* @param {Object} options |
|||
* @param {Array<Object>} options.columns - Column definitions: { key, label, width, type, options, placeholder, defaultValue } |
|||
* - type: 'input' (default), 'select' |
|||
* - options: for 'select' type, array of option values |
|||
* @param {Array<Object>} options.data - Initial row data |
|||
* @returns {{ collectData: () => Array<Object> }} - Methods to interact with the table |
|||
*/ |
|||
export function populateCrudTable(tbody, { columns, data }) { |
|||
// Populate initial rows
|
|||
data.forEach(rowData => addRow(tbody, columns, rowData)); |
|||
|
|||
return { |
|||
addEmptyRow: () => addRow(tbody, columns, {}), |
|||
collectData: () => collectTableData(tbody, columns), |
|||
}; |
|||
} |
|||
|
|||
function addRow(tbody, columns, data) { |
|||
const tr = document.createElement('tr'); |
|||
|
|||
const cells = columns.map(col => { |
|||
const fieldKey = escapeHtml(col.key); |
|||
if (col.type === 'select') { |
|||
return `<td>
|
|||
<select class="form-select form-select--sm" data-field="${fieldKey}"> |
|||
${col.options.map(v => `<option value="${escapeHtml(v)}" ${data[col.key] === v ? 'selected' : ''}>${escapeHtml(v)}</option>`).join('')} |
|||
</select> |
|||
</td>`; |
|||
} |
|||
// Default: input
|
|||
const val = data[col.key] ?? col.defaultValue ?? ''; |
|||
const style = col.width ? `style="width:${col.width};"` : ''; |
|||
const maxAttr = col.maxLength ? `maxlength="${parseInt(col.maxLength, 10) || ''}"` : ''; |
|||
const placeholder = escapeHtml(col.placeholder || ''); |
|||
return `<td><input class="form-input form-input--mono" data-field="${fieldKey}" value="${escapeHtml(val)}" placeholder="${placeholder}" ${maxAttr} ${style}></td>`; |
|||
}); |
|||
|
|||
tr.innerHTML = cells.join('') + ` |
|||
<td class="col-action"> |
|||
<button class="btn btn--ghost btn--icon row-delete" title="Delete" aria-label="Delete row">🗑</button> |
|||
</td> |
|||
`;
|
|||
|
|||
tr.querySelector('.row-delete').addEventListener('click', () => tr.remove()); |
|||
tbody.appendChild(tr); |
|||
} |
|||
|
|||
function collectTableData(tbody, columns) { |
|||
const rows = tbody.querySelectorAll('tr'); |
|||
return Array.from(rows).map(tr => { |
|||
const rowData = {}; |
|||
columns.forEach(col => { |
|||
rowData[col.key] = tr.querySelector(`[data-field="${col.key}"]`)?.value ?? ''; |
|||
}); |
|||
return rowData; |
|||
}); |
|||
} |
|||
@ -0,0 +1,101 @@ |
|||
/** |
|||
* ip-input.js — Shared IP Octet Input Component |
|||
* |
|||
* Renders 4-octet IP address fields with: |
|||
* - Numeric-only enforcement (0-255) |
|||
* - Auto-tab on 3 digits |
|||
* - Full-IP paste support |
|||
* - Consistent data-ip / data-idx attributes for validation |
|||
*/ |
|||
|
|||
import { escapeHtml } from '../utils.js'; |
|||
|
|||
/** |
|||
* Render HTML for a 4-octet IP input group. |
|||
* @param {string} id - Unique identifier (e.g. 'wifi_ip', 'eth_ip') |
|||
* @param {string} value - Current IP value (e.g. '192.168.1.1') or '' |
|||
* @param {string} [label='IP'] - Accessible label prefix for aria-label |
|||
* @returns {string} HTML string |
|||
*/ |
|||
export function renderIpOctets(id, value, label = 'IP') { |
|||
const octets = (value || '').split('.'); |
|||
return ` |
|||
<div class="ip-input-group" data-ip-group="${id}" role="group" aria-label="${label}"> |
|||
<input class="form-input ip-octet" data-ip="${id}" data-idx="0" id="${id}_0" maxlength="3" placeholder="0" value="${escapeHtml(octets[0] || '')}" aria-label="${label} octet 1"> |
|||
<span class="ip-dot">.</span> |
|||
<input class="form-input ip-octet" data-ip="${id}" data-idx="1" id="${id}_1" maxlength="3" placeholder="0" value="${escapeHtml(octets[1] || '')}" aria-label="${label} octet 2"> |
|||
<span class="ip-dot">.</span> |
|||
<input class="form-input ip-octet" data-ip="${id}" data-idx="2" id="${id}_2" maxlength="3" placeholder="0" value="${escapeHtml(octets[2] || '')}" aria-label="${label} octet 3"> |
|||
<span class="ip-dot">.</span> |
|||
<input class="form-input ip-octet" data-ip="${id}" data-idx="3" id="${id}_3" maxlength="3" placeholder="0" value="${escapeHtml(octets[3] || '')}" aria-label="${label} octet 4"> |
|||
</div>`; |
|||
} |
|||
|
|||
/** |
|||
* Attach event listeners for all .ip-octet elements within a container. |
|||
* Call this once after rendering the page HTML. |
|||
* @param {HTMLElement} container - Parent element containing ip-octet inputs |
|||
*/ |
|||
export function setupIpOctets(container) { |
|||
container.querySelectorAll('.ip-octet').forEach(el => { |
|||
el.addEventListener('input', (e) => { |
|||
let v = e.target.value.replace(/\D/g, ''); |
|||
// Strip leading zeros (e.g. '09' → '9') but keep single '0'
|
|||
if (v.length > 1 && v.startsWith('0')) v = String(parseInt(v, 10)); |
|||
e.target.value = v; |
|||
|
|||
// Visual feedback: red border when out of range (0-255)
|
|||
const num = parseInt(v, 10); |
|||
const outOfRange = v !== '' && (isNaN(num) || num > 255); |
|||
e.target.classList.toggle('form-input--error', outOfRange); |
|||
|
|||
// Auto-tab to next octet only when valid 3 digits
|
|||
if (v.length === 3 && !outOfRange) { |
|||
const idx = parseInt(e.target.dataset.idx, 10); |
|||
if (idx < 3) { |
|||
const next = container.querySelector( |
|||
`.ip-octet[data-ip="${e.target.dataset.ip}"][data-idx="${idx + 1}"]` |
|||
); |
|||
if (next) next.focus(); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
el.addEventListener('paste', (e) => { |
|||
const text = (e.clipboardData || window.clipboardData).getData('text').trim(); |
|||
const parts = text.split('.'); |
|||
if (parts.length === 4 && parts.every(p => /^\d{1,3}$/.test(p))) { |
|||
e.preventDefault(); |
|||
const ipKey = el.dataset.ip; |
|||
parts.forEach((p, i) => { |
|||
const octetEl = container.querySelector( |
|||
`.ip-octet[data-ip="${ipKey}"][data-idx="${i}"]` |
|||
); |
|||
if (octetEl) { |
|||
const num = parseInt(p, 10); |
|||
octetEl.value = String(num); |
|||
octetEl.classList.toggle('form-input--error', num > 255); |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Collect 4 octets back into a single "x.x.x.x" string. |
|||
* Returns empty string if all octets are empty. |
|||
* @param {string} id - Same identifier used in renderIpOctets |
|||
* @returns {string} |
|||
*/ |
|||
export function collectIpValue(id) { |
|||
const octets = []; |
|||
let hasValue = false; |
|||
for (let i = 0; i < 4; i++) { |
|||
const el = document.querySelector(`.ip-octet[data-ip="${id}"][data-idx="${i}"]`); |
|||
const v = el ? el.value.trim() : ''; |
|||
octets.push(v); |
|||
if (v) hasValue = true; |
|||
} |
|||
return hasValue ? octets.join('.') : ''; |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
/** |
|||
* port-input.js |
|||
* |
|||
* Shared port input behavior. 6번째 자리 입력 자체를 원천 차단 (maxlength=5) 하고, |
|||
* 범위 검증은 보조 표시 (1~65535 외 값은 form-input--error 로 visual feedback). |
|||
*/ |
|||
|
|||
import { isValidPort } from '../validator.js'; |
|||
|
|||
const MAX_PORT_DIGITS = 5; // 65535 = 5자리
|
|||
|
|||
export function setupPortInputs(container, selector = 'input[id$="_port"]') { |
|||
if (!container) return; |
|||
|
|||
container.querySelectorAll(selector).forEach(el => { |
|||
el.setAttribute('inputmode', 'numeric'); |
|||
el.setAttribute('pattern', '[0-9]*'); |
|||
el.setAttribute('maxlength', String(MAX_PORT_DIGITS)); |
|||
|
|||
const syncValidity = () => { |
|||
// non-digit 제거 + 5자 초과 paste/programmatic 입력 강제 trim
|
|||
// (maxlength 는 키 입력은 막지만 setValue/paste 일부 케이스 우회 가능)
|
|||
const digitsOnly = el.value.replace(/\D/g, '').slice(0, MAX_PORT_DIGITS); |
|||
if (el.value !== digitsOnly) el.value = digitsOnly; |
|||
|
|||
const invalid = digitsOnly !== '' && !isValidPort(digitsOnly); |
|||
el.classList.toggle('form-input--error', invalid); |
|||
if (invalid) el.setAttribute('aria-invalid', 'true'); |
|||
else el.removeAttribute('aria-invalid'); |
|||
}; |
|||
|
|||
el.addEventListener('input', syncValidity); |
|||
el.addEventListener('change', syncValidity); |
|||
syncValidity(); |
|||
}); |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
/** |
|||
* confirm-modal.js — v1.7.1 Task 4 |
|||
* |
|||
* Shared, accessible confirmation modal. Replaces scattered native confirm() |
|||
* calls with a focus-trapped, keyboard-dismissable dialog that matches the |
|||
* nav-guard / pending visual language. |
|||
* |
|||
* Usage: |
|||
* import { confirmModal } from './confirm-modal.js'; |
|||
* const ok = await confirmModal({ |
|||
* title: 'Revert network settings?', |
|||
* message: 'Roll back to the last-known-good configuration.', |
|||
* confirmLabel: 'Revert', |
|||
* cancelLabel: 'Cancel', |
|||
* danger: true, // optional — styles the confirm button as destructive
|
|||
* }); |
|||
* if (ok) { ... } |
|||
* |
|||
* Resolves true on confirm, false on cancel / Esc / backdrop click. |
|||
* Focus is trapped inside the dialog and restored to the trigger on close. |
|||
*/ |
|||
|
|||
function escapeHtml(s) { |
|||
return String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ |
|||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''' |
|||
}[ch])); |
|||
} |
|||
|
|||
/** |
|||
* @param {Object} opts |
|||
* @param {string} opts.title |
|||
* @param {string} opts.message |
|||
* @param {string} [opts.confirmLabel='Confirm'] |
|||
* @param {string} [opts.cancelLabel='Cancel'] |
|||
* @param {boolean} [opts.danger=false] |
|||
* @returns {Promise<boolean>} |
|||
*/ |
|||
export function confirmModal(opts = {}) { |
|||
const { |
|||
title = 'Confirm', |
|||
message = '', |
|||
confirmLabel = 'Confirm', |
|||
cancelLabel = 'Cancel', |
|||
danger = false, |
|||
} = opts; |
|||
|
|||
return new Promise((resolve) => { |
|||
const trigger = document.activeElement; |
|||
|
|||
const wrap = document.createElement('div'); |
|||
wrap.className = 'nav-guard__backdrop'; |
|||
wrap.setAttribute('role', 'dialog'); |
|||
wrap.setAttribute('aria-modal', 'true'); |
|||
wrap.setAttribute('aria-labelledby', 'confirm-modal-title'); |
|||
wrap.innerHTML = ` |
|||
<div class="nav-guard__modal" role="document"> |
|||
<h2 id="confirm-modal-title" class="nav-guard__title">${escapeHtml(title)}</h2> |
|||
<p class="nav-guard__desc">${escapeHtml(message)}</p> |
|||
<div class="nav-guard__actions"> |
|||
<button type="button" class="btn ${danger ? 'btn--danger' : 'btn--primary'}" data-action="confirm">${escapeHtml(confirmLabel)}</button> |
|||
<button type="button" class="btn btn--ghost" data-action="cancel">${escapeHtml(cancelLabel)}</button> |
|||
</div> |
|||
</div>`; |
|||
|
|||
const onKeydown = (e) => { |
|||
if (e.key === 'Escape') { |
|||
e.preventDefault(); |
|||
close(false); |
|||
} else if (e.key === 'Tab') { |
|||
trapTab(e, wrap); |
|||
} |
|||
}; |
|||
|
|||
function close(result) { |
|||
wrap.remove(); |
|||
document.removeEventListener('keydown', onKeydown); |
|||
if (trigger && typeof trigger.focus === 'function') { |
|||
try { trigger.focus(); } catch (_) { /* ignore */ } |
|||
} |
|||
resolve(result); |
|||
} |
|||
|
|||
wrap.querySelector('[data-action="confirm"]').addEventListener('click', () => close(true)); |
|||
wrap.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false)); |
|||
// Backdrop click (outside the modal box) cancels.
|
|||
wrap.addEventListener('click', (e) => { if (e.target === wrap) close(false); }); |
|||
|
|||
document.addEventListener('keydown', onKeydown); |
|||
document.body.appendChild(wrap); |
|||
|
|||
// Focus the confirm button so keyboard users land inside the dialog.
|
|||
const confirmBtn = wrap.querySelector('[data-action="confirm"]'); |
|||
if (confirmBtn) confirmBtn.focus(); |
|||
}); |
|||
} |
|||
|
|||
/** Tab/Shift+Tab focus trap within the modal. */ |
|||
function trapTab(e, modalRoot) { |
|||
const focusable = modalRoot.querySelectorAll( |
|||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' |
|||
); |
|||
if (focusable.length === 0) return; |
|||
const first = focusable[0]; |
|||
const last = focusable[focusable.length - 1]; |
|||
if (e.shiftKey && document.activeElement === first) { |
|||
e.preventDefault(); |
|||
last.focus(); |
|||
} else if (!e.shiftKey && document.activeElement === last) { |
|||
e.preventDefault(); |
|||
first.focus(); |
|||
} |
|||
} |
|||
@ -0,0 +1,97 @@ |
|||
/** |
|||
* constants.js — Centralized Default Values and Magic Numbers |
|||
* |
|||
* Replaces hardcoded magic strings/numbers across page modules. |
|||
*/ |
|||
|
|||
/** |
|||
* Application identity — the single source of truth for the displayed version. |
|||
* 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.11.16'; |
|||
|
|||
/** |
|||
* v1.7.0 — pageId → 표시명 (Pending Changes 패널·배지에서 사용). |
|||
* page-dirty matrix / computeChanges 페이지 귀속과 동일 id 집합. |
|||
*/ |
|||
export const PAGE_DISPLAY_NAMES = { |
|||
home: 'Dashboard', |
|||
wifi: 'Wi-Fi', |
|||
'wifi-ap': 'Wi-Fi AP', |
|||
ssid: 'Wi-Fi', |
|||
ethernet: 'Ethernet', |
|||
io: 'Sensor / IO', |
|||
'server-setting': 'Server Setting', |
|||
network: 'Server Setting', |
|||
general: 'General', |
|||
'sensor-io': 'Sensor / IO', |
|||
'can-bus': 'CAN Bus', |
|||
register: 'General', |
|||
can: 'CAN Bus', |
|||
opcua: 'OPC-UA', |
|||
modbus: 'Modbus', |
|||
log: 'Log', |
|||
firmware: 'Firmware', |
|||
'net-apply': 'Apply & Status', |
|||
}; |
|||
|
|||
/** |
|||
* v1.7.1 Task 2 — Dashboard issue type → target page id (deep-link). |
|||
* Each dashboard issue carries a `type`; home.js renders its action as a |
|||
* clickable control that navigates to the page where the operator fixes it. |
|||
* Values are canonical page ids from the PAGES map (app.js). Unmapped types |
|||
* fall back to a non-clickable note. |
|||
*/ |
|||
export const DASHBOARD_ISSUE_PAGES = { |
|||
runtime: 'home', // dpworldapp stopped / startup failed/hung — restart/inspect on Dashboard
|
|||
telemetry: 'home', // 8989 telemetry unavailable — Dashboard runtime card
|
|||
positioning: 'home', // GNSS module health — visible on Dashboard
|
|||
'gnss-drift': 'firmware', // GNSS firmware drift — firmware/maintenance
|
|||
interface: 'general', // equipment interface / protocol selection
|
|||
'interface-can': 'can-bus', |
|||
'interface-opcua': 'opcua', |
|||
'interface-modbus': 'modbus', |
|||
server: 'server-setting', // protocol server endpoint
|
|||
disk: 'log', // free space / rotate logs
|
|||
'net-drift': 'net-apply', // OS vs runtime network drift — apply & status
|
|||
'net-apply': 'net-apply', // apply watchdog critical / country pending
|
|||
// D7: Wi-Fi signal poor → network Wi-Fi configuration page
|
|||
'wifi-signal': 'wifi', |
|||
}; |
|||
|
|||
export const DEFAULTS = { |
|||
CAN_TYPE: 'extended', |
|||
CAN_BAUDRATE: 250, |
|||
RS485_MODE: 'half', |
|||
RS485_BAUDRATE: 9600, |
|||
RS485_DATABITS: '8', |
|||
RS485_STOPBITS: '1', // v1.4.6.1 B-3: io.js/state.js fallback + dha baseline 일치
|
|||
WIFI_SECURITY: 'wpa/wpa2', |
|||
WIFI_COUNTRY_CODE: 'AE', |
|||
LOG_SAVE: 'on', |
|||
LOG_MAX_SIZE: 100, |
|||
LOG_MAX_DAYS: 1, |
|||
LOG_AUTO_COMPRESS: 'off', |
|||
LOG_COMPRESS_SIZE_MB: 50, |
|||
LOG_COMPRESS_AGE_DAYS: 7, |
|||
LOG_AUTO_CLEANUP: 'off', |
|||
LOG_CLEANUP_MAX_FILES: 50, |
|||
LOG_CLEANUP_MAX_SIZE_MB: 200, |
|||
}; |
|||
|
|||
/** Home dashboard auto-refresh interval (ms). */ |
|||
export const DASHBOARD_REFRESH_MS = 10000; |
|||
|
|||
export const CAN_BAUDRATES = ['NONE', 100, 250, 500, 1000]; |
|||
export const CAN_BAUDRATE_LABELS = { |
|||
'NONE': 'NONE', |
|||
100: '100kb/s', |
|||
250: '250kb/s', |
|||
500: '500kb/s', |
|||
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.
|
|||
export const IDT_OPTIONS = ['integer', 'float', 'boolean', 'unsignedInteger', 'short', 'unsignedShort']; |
|||
export const ODT_OPTIONS = ['integer', 'float64', 'boolean', 'string']; |
|||
@ -0,0 +1,264 @@ |
|||
/** |
|||
* country-codes.js — WiFi regulatory country codes for the SSID page. |
|||
* |
|||
* COUNTRY_CODES: the 205 ISO 3166-1 alpha-2 codes recognized by this device's |
|||
* WiFi module — a Quectel module on the Qualcomm QCNFA765 chipset — extracted |
|||
* from the module's /lib/firmware/regdb.bin. The module firmware validates the |
|||
* country code against regdb.bin at association time, so only these are offered. |
|||
* |
|||
* CONTINENT_GROUPS assigns every code to one of five continents (for browsing |
|||
* only); FREQUENT_COUNTRY_CODES is the pinned "Frequently used" group. |
|||
* |
|||
* The Web Configurator only persists the chosen code to board_config; |
|||
* dpworldapp performs the actual regulatory apply. Default is AE — see |
|||
* DEFAULTS.WIFI_COUNTRY_CODE in constants.js. |
|||
*/ |
|||
|
|||
/** code → English country name. 205 entries, keyed alphabetically. */ |
|||
export const COUNTRY_CODES = { |
|||
AD: 'Andorra', |
|||
AE: 'United Arab Emirates', |
|||
AF: 'Afghanistan', |
|||
AG: 'Antigua and Barbuda', |
|||
AI: 'Anguilla', |
|||
AL: 'Albania', |
|||
AM: 'Armenia', |
|||
AR: 'Argentina', |
|||
AS: 'American Samoa', |
|||
AT: 'Austria', |
|||
AU: 'Australia', |
|||
AW: 'Aruba', |
|||
AX: 'Åland Islands', |
|||
AZ: 'Azerbaijan', |
|||
BA: 'Bosnia and Herzegovina', |
|||
BB: 'Barbados', |
|||
BD: 'Bangladesh', |
|||
BE: 'Belgium', |
|||
BF: 'Burkina Faso', |
|||
BG: 'Bulgaria', |
|||
BH: 'Bahrain', |
|||
BI: 'Burundi', |
|||
BL: 'Saint Barthélemy', |
|||
BM: 'Bermuda', |
|||
BN: 'Brunei Darussalam', |
|||
BO: 'Bolivia', |
|||
BR: 'Brazil', |
|||
BS: 'Bahamas', |
|||
BT: 'Bhutan', |
|||
BW: 'Botswana', |
|||
BY: 'Belarus', |
|||
BZ: 'Belize', |
|||
CA: 'Canada', |
|||
CD: 'Congo (Democratic Republic)', |
|||
CF: 'Central African Republic', |
|||
CG: 'Congo', |
|||
CH: 'Switzerland', |
|||
CI: "Côte d'Ivoire", |
|||
CK: 'Cook Islands', |
|||
CL: 'Chile', |
|||
CM: 'Cameroon', |
|||
CN: 'China', |
|||
CO: 'Colombia', |
|||
CR: 'Costa Rica', |
|||
CW: 'Curaçao', |
|||
CX: 'Christmas Island', |
|||
CY: 'Cyprus', |
|||
CZ: 'Czechia', |
|||
DE: 'Germany', |
|||
DK: 'Denmark', |
|||
DM: 'Dominica', |
|||
DO: 'Dominican Republic', |
|||
DZ: 'Algeria', |
|||
EC: 'Ecuador', |
|||
EE: 'Estonia', |
|||
EG: 'Egypt', |
|||
ES: 'Spain', |
|||
ET: 'Ethiopia', |
|||
FI: 'Finland', |
|||
FJ: 'Fiji', |
|||
FK: 'Falkland Islands', |
|||
FM: 'Micronesia', |
|||
FO: 'Faroe Islands', |
|||
FR: 'France', |
|||
GA: 'Gabon', |
|||
GB: 'United Kingdom', |
|||
GD: 'Grenada', |
|||
GE: 'Georgia', |
|||
GF: 'French Guiana', |
|||
GG: 'Guernsey', |
|||
GH: 'Ghana', |
|||
GI: 'Gibraltar', |
|||
GL: 'Greenland', |
|||
GP: 'Guadeloupe', |
|||
GR: 'Greece', |
|||
GT: 'Guatemala', |
|||
GU: 'Guam', |
|||
GY: 'Guyana', |
|||
HK: 'Hong Kong', |
|||
HM: 'Heard Island and McDonald Islands', |
|||
HN: 'Honduras', |
|||
HR: 'Croatia', |
|||
HT: 'Haiti', |
|||
HU: 'Hungary', |
|||
ID: 'Indonesia', |
|||
IE: 'Ireland', |
|||
IL: 'Israel', |
|||
IM: 'Isle of Man', |
|||
IN: 'India', |
|||
IQ: 'Iraq', |
|||
IS: 'Iceland', |
|||
IT: 'Italy', |
|||
JE: 'Jersey', |
|||
JM: 'Jamaica', |
|||
JO: 'Jordan', |
|||
JP: 'Japan', |
|||
KE: 'Kenya', |
|||
KH: 'Cambodia', |
|||
KN: 'Saint Kitts and Nevis', |
|||
KR: 'Korea, Republic of', |
|||
KW: 'Kuwait', |
|||
KY: 'Cayman Islands', |
|||
KZ: 'Kazakhstan', |
|||
LA: "Lao People's Democratic Republic", |
|||
LB: 'Lebanon', |
|||
LC: 'Saint Lucia', |
|||
LI: 'Liechtenstein', |
|||
LK: 'Sri Lanka', |
|||
LS: 'Lesotho', |
|||
LT: 'Lithuania', |
|||
LU: 'Luxembourg', |
|||
LV: 'Latvia', |
|||
LY: 'Libya', |
|||
MA: 'Morocco', |
|||
MC: 'Monaco', |
|||
MD: 'Moldova', |
|||
ME: 'Montenegro', |
|||
MF: 'Saint Martin (French part)', |
|||
MH: 'Marshall Islands', |
|||
MK: 'North Macedonia', |
|||
MM: 'Myanmar', |
|||
MN: 'Mongolia', |
|||
MO: 'Macao', |
|||
MP: 'Northern Mariana Islands', |
|||
MQ: 'Martinique', |
|||
MR: 'Mauritania', |
|||
MS: 'Montserrat', |
|||
MT: 'Malta', |
|||
MU: 'Mauritius', |
|||
MV: 'Maldives', |
|||
MW: 'Malawi', |
|||
MX: 'Mexico', |
|||
MY: 'Malaysia', |
|||
NA: 'Namibia', |
|||
NC: 'New Caledonia', |
|||
NF: 'Norfolk Island', |
|||
NG: 'Nigeria', |
|||
NI: 'Nicaragua', |
|||
NL: 'Netherlands', |
|||
NO: 'Norway', |
|||
NP: 'Nepal', |
|||
NU: 'Niue', |
|||
NZ: 'New Zealand', |
|||
OM: 'Oman', |
|||
PA: 'Panama', |
|||
PE: 'Peru', |
|||
PF: 'French Polynesia', |
|||
PG: 'Papua New Guinea', |
|||
PH: 'Philippines', |
|||
PK: 'Pakistan', |
|||
PL: 'Poland', |
|||
PM: 'Saint Pierre and Miquelon', |
|||
PR: 'Puerto Rico', |
|||
PT: 'Portugal', |
|||
PW: 'Palau', |
|||
PY: 'Paraguay', |
|||
QA: 'Qatar', |
|||
RE: 'Réunion', |
|||
RO: 'Romania', |
|||
RS: 'Serbia', |
|||
RU: 'Russian Federation', |
|||
RW: 'Rwanda', |
|||
SA: 'Saudi Arabia', |
|||
SE: 'Sweden', |
|||
SG: 'Singapore', |
|||
SH: 'Saint Helena', |
|||
SI: 'Slovenia', |
|||
SJ: 'Svalbard and Jan Mayen', |
|||
SK: 'Slovakia', |
|||
SM: 'San Marino', |
|||
SN: 'Senegal', |
|||
SR: 'Suriname', |
|||
ST: 'Sao Tome and Principe', |
|||
SV: 'El Salvador', |
|||
SX: 'Sint Maarten (Dutch part)', |
|||
TC: 'Turks and Caicos Islands', |
|||
TD: 'Chad', |
|||
TF: 'French Southern Territories', |
|||
TG: 'Togo', |
|||
TH: 'Thailand', |
|||
TN: 'Tunisia', |
|||
TR: 'Türkiye', |
|||
TT: 'Trinidad and Tobago', |
|||
TW: 'Taiwan', |
|||
TZ: 'Tanzania', |
|||
UA: 'Ukraine', |
|||
UG: 'Uganda', |
|||
UM: 'United States Minor Outlying Islands', |
|||
US: 'United States', |
|||
UY: 'Uruguay', |
|||
UZ: 'Uzbekistan', |
|||
VA: 'Holy See (Vatican City State)', |
|||
VC: 'Saint Vincent and the Grenadines', |
|||
VE: 'Venezuela', |
|||
VG: 'Virgin Islands (British)', |
|||
VI: 'Virgin Islands (U.S.)', |
|||
VN: 'Viet Nam', |
|||
VU: 'Vanuatu', |
|||
WF: 'Wallis and Futuna', |
|||
WS: 'Samoa', |
|||
YE: 'Yemen', |
|||
YT: 'Mayotte', |
|||
ZA: 'South Africa', |
|||
ZM: 'Zambia', |
|||
ZW: 'Zimbabwe', |
|||
}; |
|||
|
|||
/** |
|||
* Continent grouping — every COUNTRY_CODES key appears in exactly one group. |
|||
* `codes` are ordered so the dropdown renders alphabetically by country name. |
|||
*/ |
|||
export const CONTINENT_GROUPS = [ |
|||
{ region: 'Africa', codes: [ |
|||
'DZ','BW','BF','BI','CM','CF','TD','CG','CD','CI','EG','ET','TF','GA', |
|||
'GH','KE','LS','LY','MW','MR','MU','YT','MA','NA','NG','RE','RW','SH', |
|||
'ST','SN','ZA','TZ','TG','TN','UG','ZM','ZW', |
|||
] }, |
|||
{ region: 'Americas', codes: [ |
|||
'AI','AG','AR','AW','BS','BB','BZ','BM','BO','BR','CA','KY','CL','CO', |
|||
'CR','CW','DM','DO','EC','SV','FK','GF','GL','GD','GP','GT','GY','HT', |
|||
'HN','JM','MQ','MX','MS','NI','PA','PY','PE','PR','BL','KN','LC','MF', |
|||
'PM','VC','SX','SR','TT','TC','US','UY','VE','VG','VI', |
|||
] }, |
|||
{ region: 'Asia', codes: [ |
|||
'AF','AM','AZ','BH','BD','BT','BN','KH','CN','GE','HK','IN','ID','IQ', |
|||
'IL','JP','JO','KZ','KR','KW','LA','LB','MO','MY','MV','MN','MM','NP', |
|||
'OM','PK','PH','QA','SA','SG','LK','TW','TH','TR','AE','UZ','VN','YE', |
|||
] }, |
|||
{ region: 'Europe', codes: [ |
|||
'AX','AL','AD','AT','BY','BE','BA','BG','HR','CY','CZ','DK','EE','FO', |
|||
'FI','FR','DE','GI','GR','GG','VA','HU','IS','IE','IM','IT','JE','LV', |
|||
'LI','LT','LU','MT','MD','MC','ME','NL','MK','NO','PL','PT','RO','RU', |
|||
'SM','RS','SK','SI','ES','SJ','SE','CH','UA','GB', |
|||
] }, |
|||
{ region: 'Oceania', codes: [ |
|||
'AS','AU','CX','CK','FJ','PF','GU','HM','MH','FM','NC','NZ','NU','NF', |
|||
'MP','PW','PG','WS','UM','VU','WF', |
|||
] }, |
|||
]; |
|||
|
|||
/** |
|||
* Pinned "Frequently used" group — the default (AE, first) followed by |
|||
* confirmed deployment sites. Rendered above the continent groups. Every |
|||
* entry is also present in COUNTRY_CODES / CONTINENT_GROUPS. |
|||
*/ |
|||
export const FREQUENT_COUNTRY_CODES = ['AE','SA','DO','PH','IN','KR','US','DE']; |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* dirty-tracker.js |
|||
* |
|||
* Page-level dirty tracking for form controls. Page modules still own state |
|||
* updates; this module only synchronizes the sidebar dirty indicator. |
|||
*/ |
|||
|
|||
import { markDirty } from './page-dirty.js'; |
|||
|
|||
const DIRTY_SELECTOR = 'input, select, textarea'; |
|||
|
|||
export function attachDirtyTracking(container, pageId) { |
|||
if (!container || !pageId) return () => {}; |
|||
|
|||
const onUserEdit = (event) => { |
|||
const target = event.target; |
|||
if (!target || typeof target.matches !== 'function') return; |
|||
if (!target.matches(DIRTY_SELECTOR)) return; |
|||
// v1.5.5.10: transient (non-config) controls opt out of dirty tracking via
|
|||
// `data-no-dirty` on the control or any ancestor. e.g. the Log → Files 탭의
|
|||
// 파일 선택 체크박스는 다운로드용 일시 UI 상태이지 설정 변경이 아니므로
|
|||
// page-dirty / nav-guard 모달을 트리거하면 안 됨.
|
|||
if (typeof target.closest === 'function' && target.closest('[data-no-dirty]')) return; |
|||
markDirty(pageId); |
|||
}; |
|||
|
|||
// v1.5.5.11: CAPTURE phase (3rd arg `true`) — NOT bubble.
|
|||
// 이유: data-no-dirty 가드는 `target.closest()` 로 조상에 data-no-dirty 가 있는지 본다.
|
|||
// 그런데 log.js 의 파일 체크박스 change 핸들러는 renderLogFilesSection() → section.innerHTML
|
|||
// 로 목록을 즉시 re-render 하여 event.target(체크박스) 을 DOM 에서 분리(orphan)시킨다.
|
|||
// bubble-phase 였다면 onUserEdit 가 그 re-render 이후에 실행되어 orphan 된 target 의
|
|||
// closest() 가 null → 가드 무력화 → 오트리거. capture-phase 는 target 자신의 핸들러보다
|
|||
// 먼저 실행되므로 target 이 아직 attached 상태 → closest() 정상 동작. (v1.5.5.10 미해결분.)
|
|||
container.addEventListener('input', onUserEdit, true); |
|||
container.addEventListener('change', onUserEdit, true); |
|||
|
|||
return () => { |
|||
container.removeEventListener('input', onUserEdit, true); |
|||
container.removeEventListener('change', onUserEdit, true); |
|||
}; |
|||
} |
|||
@ -0,0 +1,147 @@ |
|||
/** |
|||
* firmware-logic.js — pure, DOM-free logic for the Firmware page. |
|||
* |
|||
* No `document`, `window`, or fetch here — only deterministic functions of their |
|||
* inputs, so the state machine and formatting can be unit-tested headlessly. |
|||
* firmware.js imports these; keep this file free of side effects. |
|||
* |
|||
* MERGE: copy alongside pages/firmware.js (imported as '../firmware-logic.js'). |
|||
*/ |
|||
|
|||
// ─── formatting ───────────────────────────────────────────────
|
|||
export function fmtBytes(n) { |
|||
if (!n) return '0 B'; |
|||
const u = ['B', 'KB', 'MB', 'GB']; |
|||
let i = 0, v = n; |
|||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } |
|||
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`; |
|||
} |
|||
|
|||
export function fmtDuration(sec) { |
|||
if (sec == null || sec < 0) return '—'; |
|||
const s = Math.round(sec); |
|||
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`; |
|||
} |
|||
|
|||
// average transfer rate + ETA from cumulative status (sent/total/elapsed)
|
|||
export function rate(o) { |
|||
if (!o || !o.elapsed_s || o.sent <= 0) return null; |
|||
const bps = o.sent / o.elapsed_s; |
|||
if (bps <= 0) return null; |
|||
const eta = Math.max(0, o.total - o.sent) / bps; |
|||
return { bps, eta }; |
|||
} |
|||
|
|||
export function fmtRate(o) { |
|||
const r = rate(o); |
|||
// never show "~0s left" mid-transfer — floor to 1s (the byte transfer is the
|
|||
// only thing this ETA covers; device-side commit/reboot are reported separately)
|
|||
return r ? `${fmtBytes(r.bps)}/s · ~${Math.max(1, Math.round(r.eta))}s left` : ''; |
|||
} |
|||
|
|||
// True once the byte transfer is finished and the device is doing the (indeterminate,
|
|||
// not-ETA-able) flash-write + commit. The transfer-based ETA must NOT show "0s left"
|
|||
// here — there can be up to ~2 min of device-side work + a reboot still to come.
|
|||
export function isCommitting(s) { |
|||
if (!s) return false; |
|||
const ph = (s.phases || []).find(p => p.key === 'commit'); |
|||
if (ph && ph.state === 'active') return true; |
|||
const o = s.overall; |
|||
return !!(o && o.total > 0 && o.sent >= o.total); |
|||
} |
|||
|
|||
export function buildDateOf(comps) { |
|||
const dates = (comps || []).map(c => c && c.build_date).filter(Boolean); |
|||
return dates.length ? dates[0] : null; |
|||
} |
|||
|
|||
// ─── slot helpers (single source of truth — dedups 3 prior copies) ──
|
|||
export function slotTarget(slot) { |
|||
if (!slot) return '?'; |
|||
return slot.after || (slot.before === 'A' ? 'B' : slot.before === 'B' ? 'A' : '?'); |
|||
} |
|||
|
|||
export function slotLabel(slot) { |
|||
return 'slot ' + slotTarget(slot); |
|||
} |
|||
|
|||
// ─── view state machine (client knows more than the server post-reboot) ──
|
|||
// `latches` = { sawReboot, backOnline, configChecked } from the page.
|
|||
export function computeView(state, latches) { |
|||
const l = latches || {}; |
|||
if (state === 'flashing') return 'flashing'; |
|||
if (state === 'failed') return 'failed'; |
|||
if (l.configChecked) return 'done'; |
|||
if (l.sawReboot) return l.backOnline ? 'rebooted' : 'rebooting'; |
|||
return state; // idle | staged | done
|
|||
} |
|||
|
|||
// override server phases for client-known post-flash views
|
|||
export function phaseView(view, phases) { |
|||
if (view !== 'rebooted' && view !== 'done') return phases; |
|||
return (phases || []).map(p => { |
|||
if (view === 'done') return { ...p, state: 'done', tier: 'ok' }; |
|||
if (p.key === 'config') return { ...p, state: 'active', tier: 'warn' }; |
|||
return { ...p, state: 'done', tier: 'ok' }; // rebooted: all-but-config done
|
|||
}); |
|||
} |
|||
|
|||
const VIEW_LINES = { |
|||
idle: 'Ready — upload a firmware ZIP to begin.', |
|||
staged: 'Firmware staged — run pre-flight, then flash.', |
|||
flashing: 'Flashing…', |
|||
rebooting: 'Device rebooting — reconnecting…', |
|||
rebooted: 'Firmware written — verify config below.', |
|||
done: 'Update complete.', |
|||
failed: 'Update failed — see log.', |
|||
}; |
|||
|
|||
export function viewLine(view, message) { |
|||
return VIEW_LINES[view] || message || ''; |
|||
} |
|||
|
|||
// ─── installer-style wizard (one step at a time) ──────────────
|
|||
// The internal 8 backend phases collapse into these 6 user-facing steps.
|
|||
export const WIZARD_STEPS = [ |
|||
{ key: 'upload', label: 'Upload' }, |
|||
{ key: 'preflight', label: 'Pre-flight' }, |
|||
{ key: 'flash', label: 'Flash' }, |
|||
{ key: 'reboot', label: 'Reboot' }, |
|||
{ key: 'verify', label: 'Verify' }, |
|||
{ key: 'done', label: 'Done' }, |
|||
]; |
|||
|
|||
// Which wizard step is active. Before flashing the user navigates between
|
|||
// 'upload' and 'preflight' (userStep); once flashing starts the device state
|
|||
// (view) drives it and it cannot be reversed.
|
|||
export function wizardStep(view, userStep) { |
|||
if (view === 'flashing' || view === 'failed') return 'flash'; |
|||
if (view === 'rebooting') return 'reboot'; |
|||
if (view === 'rebooted') return 'verify'; |
|||
if (view === 'done') return 'done'; |
|||
return userStep === 'preflight' ? 'preflight' : 'upload'; |
|||
} |
|||
|
|||
// Per-node state for the top progress map.
|
|||
export function wizardStepperState(current, view) { |
|||
const idx = WIZARD_STEPS.findIndex(s => s.key === current); |
|||
return WIZARD_STEPS.map((s, i) => { |
|||
let state; |
|||
if (view === 'failed' && s.key === 'flash') state = 'error'; |
|||
else if (i < idx) state = 'done'; |
|||
else if (i === idx) state = current === 'done' ? 'done' : 'active'; |
|||
else state = 'pending'; |
|||
return { key: s.key, label: s.label, state }; |
|||
}); |
|||
} |
|||
|
|||
// ─── upload-step NEXT gate ────────────────────────────────────
|
|||
// The upload step's NEXT button must be DISABLED while a firmware-ZIP upload is
|
|||
// in flight. A status poll tick can call updateNavState() mid-upload and observe
|
|||
// a stale `staged` state left by a PRIOR upload — so the in-flight `uploading`
|
|||
// flag must override the staged check, or NEXT could be clicked while bytes are
|
|||
// still streaming. NEXT is enabled only when NOT uploading AND the new upload has
|
|||
// reached `staged`.
|
|||
export function navNextDisabled(uploading, staged) { |
|||
return !!uploading || !staged; |
|||
} |
|||
@ -0,0 +1,135 @@ |
|||
/** |
|||
* src/static/js/icons.js — v1.5.0 Phase 1 |
|||
* |
|||
* Local inline Lucide SVG icons (ISC license, https://lucide.dev/license).
|
|||
* 49 icons covering the v1.5.0 IA redesign + v1.4.6.11 emoji → SVG migration. |
|||
* |
|||
* Usage: |
|||
* import { icon } from './icons.js'; |
|||
* container.innerHTML = `${icon('home', {size: 20})} Home`; |
|||
* button.innerHTML = icon('save', {size: 16, aria: 'Save'}); // role=img + <title>Save</title>
|
|||
* |
|||
* All icons share Lucide canonical wrapper: |
|||
* <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" .../> |
|||
* |
|||
* Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather |
|||
* (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. |
|||
* Permission to use, copy, modify, and/or distribute this software for any purpose with or |
|||
* without fee is hereby granted, provided that the above copyright notice and this permission |
|||
* notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL |
|||
* WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY |
|||
* AND FITNESS. ISC License — https://lucide.dev/license
|
|||
*/ |
|||
|
|||
/* eslint-disable */ |
|||
|
|||
const escapeAttr = (s) => String(s) |
|||
.replace(/&/g, '&') |
|||
.replace(/"/g, '"') |
|||
.replace(/</g, '<') |
|||
.replace(/>/g, '>'); |
|||
|
|||
const ICONS = { |
|||
// Navigation / Layout
|
|||
'home': '<path d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7h-6v7H4a1 1 0 0 1-1-1z"/>', |
|||
'menu': '<line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/>', |
|||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>', |
|||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>', |
|||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>', |
|||
|
|||
// Network / Communication
|
|||
'wifi': '<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>', |
|||
'ethernet-port': '<path d="m15 20 3-3h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h2l3 3z"/><path d="M6 8v1"/><path d="M10 8v1"/><path d="M14 8v1"/><path d="M18 8v1"/>', |
|||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>', |
|||
'radio-tower': '<path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.7a6.14 6.14 0 0 1 .8 7.5"/><path d="M19.1 1.9a14.14 14.14 0 0 1 0 14.2"/><path d="M9.7 17.9 12 22l2.3-4.1"/>', |
|||
'radio': '<path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.7a6.14 6.14 0 0 1 .8 7.5"/><path d="M19.1 1.9a14.14 14.14 0 0 1 0 14.2"/><path d="M9.7 17.9 12 22l2.3-4.1"/>', |
|||
'flag': '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>', |
|||
'server': '<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>', |
|||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', |
|||
'cable': '<path d="M4 9a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6a2 2 0 0 0 2 2h2a2 2 0 0 1 2 2v3"/><path d="M3 5h3"/><path d="M5 8V3"/><path d="M19 19h2"/><path d="M19 22v-5"/>', |
|||
'route': '<circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/>', |
|||
'smartphone': '<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>', |
|||
'plug-zap': '<path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4z"/><path d="m2 22 3-3"/><path d="M7.5 13.5 10 11"/><path d="M10.5 16.5 13 14"/><path d="m18 3-4 4h6l-4 4"/>', |
|||
|
|||
// Data
|
|||
'bar-chart-3': '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>', |
|||
'clipboard-list': '<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>', |
|||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>', |
|||
'share-2': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>', |
|||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>', |
|||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>', |
|||
'package': '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>', |
|||
'box': '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>', |
|||
'puzzle': '<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.498.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.077.877.528 1.073 1.01a2.5 2.5 0 1 0 3.259-3.259c-.482-.196-.933-.558-1.01-1.073-.05-.336.062-.676.303-.917l1.525-1.525A2.402 2.402 0 0 1 12 1.998c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z"/>', |
|||
|
|||
// Actions
|
|||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>', |
|||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>', |
|||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>', |
|||
'trash-2': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>', |
|||
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/>', |
|||
'radar': '<path d="M19.07 4.93A10 10 0 0 0 6.99 3.34"/><path d="M4 6h.01"/><path d="M2.29 9.62A10 10 0 1 0 21.31 8.35"/><path d="M16.24 7.76a6 6 0 1 0-8.49 8.49"/><path d="M9 12a3 3 0 1 0 5.83-1"/><path d="M16 12h.01"/>', |
|||
|
|||
// Status
|
|||
'check-circle-2': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>', |
|||
'x-circle': '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>', |
|||
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>', |
|||
'activity': '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>', |
|||
'hourglass': '<path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>', |
|||
|
|||
// Tool/Config
|
|||
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>', |
|||
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', |
|||
'sliders-horizontal': '<line x1="21" y1="4" x2="14" y2="4"/><line x1="10" y1="4" x2="3" y2="4"/><line x1="21" y1="12" x2="12" y2="12"/><line x1="8" y1="12" x2="3" y2="12"/><line x1="21" y1="20" x2="16" y2="20"/><line x1="12" y1="20" x2="3" y2="20"/><line x1="14" y1="2" x2="14" y2="6"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="16" y1="18" x2="16" y2="22"/>', |
|||
'gauge': '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>', |
|||
'factory': '<path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7 5V8l-7 5V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M17 18h1"/><path d="M12 18h1"/><path d="M7 18h1"/>', |
|||
'building-2': '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/>', |
|||
|
|||
// Security / Vision
|
|||
'eye': '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>', |
|||
'eye-off': '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" y1="2" x2="22" y2="22"/>', |
|||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', |
|||
|
|||
// Theme
|
|||
'moon': '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>', |
|||
'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>', |
|||
|
|||
// System
|
|||
'terminal-square': '<path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>', |
|||
'cpu': '<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="2" x2="9" y2="4"/><line x1="15" y1="2" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="22"/><line x1="15" y1="20" x2="15" y2="22"/><line x1="20" y1="9" x2="22" y2="9"/><line x1="20" y1="14" x2="22" y2="14"/><line x1="2" y1="9" x2="4" y2="9"/><line x1="2" y1="14" x2="4" y2="14"/>', |
|||
'monitor': '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>', |
|||
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>', |
|||
}; |
|||
|
|||
/** |
|||
* Render an inline SVG icon. |
|||
* |
|||
* @param {string} name ICONS key |
|||
* @param {Object} [opts] |
|||
* @param {number} [opts.size=16] |
|||
* @param {string} [opts.class=""] |
|||
* @param {string} [opts.aria] if set → role="img" + <title>; else aria-hidden="true" |
|||
* @returns {string} SVG HTML string, or '' if name unknown |
|||
*/ |
|||
export function icon(name, opts = {}) { |
|||
const body = ICONS[name]; |
|||
if (!body) { |
|||
if (typeof console !== 'undefined') console.warn('[icon] unknown:', name); |
|||
return ''; |
|||
} |
|||
const size = Number(opts.size) || 16; |
|||
// XSS-safe: strip dangerous chars from class option
|
|||
const cls = (typeof opts.class === 'string' ? opts.class : '').replace(/[<>"']/g, ''); |
|||
let ariaAttrs, titleElement; |
|||
if (opts.aria) { |
|||
ariaAttrs = `role=\"img\"`; |
|||
titleElement = `<title>${escapeAttr(opts.aria)}</title>`; |
|||
} else { |
|||
ariaAttrs = `aria-hidden=\"true\"`; |
|||
titleElement = ''; |
|||
} |
|||
return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${size}\" height=\"${size}\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"icon ${cls}\" ${ariaAttrs}>${titleElement}${body}</svg>`; |
|||
} |
|||
|
|||
/** All ICONS keys (for testing/introspection). */ |
|||
export const ICON_NAMES = Object.keys(ICONS); |
|||
@ -0,0 +1,166 @@ |
|||
/** |
|||
* src/static/js/nav-guard.js — v1.5.0 Phase 1 |
|||
* |
|||
* Navigation dirty guard — page 이동 시 unsaved changes 검사 + 모달 (Save / Discard / Cancel). |
|||
* |
|||
* Promise-based API: |
|||
* const ok = await confirmNavigation('register'); // current page id 전달
|
|||
* if (ok) showPage(targetPage); |
|||
* |
|||
* Modal 디자인: |
|||
* - role="dialog" + aria-modal="true" + aria-labelledby |
|||
* - focus trap (Tab/Shift+Tab wrap) |
|||
* - Esc → Cancel (resolve false) |
|||
* - Save 클릭 → clearDirty(currentPage) + resolve true (Phase 4에서 collector 통합) |
|||
* - Discard 클릭 → clearDirty(currentPage) + resolve true |
|||
*/ |
|||
|
|||
import { state, discardPageChanges } from './state.js'; |
|||
import { clearDirty } from './page-dirty.js'; |
|||
import { showToast } from './toast.js'; |
|||
|
|||
/** |
|||
* @param {string} currentPageId source page id (이동 전) |
|||
* @returns {Promise<boolean>} true → proceed navigation, false → stay on current page |
|||
*/ |
|||
export function confirmNavigation(currentPageId) { |
|||
// Phase 1: page-level dirty 확인. 안 dirty면 즉시 proceed.
|
|||
if (!state.pageDirty[currentPageId]) { |
|||
return Promise.resolve(true); |
|||
} |
|||
|
|||
return new Promise((resolve) => { |
|||
// v1.5.2 C4 (H13 WCAG 2.4.3): capture trigger element to restore focus on close
|
|||
const trigger = document.activeElement; |
|||
const modal = _buildModal(currentPageId, (proceed) => { |
|||
// Restore focus to the element that triggered navigation
|
|||
if (trigger && typeof trigger.focus === 'function') { |
|||
try { trigger.focus(); } catch (_) {} |
|||
} |
|||
resolve(proceed); |
|||
}); |
|||
document.body.appendChild(modal); |
|||
_trapFocus(modal); |
|||
|
|||
// Focus first button (Save)
|
|||
const saveBtn = modal.querySelector('[data-action="save"]'); |
|||
if (saveBtn) saveBtn.focus(); |
|||
}); |
|||
} |
|||
|
|||
function _buildModal(currentPageId, resolve) { |
|||
const wrap = document.createElement('div'); |
|||
wrap.className = 'nav-guard__backdrop'; |
|||
wrap.setAttribute('role', 'dialog'); |
|||
wrap.setAttribute('aria-modal', 'true'); |
|||
wrap.setAttribute('aria-labelledby', 'nav-guard-title'); |
|||
wrap.innerHTML = ` |
|||
<div class="nav-guard__modal"> |
|||
<h2 id="nav-guard-title" class="nav-guard__title">Unsaved changes</h2> |
|||
<p class="nav-guard__desc"> |
|||
You have unsaved changes on <strong>${_escape(currentPageId)}</strong>. |
|||
What would you like to do? |
|||
</p> |
|||
<div class="nav-guard__actions"> |
|||
<button type="button" class="btn btn--primary" data-action="save">Save</button> |
|||
<button type="button" class="btn btn--outline" data-action="discard">Discard</button> |
|||
<button type="button" class="btn btn--ghost" data-action="cancel">Cancel</button> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
|
|||
const close = (proceed) => { |
|||
wrap.remove(); |
|||
document.removeEventListener('keydown', escHandler); |
|||
resolve(proceed); |
|||
}; |
|||
|
|||
wrap.querySelector('[data-action="save"]').addEventListener('click', async () => { |
|||
// v1.5.4.3 U1 fix: 실제로 save를 수행 (이전엔 clearDirty만 하고 변경사항 소실).
|
|||
// app.js → nav-guard.js 정적 import 가 이미 있어 순환을 피하려 dynamic import.
|
|||
const saveBtn = wrap.querySelector('[data-action="save"]'); |
|||
const discardBtn = wrap.querySelector('[data-action="discard"]'); |
|||
const cancelBtn = wrap.querySelector('[data-action="cancel"]'); |
|||
const origLabel = saveBtn.textContent; |
|||
saveBtn.disabled = true; |
|||
if (discardBtn) discardBtn.disabled = true; |
|||
if (cancelBtn) cancelBtn.disabled = true; |
|||
saveBtn.textContent = 'Saving...'; |
|||
try { |
|||
const app = await import('./app.js'); |
|||
const ok = await app.handleSaveAll({ skipConfirm: true }); |
|||
if (ok) { |
|||
clearDirty(currentPageId); |
|||
close(true); |
|||
} else { |
|||
// Save 실패 또는 validation error — handleSaveAll이 이미 toast로 사유 표시.
|
|||
// 모달 유지 + 버튼 복원 → 사용자가 수정 후 재시도 가능.
|
|||
saveBtn.disabled = false; |
|||
if (discardBtn) discardBtn.disabled = false; |
|||
if (cancelBtn) cancelBtn.disabled = false; |
|||
saveBtn.textContent = origLabel; |
|||
} |
|||
} catch (e) { |
|||
showToast('Save failed — ' + (e && e.message ? e.message : 'unknown error'), 'error'); |
|||
saveBtn.disabled = false; |
|||
if (discardBtn) discardBtn.disabled = false; |
|||
if (cancelBtn) cancelBtn.disabled = false; |
|||
saveBtn.textContent = origLabel; |
|||
} |
|||
}); |
|||
wrap.querySelector('[data-action="discard"]').addEventListener('click', async () => { |
|||
// Page-scoped restore: discard only the current page's owned state slice.
|
|||
// Other dirty pages keep their edits and sidebar dirty dots.
|
|||
discardPageChanges(currentPageId); |
|||
if (typeof window !== 'undefined' && window.__pageCollectors) { |
|||
window.__pageCollectors[currentPageId] = null; |
|||
} |
|||
clearDirty(currentPageId); |
|||
if (currentPageId === 'general' || currentPageId === 'register') { |
|||
try { |
|||
const app = await import('./app.js'); |
|||
app.updateTabVisibility?.(); |
|||
} catch (_) {} |
|||
} |
|||
close(true); |
|||
}); |
|||
wrap.querySelector('[data-action="cancel"]').addEventListener('click', () => { |
|||
close(false); |
|||
}); |
|||
|
|||
const escHandler = (e) => { |
|||
if (e.key === 'Escape') { |
|||
e.preventDefault(); |
|||
close(false); |
|||
} |
|||
}; |
|||
document.addEventListener('keydown', escHandler); |
|||
|
|||
return wrap; |
|||
} |
|||
|
|||
/** Focus trap: Tab/Shift+Tab 가 modal 내부에서 순환. */ |
|||
function _trapFocus(modalRoot) { |
|||
modalRoot.addEventListener('keydown', (e) => { |
|||
if (e.key !== 'Tab') return; |
|||
const focusable = modalRoot.querySelectorAll( |
|||
'button, [href], input, [tabindex]:not([tabindex="-1"])' |
|||
); |
|||
if (focusable.length === 0) return; |
|||
const first = focusable[0]; |
|||
const last = focusable[focusable.length - 1]; |
|||
if (e.shiftKey && document.activeElement === first) { |
|||
e.preventDefault(); |
|||
last.focus(); |
|||
} else if (!e.shiftKey && document.activeElement === last) { |
|||
e.preventDefault(); |
|||
first.focus(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
function _escape(s) { |
|||
return String(s).replace(/[&<>"']/g, ch => ({ |
|||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''' |
|||
}[ch])); |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
/** |
|||
* src/static/js/page-dirty.js — v1.5.0 Phase 1 |
|||
* |
|||
* Per-page dirty state matrix helpers. |
|||
* state.pageDirty matrix와 sidebar UI (.nav-item.has-dirty) 동기화. |
|||
* |
|||
* v1.4.6.x state.isDirty boolean은 backward-compat로 유지하되 markDirty/clearDirty가 |
|||
* matrix 갱신 + 합산 boolean 업데이트. |
|||
*/ |
|||
|
|||
import { state } from './state.js'; |
|||
|
|||
/** |
|||
* v1.7.0 — dirty 변경 옵저버. |
|||
* pending.js 가 onDirtyChange(renderBadge) 로 구독한다. |
|||
* page-dirty 는 pending 을 import 하지 않는다 (순환 import 회피). |
|||
*/ |
|||
const _subs = []; |
|||
|
|||
/** Register a callback invoked after any markDirty/clearDirty/clearAllDirty. |
|||
* Dedup: a callback already in _subs is not pushed again (guards repeated initPending). */ |
|||
export function onDirtyChange(cb) { |
|||
if (typeof cb === 'function' && !_subs.includes(cb)) _subs.push(cb); |
|||
} |
|||
|
|||
function _notify() { |
|||
_subs.forEach(cb => { |
|||
try { cb(); } catch (e) { /* observer must never break dirty tracking */ } |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* Mark a page as having unsaved changes. |
|||
* Sidebar leaf에 dirty dot 표시 + state.isDirty = true. |
|||
* |
|||
* @param {string} pageId — state.pageDirty key (home/ssid/io/network/register/can/opcua/modbus/log/firmware) |
|||
*/ |
|||
export function markDirty(pageId) { |
|||
if (!(pageId in state.pageDirty)) { |
|||
if (typeof console !== 'undefined') console.warn('[page-dirty] unknown pageId:', pageId); |
|||
return; |
|||
} |
|||
state.pageDirty[pageId] = true; |
|||
state.isDirty = true; // v1.4.6.x compat
|
|||
_syncDot(pageId, true); |
|||
_notify(); |
|||
} |
|||
|
|||
/** |
|||
* Clear dirty state for a page (after successful save). |
|||
* |
|||
* @param {string} pageId |
|||
*/ |
|||
export function clearDirty(pageId) { |
|||
if (!(pageId in state.pageDirty)) return; |
|||
state.pageDirty[pageId] = false; |
|||
_syncDot(pageId, false); |
|||
// 합산 isDirty 갱신
|
|||
state.isDirty = Object.values(state.pageDirty).some(v => v === true); |
|||
_notify(); |
|||
} |
|||
|
|||
/** True if any page has unsaved changes. */ |
|||
export function hasDirty() { |
|||
return Object.values(state.pageDirty).some(v => v === true); |
|||
} |
|||
|
|||
/** Return array of dirty page ids. */ |
|||
export function getDirtyPages() { |
|||
return Object.entries(state.pageDirty) |
|||
.filter(([, dirty]) => dirty) |
|||
.map(([id]) => id); |
|||
} |
|||
|
|||
/** Clear all dirty flags (after Save All). */ |
|||
export function clearAllDirty() { |
|||
Object.keys(state.pageDirty).forEach(k => { state.pageDirty[k] = false; }); |
|||
state.isDirty = false; |
|||
document.querySelectorAll('.nav-item.has-dirty').forEach(el => { |
|||
el.classList.remove('has-dirty'); |
|||
}); |
|||
_notify(); |
|||
} |
|||
|
|||
/** Sync sidebar dirty dot for a single page. */ |
|||
function _syncDot(pageId, dirty) { |
|||
const navEl = document.querySelector(`.nav-item[data-page="${pageId}"]`); |
|||
if (!navEl) return; |
|||
if (dirty) navEl.classList.add('has-dirty'); |
|||
else navEl.classList.remove('has-dirty'); |
|||
} |
|||
@ -0,0 +1,134 @@ |
|||
/** |
|||
* can-bus.js — CAN Bus (config + mapping) |
|||
* |
|||
* v1.5.0 Phase 3 T3: io.js의 CAN bus 카드 (frame type + baud) + can.js의 mapping table 통합. |
|||
* 사용자 IA 명시: "CAN-BUS: Frametype + Baudrate + Mapping 한 페이지" |
|||
*/ |
|||
|
|||
import { state } from '../state.js'; |
|||
import { populateCrudTable } from '../components/crud-table.js'; |
|||
import { CAN_BAUDRATES, CAN_BAUDRATE_LABELS, ODT_OPTIONS } from '../constants.js'; |
|||
import { icon } from '../icons.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: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' }, |
|||
{ key: 'expr', label: 'Expr', placeholder: 'x*0.05' }, |
|||
{ key: 'mask', label: 'Mask', placeholder: '0xff' }, |
|||
]; |
|||
|
|||
let _crudTable = null; |
|||
|
|||
const canBusPage = { |
|||
render(container) { |
|||
const p = state.protocol || {}; |
|||
const d = state.device || {}; |
|||
const can = d.can || {}; |
|||
const rows = p.CAN || []; |
|||
|
|||
container.innerHTML = ` |
|||
<div class="page-header"> |
|||
<h1 class="page-header__title">${icon('route', { size: 28 })} CAN-BUS</h1> |
|||
<p class="page-header__desc">CAN bus configuration and frame mapping.</p> |
|||
</div> |
|||
|
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title">${icon('cable', { size: 18 })} Bus Settings</h2> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Frame Type</label> |
|||
<select class="form-select" id="can_type"> |
|||
<option value="standard" ${can.type === 'standard' ? 'selected' : ''}>Standard</option> |
|||
<option value="extended" ${can.type !== 'standard' ? 'selected' : ''}>Extended</option> |
|||
</select> |
|||
</div> |
|||
<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('')} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
${p.can_input !== 'on' ? ` |
|||
<div class="card card--info"> |
|||
<p>${icon('alert-triangle', { size: 16 })} Set CAN Input to <strong>ON</strong> on the General Settings page to enable mapping.</p> |
|||
</div> |
|||
` : ''}
|
|||
|
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title">${icon('list', { size: 18 })} CAN Mapping</h2> |
|||
<button class="btn btn--outline btn--sm" id="canbus-add-btn">+ Add Row</button> |
|||
</div> |
|||
<div class="data-table-wrapper"> |
|||
<table class="data-table" id="canbus-table"> |
|||
<thead> |
|||
<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:60px;">Shift</th> |
|||
<th style="width:150px;">Expr</th> |
|||
<th style="width:80px;">Mask</th> |
|||
<th class="col-action"></th> |
|||
</tr> |
|||
</thead> |
|||
<tbody id="canbus-tbody"></tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
|
|||
const tbody = document.getElementById('canbus-tbody'); |
|||
_crudTable = populateCrudTable(tbody, { columns: CAN_COLUMNS, data: rows }); |
|||
document.getElementById('canbus-add-btn').addEventListener('click', () => _crudTable.addEmptyRow()); |
|||
}, |
|||
|
|||
mount(container) { |
|||
// Bus settings bindings
|
|||
const updCan = (key, val) => { |
|||
if (!state.device) state.device = {}; |
|||
if (!state.device.can) state.device.can = {}; |
|||
state.device.can[key] = val; |
|||
state.isDirty = true; |
|||
}; |
|||
const canType = container.querySelector('#can_type'); |
|||
if (canType) canType.addEventListener('change', e => updCan('type', e.target.value)); |
|||
const canSpeed = container.querySelector('#can_speed'); |
|||
if (canSpeed) canSpeed.addEventListener('change', e => updCan('speed', e.target.value)); |
|||
|
|||
window.__pageCollectors['can-bus'] = collectCanBusData; |
|||
// Legacy alias: can.js collector also points here
|
|||
window.__pageCollectors['can'] = collectCanBusData; |
|||
}, |
|||
|
|||
destroy() { |
|||
_crudTable = null; |
|||
}, |
|||
|
|||
validate() { |
|||
return []; |
|||
}, |
|||
}; |
|||
|
|||
function collectCanBusData() { |
|||
if (!state.device) state.device = {}; |
|||
if (!state.protocol) state.protocol = {}; |
|||
state.device.can = { |
|||
type: document.getElementById('can_type')?.value || '', |
|||
speed: document.getElementById('can_speed')?.value || '', |
|||
}; |
|||
if (_crudTable) { |
|||
state.protocol.CAN = _crudTable.collectData(); |
|||
} |
|||
} |
|||
|
|||
export default canBusPage; |
|||
@ -0,0 +1,92 @@ |
|||
/** |
|||
* can.js — CAN Mapping Table Page |
|||
* |
|||
* Dynamic CRUD table for CAN frame mappings. |
|||
* Columns: Field, CAN ID, ODT, DV, Shift, Expr, Mask |
|||
* Note: No addr/ns/idt columns (differs from MODBUS and OPC-UA) |
|||
* |
|||
* Data source: state.protocol.CAN (array) |
|||
*/ |
|||
|
|||
import { state } from '../state.js'; |
|||
import { populateCrudTable } from '../components/crud-table.js'; |
|||
import { 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: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' }, |
|||
{ key: 'expr', label: 'Expr', placeholder: 'x*0.05' }, |
|||
{ key: 'mask', label: 'Mask', placeholder: '0xff' }, |
|||
]; |
|||
|
|||
export function renderCanPage(container) { |
|||
const p = state.protocol || {}; |
|||
const rows = p.CAN || []; |
|||
|
|||
container.innerHTML = ` |
|||
<div class="page-header"> |
|||
<h1 class="page-header__title">🚌 CAN Mapping</h1> |
|||
<p class="page-header__desc">Manage the CAN Frame mapping table.</p> |
|||
</div> |
|||
|
|||
${p.can_input !== 'on' ? ` |
|||
<div class="card card--info"> |
|||
<p>ℹ️ Set CAN Input to <strong>ON</strong> on the Register page.</p> |
|||
</div> |
|||
` : ''}
|
|||
|
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title"><span class="card__title-icon">📋</span> CAN Frame Mapping</h2> |
|||
<button class="btn btn--outline btn--sm" id="can-add-btn">+ Add Row</button> |
|||
</div> |
|||
<div class="data-table-wrapper"> |
|||
<table class="data-table" id="can-table"> |
|||
<thead> |
|||
<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:60px;">Shift</th> |
|||
<th style="width:150px;">Expr</th> |
|||
<th style="width:80px;">Mask</th> |
|||
<th class="col-action"></th> |
|||
</tr> |
|||
</thead> |
|||
<tbody id="can-tbody"></tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
|
|||
// Populate rows using shared CRUD component
|
|||
const tbody = document.getElementById('can-tbody'); |
|||
const table = populateCrudTable(tbody, { columns: CAN_COLUMNS, data: rows }); |
|||
|
|||
// Add row
|
|||
document.getElementById('can-add-btn').addEventListener('click', () => { |
|||
table.addEmptyRow(); |
|||
}); |
|||
|
|||
// Register data collector
|
|||
window.__pageCollectors.can = () => { |
|||
if (!state.protocol) state.protocol = {}; |
|||
state.protocol.CAN = table.collectData(); |
|||
}; |
|||
} |
|||
|
|||
// ─── New Page Interface ─────────────────────────────────────
|
|||
const canPage = { |
|||
render(container) { renderCanPage(container); }, |
|||
mount() { |
|||
window.__pageCollectors.can?.(); |
|||
}, |
|||
destroy() {}, |
|||
validate() { return []; }, |
|||
}; |
|||
|
|||
export default canPage; |
|||
@ -0,0 +1,158 @@ |
|||
/** |
|||
* ethernet.js — Wired interface IP configuration (eth0 + WAN uplink) |
|||
* |
|||
* v1.5.0 Phase 2: io.js의 두 wired 카드를 분리. |
|||
* v1.7.1 Task 3: the second card was mislabeled "LTE Interface" — the device has |
|||
* no cellular modem; this is a wired uplink port. The visible label/icon are |
|||
* corrected to WAN/wired. The DB keys stay `lte_*` (backend/Java contract). |
|||
* server endpoints (TIOT/Update/RTCM/uplink 등)는 server-setting.js 소유. |
|||
* |
|||
* Data model (nested — matches io.js state.device.eth / .lte convention): |
|||
* state.device.eth = { ip, netmask, gateway } |
|||
* state.device.lte = { ip, netmask, gateway, port } ← lte_* = wired WAN keys |
|||
* |
|||
* eth_ip / eth_netmask / eth_gateway / lte_ip / lte_netmask / lte_gateway are |
|||
* present as DOM element IDs (ip-input component convention) and also used as |
|||
* string literals for test detection. |
|||
*/ |
|||
|
|||
import { state, bindIpGroup } from '../state.js'; |
|||
import { escapeHtml } from '../utils.js'; |
|||
import { isValidIP } from '../validator.js'; |
|||
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js'; |
|||
import { icon } from '../icons.js'; |
|||
|
|||
// Field definitions — flat DOM ids map to nested state paths
|
|||
const ETH_FIELDS = [ |
|||
{ key: 'eth_ip', label: 'IP Address', group: 'eth', prop: 'ip' }, |
|||
{ key: 'eth_netmask', label: 'Subnet Mask', group: 'eth', prop: 'netmask' }, |
|||
{ key: 'eth_gateway', label: 'Gateway', group: 'eth', prop: 'gateway' }, |
|||
]; |
|||
|
|||
const LTE_FIELDS = [ |
|||
{ key: 'lte_ip', label: 'IP Address', group: 'lte', prop: 'ip' }, |
|||
{ key: 'lte_netmask', label: 'Subnet Mask', group: 'lte', prop: 'netmask' }, |
|||
{ key: 'lte_gateway', label: 'Gateway', group: 'lte', prop: 'gateway' }, |
|||
]; |
|||
|
|||
const ethernetPage = { |
|||
render(container) { |
|||
const d = state.device || {}; |
|||
const eth = d.eth || {}; |
|||
const lte = d.lte || {}; |
|||
|
|||
container.innerHTML = ` |
|||
<div class="page-header"> |
|||
<h1 class="page-header__title">${icon('ethernet-port', { size: 28 })} Ethernet</h1> |
|||
<p class="page-header__desc">Configure the wired management and uplink interfaces. Server endpoints are configured under Network → Server Setting.</p> |
|||
</div> |
|||
|
|||
<div class="grid-2col"> |
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title"><span class="card__title-icon">${icon('cable', { size: 18 })}</span> Management Ethernet (eth1)</h2> |
|||
</div> |
|||
<div class="card__body"> |
|||
${ETH_FIELDS.map(f => ` |
|||
<div class="form-group"> |
|||
<label class="form-label">${escapeHtml(f.label)}</label> |
|||
${renderIpOctets(f.key, eth[f.prop] || '')} |
|||
</div> |
|||
`).join('')}
|
|||
</div> |
|||
</div> |
|||
|
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title"><span class="card__title-icon">${icon('cable', { size: 18 })}</span> Uplink Ethernet (eth0)</h2> |
|||
</div> |
|||
<div class="card__body"> |
|||
<p class="form-hint">Physical eth0 uplink. Endpoint ports are configured on Server Setting.</p> |
|||
${LTE_FIELDS.map(f => ` |
|||
<div class="form-group"> |
|||
<label class="form-label">${escapeHtml(f.label)}</label> |
|||
${renderIpOctets(f.key, lte[f.prop] || '')} |
|||
</div> |
|||
`).join('')}
|
|||
</div> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
}, |
|||
|
|||
mount(container) { |
|||
setupIpOctets(container); |
|||
|
|||
// Ethernet IP bindings
|
|||
ETH_FIELDS.forEach(({ key, group, prop }) => { |
|||
bindIpGroup(container, key, |
|||
() => state.device?.[group]?.[prop] || '', |
|||
(val) => { |
|||
if (!state.device) state.device = {}; |
|||
if (!state.device[group]) state.device[group] = {}; |
|||
state.device[group][prop] = val; |
|||
state.isDirty = true; |
|||
} |
|||
); |
|||
}); |
|||
|
|||
// LTE IP bindings
|
|||
LTE_FIELDS.forEach(({ key, group, prop }) => { |
|||
bindIpGroup(container, key, |
|||
() => state.device?.[group]?.[prop] || '', |
|||
(val) => { |
|||
if (!state.device) state.device = {}; |
|||
if (!state.device[group]) state.device[group] = {}; |
|||
state.device[group][prop] = val; |
|||
state.isDirty = true; |
|||
} |
|||
); |
|||
}); |
|||
|
|||
// Register data collector
|
|||
window.__pageCollectors.ethernet = collectEthernetData; |
|||
}, |
|||
|
|||
destroy() {}, |
|||
|
|||
validate() { |
|||
const errors = []; |
|||
const d = state.device || {}; |
|||
|
|||
ETH_FIELDS.forEach(({ key, group, prop, label }) => { |
|||
const val = d[group]?.[prop]; |
|||
if (val && val !== '...' && !isValidIP(val)) { |
|||
errors.push({ field: key, page: 'ethernet', message: `${label} — Invalid IP format` }); |
|||
} |
|||
}); |
|||
|
|||
LTE_FIELDS.forEach(({ key, group, prop, label }) => { |
|||
const val = d[group]?.[prop]; |
|||
if (val && val !== '...' && !isValidIP(val)) { |
|||
errors.push({ field: key, page: 'ethernet', message: `${label} — Invalid IP format` }); |
|||
} |
|||
}); |
|||
|
|||
return errors; |
|||
}, |
|||
}; |
|||
|
|||
function collectEthernetData() { |
|||
if (!state.device) state.device = {}; |
|||
|
|||
state.device.eth = { |
|||
ip: collectIpValue('eth_ip'), |
|||
netmask: collectIpValue('eth_netmask'), |
|||
gateway: collectIpValue('eth_gateway'), |
|||
}; |
|||
|
|||
const prevLte = state.device.lte || {}; |
|||
state.device.lte = { |
|||
...prevLte, |
|||
ip: collectIpValue('lte_ip'), |
|||
netmask: collectIpValue('lte_netmask'), |
|||
gateway: collectIpValue('lte_gateway'), |
|||
}; |
|||
} |
|||
|
|||
export default ethernetPage; |
|||
@ -0,0 +1,931 @@ |
|||
/** |
|||
* firmware.js — Firmware Update page |
|||
* |
|||
* Production page module ({ render, mount, destroy }) matching the home.js |
|||
* contract. Drives a graphical update flow from a single backend status poll: |
|||
* stepper · upload · pre-flight · per-component flash progress · reboot |
|||
* watchdog · config-preservation check. |
|||
* |
|||
* MERGE: drop into src/static/js/pages/firmware.js, register in app.js PAGES, |
|||
* add a nav-item in index.html, and append firmware.css to style.css. |
|||
*/ |
|||
|
|||
import { getFirmwareStatus, preflightFirmware as preflight, uploadFirmware, startFlash, restoreCheck } from '../api.js'; |
|||
import { icon } from '../icons.js'; |
|||
import { showToast } from '../toast.js'; |
|||
import { escapeHtml } from '../utils.js'; |
|||
import { |
|||
fmtBytes, fmtDuration, fmtRate, buildDateOf, |
|||
slotTarget, slotLabel, computeView, viewLine, |
|||
wizardStep, wizardStepperState, isCommitting, navNextDisabled, |
|||
} from '../firmware-logic.js'; |
|||
|
|||
const POLL_MS = 1000; |
|||
const REBOOT_TIMEOUT_S = 180; // after this, the reboot watchdog warns it's overdue
|
|||
|
|||
// ─── module state ─────────────────────────────────────────────
|
|||
let pollTimer = null; |
|||
let visHandler = null; // visibilitychange listener (paused polling when hidden)
|
|||
let announceTimer = null; // pending aria-live announcement timeout
|
|||
let boundListeners = []; // {el, ev, fn} added in mount(), removed in destroy()
|
|||
let userStep = 'upload'; // wizard step the user navigated to (pre-flash only)
|
|||
let lastNavKey = null; // current footer-nav key (re-render only on change)
|
|||
let lastStepperSig = ''; // stepper node-states signature (re-render only on change)
|
|||
let failureAck = false; // user dismissed a flash failure → back to pre-flash steps
|
|||
let commitAnnounced = false; // one-shot SR announcement when device-side commit starts
|
|||
let last = null; // last status snapshot
|
|||
let prevState = null; // state on the previous poll (transition detection)
|
|||
let preflightOk = false; |
|||
let sawReboot = false; // latched once we observe state=rebooting
|
|||
let backOnline = false; // device answered again after a reboot
|
|||
let configChecked = false; // restore-check completed after our flash
|
|||
let flashMode = ''; // flash card body: '' | 'progress' | 'reboot' | 'rebooted' | 'done'
|
|||
let uploadLocked = false; // upload blocked while a flash is in progress
|
|||
let uploading = false; // a firmware-ZIP upload is in flight (gates the upload-step NEXT)
|
|||
let rebootStart = 0; // performance.now() when reboot observed (elapsed display)
|
|||
let rebootOverdueAnnounced = false; // one-shot reboot-timeout announcement
|
|||
let autoPreflightDone = false; // one-shot auto pre-flight after a staged page-load
|
|||
let flashStartedAt = 0; // performance.now() when this flash began (report duration)
|
|||
let lastConfigResult = null; // restore-check result, for the completion report
|
|||
let builtCompSig = ''; // signature of currently-built component rows
|
|||
let logLines = []; |
|||
// v1.11.10 (review #17 MED leak): cleanup hook for an OPEN showConfirm modal. destroy()
|
|||
// previously removed the overlay without resolving its Promise or removing the document
|
|||
// keydown listener (listener leak + dangling promise) — this lets destroy() tear it down
|
|||
// cleanly. Set while a confirm is open, cleared on close/destroy.
|
|||
let _modalCleanup = null; |
|||
|
|||
// formatting / view helpers now live in ../firmware-logic.js (pure, unit-tested)
|
|||
|
|||
function log(msg) { |
|||
logLines.push(msg); |
|||
if (logLines.length > 200) logLines = logLines.slice(-200); |
|||
const el = document.getElementById('fw-log'); |
|||
if (el) { el.textContent = logLines.join('\n'); el.scrollTop = el.scrollHeight; } |
|||
} |
|||
|
|||
// assertive screen-reader announcement for milestones (clear → set forces re-read)
|
|||
function announce(msg) { |
|||
const el = document.getElementById('fw-announce'); |
|||
if (!el) return; |
|||
if (announceTimer) clearTimeout(announceTimer); |
|||
el.textContent = ''; |
|||
// brief delay so AT registers the change even for an identical message
|
|||
announceTimer = setTimeout(() => { el.textContent = msg; announceTimer = null; }, 30); |
|||
} |
|||
|
|||
// single-timer polling control (provably no double-start)
|
|||
function startPolling() { |
|||
if (pollTimer) return; |
|||
poll(); |
|||
pollTimer = setInterval(poll, POLL_MS); |
|||
} |
|||
function stopPolling() { |
|||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } |
|||
} |
|||
|
|||
function rebootElapsedText() { |
|||
const sec = rebootStart ? Math.round((performance.now() - rebootStart) / 1000) : 0; |
|||
return sec > REBOOT_TIMEOUT_S |
|||
? `${sec}s — taking longer than expected. Check the device power & network, then reload this page.` |
|||
: `${sec}s — reconnecting…`; |
|||
} |
|||
|
|||
// ─── stepper ──────────────────────────────────────────────────
|
|||
const STEP_ICON = { done: '✓', active: '•', error: '✕', pending: '' }; |
|||
|
|||
function renderStepper(phases) { |
|||
const el = document.getElementById('fw-stepper'); |
|||
if (!el || !phases) return; |
|||
el.innerHTML = phases.map((p, i) => { |
|||
const icon = STEP_ICON[p.state] || String(i + 1); |
|||
const cls = `fw-step fw-step--${p.state}`; |
|||
const detail = p.detail ? `<span class="fw-step__detail">${escapeHtml(p.detail)}</span>` : ''; |
|||
return `<div class="${cls}">
|
|||
<span class="fw-step__dot">${icon || (i + 1)}</span> |
|||
<span class="fw-step__label">${escapeHtml(p.label)}</span> |
|||
${detail} |
|||
</div>`; |
|||
}).join(''); |
|||
} |
|||
|
|||
function latches() { |
|||
return { sawReboot, backOnline, configChecked }; |
|||
} |
|||
|
|||
// ─── components (built once, updated in place) ────────────────
|
|||
function buildComponents(comps) { |
|||
const host = document.getElementById('fw-comps'); |
|||
if (!host) return; |
|||
if (!comps.length) { host.innerHTML = ''; builtCompSig = ''; return; } |
|||
host.innerHTML = `<div class="card__title" style="margin:var(--space-md) 0 var(--space-sm);">Components</div>` + |
|||
comps.map(c => ` |
|||
<div class="fw-comp" id="fw-comp-${c.signature}"> |
|||
<span class="fw-comp__state-icon" data-role="icon" aria-hidden="true">○</span> |
|||
<span class="fw-comp__sig">${escapeHtml(c.signature)}</span> |
|||
<div class="fw-comp__name"> |
|||
<span class="fw-comp__role">${escapeHtml(c.role)} <small>${escapeHtml(c.name)}</small></span> |
|||
<span class="fw-comp__track" data-role="track" role="progressbar" |
|||
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="pending, 0%" |
|||
aria-label="${escapeHtml(c.role)} ${escapeHtml(c.signature)} transfer"><span class="fw-comp__fill" data-role="fill"></span></span> |
|||
<span class="fw-comp__hash">${escapeHtml((c.sha256 || '').slice(0, 24))}… · ${fmtBytes(c.size)}</span> |
|||
</div> |
|||
<span class="fw-comp__pct" data-role="pct">0%</span> |
|||
</div>`).join(''); |
|||
builtCompSig = comps.map(c => c.signature).join(','); |
|||
} |
|||
|
|||
const STATE_ICON = { pending: '○', active: '◐', done: '✓', error: '✕' }; |
|||
|
|||
function updateComponents(comps) { |
|||
const sig = comps.map(c => c.signature).join(','); |
|||
if (sig !== builtCompSig) buildComponents(comps); |
|||
comps.forEach(c => { |
|||
const row = document.getElementById(`fw-comp-${c.signature}`); |
|||
if (!row) return; |
|||
row.className = `fw-comp fw-comp--${c.state}`; |
|||
const fill = row.querySelector('[data-role=fill]'); |
|||
const pct = row.querySelector('[data-role=pct]'); |
|||
const icon = row.querySelector('[data-role=icon]'); |
|||
const track = row.querySelector('[data-role=track]'); |
|||
const p = c.state === 'done' ? 100 : c.pct; |
|||
if (fill) fill.style.width = p + '%'; |
|||
if (pct) pct.textContent = p + '%'; |
|||
if (icon) icon.textContent = STATE_ICON[c.state] || '○'; |
|||
if (track) { |
|||
track.setAttribute('aria-valuenow', String(p)); |
|||
track.setAttribute('aria-valuetext', `${c.state}, ${p}%`); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// ─── slot badge ───────────────────────────────────────────────
|
|||
function slotBadge(slot) { |
|||
if (!slot) return ''; |
|||
const before = escapeHtml(slot.before || '?'); |
|||
const after = escapeHtml(slotTarget(slot)); |
|||
return `<span class="fw-slot">
|
|||
<span class="fw-slot__pill">slot ${before}</span> |
|||
<span class="fw-slot__arrow" aria-hidden="true">→</span> |
|||
<span class="fw-slot__pill fw-slot__pill--next">slot ${after}</span> |
|||
</span>`; |
|||
} |
|||
|
|||
// ─── flash card body: build once per mode, then update in place ───
|
|||
// (rebuilding innerHTML every poll would restart the CSS width transitions
|
|||
// and make the bars jump — so we build a skeleton once and only touch values.)
|
|||
function updateFlashArea(s, view) { |
|||
if (!['flashing', 'failed', 'rebooting', 'rebooted', 'done'].includes(view)) return; |
|||
showFlashCard(); |
|||
const body = document.getElementById('fw-flash-body'); |
|||
if (!body) return; |
|||
if (view === 'rebooting') { ensureFlashMode('reboot', body, s); updateRebootElapsed(); return; } |
|||
if (view === 'rebooted') { ensureFlashMode('rebooted', body, s); return; } |
|||
if (view === 'done') { ensureFlashMode('done', body, s); return; } |
|||
ensureFlashMode('progress', body, s); // flashing | failed
|
|||
updateFlashProgress(s, view); |
|||
} |
|||
|
|||
function ensureFlashMode(mode, body, s) { |
|||
if (flashMode === mode) return; |
|||
flashMode = mode; |
|||
if (mode === 'progress') { |
|||
body.innerHTML = ` |
|||
<div class="fw-danger">${icon('alert-triangle', {size:16})} Do not power off the device — firmware is being written. This can take up to ~2 minutes.</div> |
|||
${s.backup ? `<div class="info-banner">${icon('save', {size:14})} Config backed up before flash — <span class="text-mono">${escapeHtml(s.backup.when || s.backup.path)}</span></div>` : ''} |
|||
<div class="fw-overall"> |
|||
<div class="fw-overall__head"> |
|||
<span class="fw-overall__pct" id="fw-overall-pct">0%</span> |
|||
<span class="fw-overall__meta" id="fw-overall-meta"></span> |
|||
</div> |
|||
<div class="fw-bar"><div class="fw-bar__fill" id="fw-overall-fill" style="width:0%"></div></div> |
|||
</div> |
|||
<div id="fw-comps"></div>`; |
|||
builtCompSig = ''; |
|||
} else if (mode === 'reboot') { |
|||
body.innerHTML = ` |
|||
<div class="fw-danger">${icon('alert-triangle', {size:16})} Do not power off — the device is rebooting onto the new firmware.</div> |
|||
<div class="fw-reboot"> |
|||
<div class="loading__spinner"></div> |
|||
<div class="fw-reboot__title">Device is rebooting…</div> |
|||
<div class="fw-reboot__sub">The firmware was written and the device is restarting onto the new slot. The connection drops briefly — this page reconnects automatically.</div> |
|||
<div class="fw-reboot__elapsed" id="fw-reboot-elapsed"></div> |
|||
${slotBadge(s.slot)} |
|||
</div>`; |
|||
} else if (mode === 'rebooted') { |
|||
body.innerHTML = ` |
|||
<div class="fw-done fw-done--reboot"> |
|||
<span class="fw-done__icon">${icon('refresh-cw', {size:32})}</span> |
|||
<div> |
|||
<div class="fw-done__title">Firmware written — device rebooted</div> |
|||
<div class="fw-done__sub">Now on ${escapeHtml(slotLabel(s.slot))}. Run the config check below to confirm settings survived.</div> |
|||
</div> |
|||
</div>`; |
|||
} else if (mode === 'done') { |
|||
body.innerHTML = buildReportHtml(s); |
|||
body.querySelector('#fw-report-btn')?.addEventListener('click', downloadReport); |
|||
} |
|||
} |
|||
|
|||
// ─── completion report (#4) ───────────────────────────────────
|
|||
function reportData(s) { |
|||
const comps = (s && s.components) || []; |
|||
const slot = (s && s.slot) || {}; |
|||
const dur = flashStartedAt ? (performance.now() - flashStartedAt) / 1000 : null; |
|||
return { comps, before: slot.before || '?', after: slotTarget(slot), dur, cfg: lastConfigResult }; |
|||
} |
|||
|
|||
function buildReportHtml(s) { |
|||
const d = reportData(s); |
|||
const cfgTxt = d.cfg ? (d.cfg.restored ? 'restored from backup' : 'preserved') : 'not checked'; |
|||
const rows = d.comps.map(c => |
|||
`<div class="fw-report__row"><span>${escapeHtml(c.signature)} · ${escapeHtml(c.name)}</span><span>${fmtBytes(c.size)}</span></div>`).join(''); |
|||
return ` |
|||
<div class="fw-done fw-done--ok"> |
|||
<span class="fw-done__icon">${icon('check-circle-2', {size:32})}</span> |
|||
<div> |
|||
<div class="fw-done__title">Update complete</div> |
|||
<div class="fw-done__sub">Firmware written to slot ${escapeHtml(d.after)} · configuration ${escapeHtml(cfgTxt)}.</div> |
|||
</div> |
|||
</div> |
|||
<div class="fw-report mt-md"> |
|||
<div class="fw-report__row"><span>Duration</span><span>${fmtDuration(d.dur)}</span></div> |
|||
<div class="fw-report__row"><span>Slot</span><span>${escapeHtml(d.before)} → ${escapeHtml(d.after)}</span></div> |
|||
<div class="fw-report__row"><span>Build date</span><span>${escapeHtml(buildDateOf(d.comps) || 'unknown')}</span></div> |
|||
<div class="fw-report__row"><span>Components</span><span>${d.comps.length} (${d.comps.map(c => escapeHtml(c.signature)).join(', ')})</span></div> |
|||
${rows} |
|||
<div class="fw-report__row"><span>Config</span><span>${escapeHtml(cfgTxt)}</span></div> |
|||
</div> |
|||
<div class="fw-actions mt-md"><button class="btn btn--outline btn--sm" id="fw-report-btn">⬇ Download report</button></div>`; |
|||
} |
|||
|
|||
function downloadReport() { |
|||
const d = reportData(last); |
|||
const cfgTxt = d.cfg |
|||
? (d.cfg.restored ? 'restored from backup (' + (d.cfg.reason || '') + ')' : 'preserved') |
|||
: 'not checked'; |
|||
const ts = new Date().toISOString(); |
|||
const lines = [ |
|||
'DPW Firmware Update Report', |
|||
'==========================', |
|||
'Timestamp: ' + ts, |
|||
'Result: SUCCESS', |
|||
'Slot: ' + d.before + ' -> ' + d.after, |
|||
'Build date: ' + (buildDateOf(d.comps) || 'unknown'), |
|||
'Duration: ' + fmtDuration(d.dur), |
|||
'Config: ' + cfgTxt, |
|||
'', |
|||
'Components flashed:', |
|||
...d.comps.map(c => ' ' + String(c.signature).padEnd(4) + ' ' + c.name + |
|||
' ' + fmtBytes(c.size) + ' sha256=' + (c.sha256 || '')), |
|||
'', |
|||
]; |
|||
const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); |
|||
const url = URL.createObjectURL(blob); |
|||
const a = document.createElement('a'); |
|||
a.href = url; |
|||
a.download = 'fw-update-report_' + ts.slice(0, 19).replace(/[:T]/g, '-') + '.txt'; |
|||
document.body.appendChild(a); |
|||
a.click(); |
|||
document.body.removeChild(a); |
|||
URL.revokeObjectURL(url); |
|||
} |
|||
|
|||
function updateFlashProgress(s, view) { |
|||
const o = s.overall || { sent: 0, total: 0, pct: 0, elapsed_s: 0 }; |
|||
const fill = document.getElementById('fw-overall-fill'); |
|||
const pct = document.getElementById('fw-overall-pct'); |
|||
const meta = document.getElementById('fw-overall-meta'); |
|||
const committing = view === 'flashing' && isCommitting(s); |
|||
if (fill) { |
|||
fill.style.width = o.pct + '%'; |
|||
fill.className = 'fw-bar__fill' |
|||
+ (view === 'failed' ? ' fw-bar__fill--error' : '') |
|||
+ (committing ? ' fw-bar__fill--busy' : ''); // pulse: device still working
|
|||
} |
|||
if (pct) pct.textContent = committing ? '⏳' : o.pct + '%'; |
|||
if (meta) { |
|||
if (committing) { |
|||
meta.textContent = `writing to flash & committing — do not power off (up to ~2 min) · ${slotBadgeInline(s.slot)}`; |
|||
} else { |
|||
const r = fmtRate(o); |
|||
meta.textContent = `${fmtBytes(o.sent)} / ${fmtBytes(o.total)} · ${o.elapsed_s}s${r ? ' · ' + r : ''} · ${slotBadgeInline(s.slot)}`; |
|||
} |
|||
} |
|||
if (s.components && s.components.length) updateComponents(s.components); |
|||
} |
|||
|
|||
function updateRebootElapsed() { |
|||
const el = document.getElementById('fw-reboot-elapsed'); |
|||
if (!el) return; |
|||
const sec = rebootStart ? Math.round((performance.now() - rebootStart) / 1000) : 0; |
|||
el.textContent = rebootElapsedText(); |
|||
el.classList.toggle('fw-reboot__elapsed--overdue', sec > REBOOT_TIMEOUT_S); |
|||
} |
|||
|
|||
function slotBadgeInline(slot) { |
|||
if (!slot) return ''; |
|||
return `slot ${escapeHtml(slot.before || '?')}→${escapeHtml(slotTarget(slot))}`; |
|||
} |
|||
|
|||
// ─── pre-flight ───────────────────────────────────────────────
|
|||
function renderPreflight(pf) { |
|||
const host = document.getElementById('fw-checks'); |
|||
if (!host) return; |
|||
if (!pf) { host.innerHTML = '<p class="text-muted">Run pre-flight to validate the device before flashing.</p>'; return; } |
|||
host.innerHTML = pf.checks.map(c => ` |
|||
<div class="fw-check"> |
|||
<span class="dash-glyph dash-glyph--${escapeHtml(c.tier)}"></span> |
|||
<span class="fw-check__label">${escapeHtml(c.label)}</span> |
|||
<span class="fw-check__detail">${escapeHtml(c.detail)}</span> |
|||
${c.critical ? '' : '<span class="fw-check__opt">optional</span>'} |
|||
</div>`).join(''); |
|||
} |
|||
|
|||
// ─── poll ─────────────────────────────────────────────────────
|
|||
async function poll() { |
|||
let s; |
|||
try { |
|||
s = await getFirmwareStatus(); |
|||
} catch (err) { |
|||
// connection dropped — expected during the reboot window
|
|||
if (sawReboot && !backOnline) { |
|||
const sec = rebootStart ? Math.round((performance.now() - rebootStart) / 1000) : 0; |
|||
const el = document.getElementById('fw-reboot-elapsed'); |
|||
if (el) { |
|||
el.textContent = rebootElapsedText(); |
|||
el.classList.toggle('fw-reboot__elapsed--overdue', sec > REBOOT_TIMEOUT_S); |
|||
} |
|||
if (sec > REBOOT_TIMEOUT_S && !rebootOverdueAnnounced) { |
|||
rebootOverdueAnnounced = true; |
|||
announce('Device has not come back online. Check power and network, then reload this page.'); |
|||
log('reboot watchdog: device overdue (>' + REBOOT_TIMEOUT_S + 's)'); |
|||
} |
|||
} else { |
|||
const stamp = document.getElementById('fw-updated'); |
|||
if (stamp) stamp.textContent = 'Status unavailable — ' + err.message; |
|||
} |
|||
return; |
|||
} |
|||
|
|||
// device answered again after a reboot → advance to the Verify step
|
|||
if (sawReboot && !backOnline) { |
|||
backOnline = true; |
|||
showToast('Device is back online — verify the config.', 'success'); |
|||
announce('Device is back online. Verify the config.'); |
|||
log('device reconnected after reboot'); |
|||
window.scrollTo({ top: 0, behavior: 'smooth' }); // surface the Verify step
|
|||
} |
|||
|
|||
last = s; |
|||
|
|||
// detect reboot transition before computing the view
|
|||
if (s.state === 'rebooting' && !sawReboot) { |
|||
sawReboot = true; |
|||
rebootStart = performance.now(); |
|||
rebootOverdueAnnounced = false; |
|||
announce('Firmware written — device is rebooting. Do not power off.'); |
|||
log('flash committed — device rebooting'); |
|||
} |
|||
|
|||
// the failure-dismissed latch only masks while the server still reports failed;
|
|||
// once a re-flash / re-upload moves it off 'failed', drop the latch
|
|||
if (s.state !== 'failed') failureAck = false; |
|||
let view = computeView(s.state, latches()); |
|||
if (failureAck && view === 'failed') view = 'staged'; // user dismissed the failure
|
|||
const step = wizardStep(view, userStep); |
|||
|
|||
// ── wizard: top map + single panel + footer nav ──
|
|||
// re-render the stepper only when a node's state changes — rebuilding its
|
|||
// innerHTML every poll would restart the active-dot pulse animation each second
|
|||
const stepperStates = wizardStepperState(step, view); |
|||
const stepperSig = stepperStates.map(p => p.state).join(''); |
|||
if (stepperSig !== lastStepperSig) { renderStepper(stepperStates); lastStepperSig = stepperSig; } |
|||
const navKey = step + (view === 'failed' ? ':failed' : ''); |
|||
if (navKey !== lastNavKey) { renderNav(step, view === 'failed'); lastNavKey = navKey; } |
|||
showPanel(PANEL_FOR_STEP[step]); |
|||
updateNavState(); |
|||
setFlashTitle(view); |
|||
toggleWarning(step); |
|||
updateSlot(s.slot); |
|||
updateLive(s, view); |
|||
setUploadLocked(view === 'flashing' || view === 'rebooting'); |
|||
|
|||
// one-shot screen-reader cue when device-side commit begins (long indeterminate wait)
|
|||
if (view === 'flashing' && isCommitting(s) && !commitAnnounced) { |
|||
commitAnnounced = true; |
|||
announce('Writing firmware to flash and committing. Do not power off — up to about two minutes.'); |
|||
} |
|||
|
|||
// staged firmware → fill the upload panel + one-shot auto pre-flight
|
|||
if (view === 'staged' && !sawReboot && s.components && s.components.length) { |
|||
const host = document.getElementById('fw-comps-upload'); |
|||
if (host && !host.children.length) { |
|||
buildComponentsInUpload(s.components); |
|||
const drop = document.getElementById('fw-drop'); |
|||
if (drop) drop.querySelector('.fw-drop__main').textContent = 'Replace firmware ZIP (click or drop)'; |
|||
} |
|||
if (!preflightOk && !autoPreflightDone) { autoPreflightDone = true; doPreflight(); } |
|||
} |
|||
|
|||
updateFlashArea(s, view); |
|||
|
|||
if (view === 'failed' && prevState !== 'failed') { |
|||
announce('Update failed. ' + (s.message || 'See the log.')); |
|||
} |
|||
if (prevState !== 'flashing' && view === 'flashing') { |
|||
window.scrollTo({ top: 0, behavior: 'smooth' }); |
|||
announce('Flash started — do not power off the device.'); |
|||
} |
|||
prevState = view; |
|||
|
|||
const stamp = document.getElementById('fw-updated'); |
|||
if (stamp) stamp.textContent = viewLine(view, s.message); |
|||
} |
|||
|
|||
function updateSlot(slot) { |
|||
const el = document.getElementById('fw-slot-badge'); |
|||
if (el) el.innerHTML = slotBadge(slot); |
|||
} |
|||
|
|||
// live progress summary in the sticky header (always visible)
|
|||
function updateLive(s, view) { |
|||
const stateEl = document.getElementById('fw-live-state'); |
|||
const pctEl = document.getElementById('fw-live-pct'); |
|||
const fill = document.getElementById('fw-live-fill'); |
|||
const o = s.overall || { pct: 0, sent: 0, total: 0, elapsed_s: 0 }; |
|||
const active = view === 'flashing' || view === 'rebooting' || view === 'rebooted'; |
|||
const committing = view === 'flashing' && isCommitting(s); // computed once
|
|||
if (stateEl) { |
|||
stateEl.textContent = viewLine(view, s.message); |
|||
stateEl.className = 'fw-live__state' + ( |
|||
view === 'done' ? ' fw-live__state--done' |
|||
: view === 'failed' ? ' fw-live__state--error' |
|||
: active ? ' fw-live__state--active' : ''); |
|||
} |
|||
if (fill) { |
|||
const full = view === 'rebooting' || view === 'rebooted' || view === 'done'; |
|||
fill.style.width = (full ? 100 : o.pct) + '%'; |
|||
fill.className = 'fw-bar__fill' + ( |
|||
view === 'failed' ? ' fw-bar__fill--error' |
|||
: (view === 'done' || full) ? ' fw-bar__fill--done' |
|||
: committing ? ' fw-bar__fill--busy' : ''); |
|||
} |
|||
if (pctEl) { |
|||
const flashingText = committing |
|||
? 'writing to flash & committing — do not power off…' |
|||
: `${o.pct}% · ${fmtBytes(o.sent)}/${fmtBytes(o.total)} · ${fmtRate(o) || o.elapsed_s + 's'}`; |
|||
pctEl.textContent = |
|||
view === 'flashing' ? flashingText |
|||
: view === 'rebooting' ? 'rebooting…' |
|||
: view === 'rebooted' ? 'awaiting config check' |
|||
: view === 'done' ? 'complete' |
|||
: view === 'staged' ? 'ready to flash' : ''; |
|||
} |
|||
} |
|||
|
|||
function setUploadLocked(locked) { |
|||
if (uploadLocked === locked) return; |
|||
uploadLocked = locked; |
|||
const drop = document.getElementById('fw-drop'); |
|||
const file = document.getElementById('fw-file'); |
|||
if (drop) { |
|||
drop.classList.toggle('fw-drop--disabled', locked); |
|||
drop.setAttribute('aria-disabled', locked ? 'true' : 'false'); |
|||
drop.tabIndex = locked ? -1 : 0; |
|||
} |
|||
if (file) file.disabled = locked; |
|||
} |
|||
|
|||
function showFlashCard() { |
|||
document.getElementById('fw-flash-card')?.classList.remove('hidden'); |
|||
} |
|||
|
|||
// ─── wizard navigation (one step at a time) ───────────────────
|
|||
const PANEL_FOR_STEP = { |
|||
upload: 'upload', preflight: 'preflight', flash: 'flash', |
|||
reboot: 'flash', verify: 'verify', done: 'flash', |
|||
}; |
|||
|
|||
function showPanel(panel) { |
|||
document.querySelectorAll('#fw-wizard-body .fw-step-panel').forEach(p => { |
|||
p.classList.toggle('hidden', p.dataset.panel !== panel); |
|||
}); |
|||
} |
|||
|
|||
function stagedReady() { |
|||
return !!(last && last.state === 'staged' && last.components && last.components.length); |
|||
} |
|||
|
|||
function setFlashTitle(view) { |
|||
const el = document.getElementById('fw-flash-title'); |
|||
if (!el) return; |
|||
el.textContent = (view === 'rebooting' || view === 'rebooted') ? 'Restarting' |
|||
: view === 'done' ? 'Update complete' |
|||
: view === 'failed' ? 'Flash failed' : 'Flashing'; |
|||
} |
|||
|
|||
function toggleWarning(step) { |
|||
const w = document.getElementById('fw-warning'); |
|||
if (w) w.classList.toggle('hidden', step !== 'upload' && step !== 'preflight'); |
|||
} |
|||
|
|||
function renderNav(step, failed) { |
|||
const nav = document.getElementById('fw-wizard-nav'); |
|||
if (!nav) return; |
|||
let html; |
|||
if (step === 'upload') { |
|||
html = `<span class="fw-nav__hint">Choose a firmware ZIP to begin</span>
|
|||
<button class="btn btn--primary" id="fw-nav-next" disabled>Next →</button>`; |
|||
} else if (step === 'preflight') { |
|||
html = `<button class="btn btn--outline" id="fw-nav-back">← Back</button>
|
|||
<button class="btn btn--primary" id="fw-btn-flash" disabled>${icon('activity', {size:16})} Flash</button>`; |
|||
} else if (step === 'flash' && failed) { |
|||
html = `<button class="btn btn--outline" id="fw-nav-retry">← Back to pre-flight</button>
|
|||
<span class="fw-nav__hint fw-nav__hint--error">Flash failed — see log</span>`; |
|||
} else if (step === 'flash') { |
|||
html = `<span class="fw-nav__hint">${icon('activity', {size:16})} Installing — do not power off the device…</span>`; |
|||
} else if (step === 'reboot') { |
|||
html = `<span class="fw-nav__hint">${icon('refresh-cw', {size:16})} Restarting the device…</span>`; |
|||
} else if (step === 'verify') { |
|||
html = `<span class="fw-nav__hint">Verify the configuration survived</span>
|
|||
<button class="btn btn--primary" id="fw-btn-restore">${icon('check-circle-2', {size:16})} Verify config</button>`; |
|||
} else { // done
|
|||
html = `<span class="fw-nav__hint">${icon('check-circle-2', {size:16})} Update complete</span>
|
|||
<button class="btn btn--outline" id="fw-nav-startover">↻ Start over</button>`; |
|||
} |
|||
nav.innerHTML = html; |
|||
nav.querySelector('#fw-nav-next')?.addEventListener('click', () => gotoStep('preflight')); |
|||
nav.querySelector('#fw-nav-back')?.addEventListener('click', () => gotoStep('upload')); |
|||
nav.querySelector('#fw-nav-retry')?.addEventListener('click', () => { failureAck = true; gotoStep('preflight'); }); |
|||
nav.querySelector('#fw-btn-flash')?.addEventListener('click', doFlash); |
|||
nav.querySelector('#fw-btn-restore')?.addEventListener('click', doRestoreCheck); |
|||
nav.querySelector('#fw-nav-startover')?.addEventListener('click', startOver); |
|||
updateNavState(); |
|||
} |
|||
|
|||
function updateNavState() { |
|||
const next = document.getElementById('fw-nav-next'); |
|||
if (next) next.disabled = navNextDisabled(uploading, stagedReady()); |
|||
const flash = document.getElementById('fw-btn-flash'); |
|||
if (flash) flash.disabled = !preflightOk; |
|||
} |
|||
|
|||
function gotoStep(step) { |
|||
userStep = step; |
|||
// run pre-flight on entry if not yet passed; mark autoPreflightDone so poll()'s
|
|||
// own auto-preflight can't double-fire it
|
|||
if (step === 'preflight' && !preflightOk) { autoPreflightDone = true; doPreflight(); } |
|||
poll(); |
|||
} |
|||
|
|||
function startOver() { |
|||
sawReboot = false; backOnline = false; configChecked = false; flashMode = ''; |
|||
preflightOk = false; uploading = false; prevState = null; userStep = 'upload'; autoPreflightDone = false; |
|||
failureAck = false; lastConfigResult = null; flashStartedAt = 0; rebootStart = 0; |
|||
builtCompSig = ''; rebootOverdueAnnounced = false; commitAnnounced = false; |
|||
lastStepperSig = ''; lastNavKey = null; |
|||
renderPreflight(null); |
|||
const comps = document.getElementById('fw-comps-upload'); if (comps) comps.innerHTML = ''; |
|||
const drop = document.getElementById('fw-drop'); |
|||
if (drop) drop.querySelector('.fw-drop__main').textContent = 'Drop firmware ZIP here or click to choose'; |
|||
poll(); |
|||
} |
|||
|
|||
// ─── actions ──────────────────────────────────────────────────
|
|||
async function doUpload(file) { |
|||
if (!file) return; |
|||
// v1.11.9 (review LOW concurrency): doUpload is async and yields at `await
|
|||
// uploadFirmware(...)`; uploadLocked only guards an in-progress FLASH, not an
|
|||
// in-flight UPLOAD. Without this a second gesture re-enters and starts a 2nd upload.
|
|||
if (uploading) return; |
|||
if (uploadLocked) { showToast('A flash is in progress — please wait.', 'warning'); return; } |
|||
if (!/\.zip$/i.test(file.name)) { showToast('Please choose a .zip archive.', 'warning'); return; } |
|||
autoPreflightDone = true; // doUpload runs its own pre-flight; don't double-fire from poll
|
|||
// a new upload invalidates any prior pre-flight: gate Flash until re-checked
|
|||
preflightOk = false; |
|||
// gate NEXT for the whole upload: set BEFORE any await and reflect immediately, so a
|
|||
// poll tick mid-upload (which calls updateNavState) can't re-enable NEXT from a stale
|
|||
// `staged` state left by a prior upload. Re-derived in the finally block once settled.
|
|||
uploading = true; |
|||
updateNavState(); |
|||
const flashBtnReset = document.getElementById('fw-btn-flash'); |
|||
if (flashBtnReset) flashBtnReset.disabled = true; |
|||
const wrap = document.getElementById('fw-upbar-wrap'); |
|||
const bar = document.getElementById('fw-upbar'); |
|||
const drop = document.getElementById('fw-drop'); |
|||
if (wrap) wrap.classList.remove('hidden'); |
|||
if (drop) drop.querySelector('.fw-drop__main').innerHTML = |
|||
`Uploading <span class="fw-drop__name">${escapeHtml(file.name)}</span>…`; |
|||
log(`upload start: ${file.name} (${fmtBytes(file.size)})`); |
|||
try { |
|||
const r = await uploadFirmware(file, (loaded, total) => { |
|||
if (bar) bar.style.width = (loaded * 100 / total) + '%'; |
|||
}); |
|||
log(`identified: ${r.components.map(c => c.signature).join(', ')}`); |
|||
showToast(`Identified ${r.components.length} components.`, 'success'); |
|||
buildComponentsInUpload(r.components); |
|||
if (drop) drop.querySelector('.fw-drop__main').textContent = 'Replace firmware ZIP (click or drop)'; |
|||
// auto pre-flight
|
|||
await doPreflight(); |
|||
} catch (err) { |
|||
log('upload failed: ' + err.message); |
|||
showToast('Upload failed — ' + err.message, 'error'); |
|||
if (drop) drop.querySelector('.fw-drop__main').textContent = 'Drop firmware ZIP here or click to choose'; |
|||
} finally { |
|||
uploading = false; |
|||
updateNavState(); // re-derive NEXT: enabled iff the upload reached `staged`
|
|||
if (wrap) wrap.classList.add('hidden'); |
|||
if (bar) bar.style.width = '0%'; |
|||
} |
|||
} |
|||
|
|||
function buildComponentsInUpload(comps) { |
|||
const host = document.getElementById('fw-comps-upload'); |
|||
if (!host) return; |
|||
const bd = buildDateOf(comps); |
|||
host.innerHTML = |
|||
`<div class="card__title" style="margin:var(--space-md) 0 var(--space-sm);font-size:var(--font-size-sm);">Identified components</div>` + |
|||
comps.map(c => ` |
|||
<div class="fw-comp fw-comp--inline"> |
|||
<span class="fw-comp__state-icon">✓</span> |
|||
<span class="fw-comp__sig">${escapeHtml(c.signature)}</span> |
|||
<span class="fw-comp__fname" title="${escapeHtml(c.name)}">${escapeHtml(c.name)}</span> |
|||
<span class="fw-comp__role2">${escapeHtml(c.role)}</span> |
|||
<span class="fw-comp__hash">${escapeHtml((c.sha256 || '').slice(0, 16))}…</span> |
|||
<span class="fw-comp__size">${fmtBytes(c.size)}</span> |
|||
</div>`).join('') + |
|||
(bd ? `<div class="fw-builddate">${icon('book-open', {size:14})} Firmware build date: <strong>${escapeHtml(bd)}</strong></div>` : ''); |
|||
} |
|||
|
|||
async function doPreflight() { |
|||
const btn = document.getElementById('fw-btn-preflight'); |
|||
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; } |
|||
try { |
|||
const pf = await preflight(); |
|||
renderPreflight(pf); |
|||
preflightOk = pf.ok; |
|||
const flashBtn = document.getElementById('fw-btn-flash'); |
|||
if (flashBtn) flashBtn.disabled = !pf.ok; |
|||
log('pre-flight: ' + (pf.ok ? 'all critical checks pass' : 'blocked')); |
|||
if (!pf.ok) showToast('Pre-flight blocked — resolve the red checks.', 'warning'); |
|||
else showToast('Pre-flight passed — ready to flash.', 'success'); |
|||
} catch (err) { |
|||
preflightOk = false; // failed check must NOT leave Flash enabled
|
|||
const flashBtn = document.getElementById('fw-btn-flash'); |
|||
if (flashBtn) flashBtn.disabled = true; |
|||
showToast('Pre-flight failed — ' + err.message, 'error'); |
|||
log('pre-flight error: ' + err.message); |
|||
} finally { |
|||
if (btn) { btn.disabled = false; btn.textContent = '↻ Re-run'; } // wizard panel label
|
|||
} |
|||
} |
|||
|
|||
async function doFlash() { |
|||
if (!preflightOk) { showToast('Run pre-flight first.', 'warning'); return; } |
|||
const ok = await showConfirm(last); |
|||
if (!ok) return; |
|||
// fresh flash → clear any latched post-reboot/done state from a prior run
|
|||
sawReboot = false; backOnline = false; configChecked = false; flashMode = ''; |
|||
rebootOverdueAnnounced = false; preflightOk = false; prevState = null; failureAck = false; |
|||
commitAnnounced = false; |
|||
lastConfigResult = null; flashStartedAt = performance.now(); |
|||
const btn = document.getElementById('fw-btn-flash'); |
|||
if (btn) btn.disabled = true; |
|||
log('flash requested'); |
|||
try { |
|||
await startFlash(); |
|||
showFlashCard(); |
|||
window.scrollTo({ top: 0, behavior: 'smooth' }); |
|||
log('flash started — backing up config, then transferring'); |
|||
poll(); // advance the wizard to the Flash step immediately
|
|||
} catch (err) { |
|||
if (btn) btn.disabled = false; |
|||
showToast('Could not start flash — ' + err.message, 'error'); |
|||
log('flash start failed: ' + err.message); |
|||
} |
|||
} |
|||
|
|||
// design-system confirm modal showing exactly what will be flashed + target slot
|
|||
function showConfirm(s) { |
|||
return new Promise((resolve) => { |
|||
const comps = (s && s.components) || []; |
|||
const slot = (s && s.slot) || {}; |
|||
const target = slotTarget(slot); |
|||
const bd = buildDateOf(comps); |
|||
const rows = comps.map(c => |
|||
`<div class="fw-modal__row"><span>${escapeHtml(c.signature)} · ${escapeHtml(c.name)}</span><span>${fmtBytes(c.size)}</span></div>`).join(''); |
|||
const overlay = document.createElement('div'); |
|||
overlay.className = 'fw-modal-overlay'; |
|||
overlay.innerHTML = ` |
|||
<div class="fw-modal" role="dialog" aria-modal="true" aria-labelledby="fw-modal-title"> |
|||
<div class="fw-modal__title" id="fw-modal-title">${icon('activity', {size:16})} Flash firmware?</div> |
|||
<div class="fw-modal__body">The device will write these ${comps.length} component(s)${bd ? ' (built ' + escapeHtml(bd) + ')' : ''}, commit, and reboot. Config is backed up automatically first.</div> |
|||
<div class="fw-modal__list">${rows}</div> |
|||
<div class="fw-modal__slot">${slotBadge({ before: slot.before, after: target })}</div> |
|||
<div class="fw-modal__actions"> |
|||
<button class="btn btn--outline btn--sm" id="fw-modal-cancel">Cancel</button> |
|||
<button class="btn btn--primary btn--sm" id="fw-modal-ok">${icon('activity', {size:16})} Flash now</button> |
|||
</div> |
|||
</div>`; |
|||
document.body.appendChild(overlay); |
|||
const trigger = document.activeElement; // return focus here on close
|
|||
const focusable = overlay.querySelectorAll('button'); |
|||
const first = focusable[0], lastF = focusable[focusable.length - 1]; |
|||
const close = (val) => { |
|||
_modalCleanup = null; |
|||
overlay.remove(); |
|||
document.removeEventListener('keydown', onKey, true); |
|||
if (trigger && trigger.focus) trigger.focus(); |
|||
resolve(val); |
|||
}; |
|||
const onKey = (e) => { |
|||
if (e.key === 'Escape') { e.preventDefault(); close(false); return; } |
|||
if (e.key === 'Tab' && focusable.length) { // trap focus inside the dialog
|
|||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); lastF.focus(); } |
|||
else if (!e.shiftKey && document.activeElement === lastF) { e.preventDefault(); first.focus(); } |
|||
} |
|||
}; |
|||
// v1.11.10 (review #17): allow destroy() to tear this modal down without leaking the
|
|||
// document keydown listener or leaving the Promise dangling (resolves false = cancelled).
|
|||
_modalCleanup = () => { |
|||
document.removeEventListener('keydown', onKey, true); |
|||
overlay.remove(); |
|||
resolve(false); |
|||
}; |
|||
overlay.querySelector('#fw-modal-cancel').addEventListener('click', () => close(false)); |
|||
overlay.querySelector('#fw-modal-ok').addEventListener('click', () => close(true)); |
|||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); }); |
|||
document.addEventListener('keydown', onKey, true); |
|||
overlay.querySelector('#fw-modal-ok').focus(); |
|||
}); |
|||
} |
|||
|
|||
async function doRestoreCheck() { |
|||
const btn = document.getElementById('fw-btn-restore'); |
|||
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; } |
|||
try { |
|||
const { result } = await restoreCheck(); |
|||
lastConfigResult = result; |
|||
const host = document.getElementById('fw-config-result'); |
|||
const restored = result.restored; |
|||
const cls = restored ? 'fw-result--warn' : 'fw-result--ok'; |
|||
const resultIcon = restored ? icon('refresh-cw', {size:24}) : icon('check-circle-2', {size:24}); |
|||
const head = restored ? 'Config was reseeded — restored from backup' : 'Config preserved'; |
|||
const extra = result.dpworldapp_restarted ? ' · dpworldapp restarted' : ''; |
|||
if (host) host.innerHTML = `<div class="fw-result ${cls}">
|
|||
<span class="fw-result__icon">${resultIcon}</span> |
|||
<span>${escapeHtml(head)} — ${escapeHtml(result.reason || '')}${escapeHtml(extra)}</span></div>`; |
|||
showToast(head, restored ? 'warning' : 'success'); |
|||
announce(head + '. ' + (result.reason || '')); |
|||
log('config check: ' + head + ' — ' + (result.reason || '')); |
|||
configChecked = true; // → view becomes 'done', stepper completes
|
|||
} catch (err) { |
|||
showToast('Config check failed — ' + err.message, 'error'); |
|||
log('config check error: ' + err.message); |
|||
} finally { |
|||
if (btn) { btn.disabled = false; btn.innerHTML = icon('check-circle-2', {size:16}) + ' Verify config'; } // wizard nav label
|
|||
} |
|||
} |
|||
|
|||
// ─── page object ──────────────────────────────────────────────
|
|||
const firmwarePage = { |
|||
render(container) { |
|||
container.innerHTML = ` |
|||
<div class="fw-page"> |
|||
<div id="fw-announce" class="fw-sr-only" role="alert" aria-live="assertive"></div> |
|||
<div class="page-header"> |
|||
<h1 class="page-header__title">⬆ Firmware Update</h1> |
|||
<p class="page-header__desc" id="fw-updated">Loading…</p> |
|||
</div> |
|||
|
|||
<div class="card fw-sticky-card" id="fw-sticky"> |
|||
<div class="fw-stepper" id="fw-stepper"></div> |
|||
<div class="fw-live" role="status" aria-live="polite"> |
|||
<div class="fw-live__row"> |
|||
<span class="fw-live__state" id="fw-live-state">Ready</span> |
|||
<span class="fw-live__pct" id="fw-live-pct"></span> |
|||
</div> |
|||
<div class="fw-bar"><div class="fw-bar__fill" id="fw-live-fill" style="width:0%"></div></div> |
|||
<div class="flex-between mt-sm"> |
|||
<span class="text-muted" style="font-size:var(--font-size-xs)">Active / target slot</span> |
|||
<span id="fw-slot-badge"></span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="info-banner info-banner--warning" id="fw-warning">${icon('alert-triangle', {size:16})} This flashes real device firmware and reboots the device. Config is backed up automatically before writing.</div> |
|||
|
|||
<div class="fw-wizard-body" id="fw-wizard-body"> |
|||
<section class="card fw-step-panel" data-panel="upload" id="fw-panel-upload"> |
|||
<div class="card__header"><span class="card__title"><span class="card__title-icon">${icon('package', {size:16})}</span> Upload firmware ZIP</span></div> |
|||
<div class="fw-drop" id="fw-drop" tabindex="0" role="button" aria-label="Upload firmware ZIP — click or press Enter to choose a file"> |
|||
<span class="fw-drop__icon">${icon('package', {size:48})}</span> |
|||
<div class="fw-drop__main">Drop firmware ZIP here or click to choose</div> |
|||
<div class="fw-drop__sub">archive should contain main_bootloader.rom · main_boot.img · main_rootfs.ext4 · main.dtb</div> |
|||
</div> |
|||
<input type="file" id="fw-file" accept=".zip" style="display:none"> |
|||
<div class="fw-bar mt-md hidden" id="fw-upbar-wrap"><div class="fw-bar__fill" id="fw-upbar"></div></div> |
|||
<div id="fw-comps-upload"></div> |
|||
</section> |
|||
|
|||
<section class="card fw-step-panel hidden" data-panel="preflight" id="fw-panel-preflight"> |
|||
<div class="card__header"><span class="card__title"><span class="card__title-icon">${icon('radar', {size:16})}</span> Pre-flight checks</span> |
|||
<button class="btn btn--outline btn--sm" id="fw-btn-preflight">↻ Re-run</button></div> |
|||
<div id="fw-checks"></div> |
|||
</section> |
|||
|
|||
<section class="card fw-step-panel hidden" data-panel="flash" id="fw-panel-flash"> |
|||
<div class="card__header"><span class="card__title"><span class="card__title-icon">${icon('activity', {size:16})}</span> <span id="fw-flash-title">Flashing</span></span></div> |
|||
<div id="fw-flash-card"><div id="fw-flash-body"></div></div> |
|||
</section> |
|||
|
|||
<section class="card fw-step-panel hidden" data-panel="verify" id="fw-panel-verify"> |
|||
<div class="card__header"><span class="card__title"><span class="card__title-icon">${icon('check-circle-2', {size:16})}</span> Config check</span></div> |
|||
<div id="fw-config-result"><p class="text-muted" style="font-size:var(--font-size-xs)">After the device is back online, verify the configuration survived the update.</p></div> |
|||
</section> |
|||
</div> |
|||
|
|||
<div class="fw-wizard-nav" id="fw-wizard-nav"></div> |
|||
|
|||
<div class="card card--collapsible"> |
|||
<div class="card__header card__header--clickable" id="fw-log-toggle"> |
|||
<span class="card__title"><span class="card__title-icon">${icon('file-text', {size:16})}</span> Log</span> |
|||
<span class="card__collapse-icon" id="fw-log-caret">▸</span> |
|||
</div> |
|||
<pre class="fw-log" id="fw-log" style="display:none"></pre> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
}, |
|||
|
|||
mount(container) { |
|||
// track every listener so destroy() can remove them (no leak on re-mount)
|
|||
const bind = (el, ev, fn, opts) => { |
|||
if (!el) return; |
|||
el.addEventListener(ev, fn, opts); |
|||
boundListeners.push({ el, ev, fn, opts }); |
|||
}; |
|||
const fileInput = container.querySelector('#fw-file'); |
|||
const drop = container.querySelector('#fw-drop'); |
|||
bind(drop, 'click', () => { if (!uploadLocked) fileInput?.click(); }); |
|||
bind(drop, 'keydown', (e) => { |
|||
if ((e.key === 'Enter' || e.key === ' ') && !uploadLocked) { e.preventDefault(); fileInput?.click(); } |
|||
}); |
|||
bind(fileInput, 'change', (e) => { const f = e.target.files[0]; e.target.value = ''; doUpload(f); }); |
|||
['dragenter', 'dragover'].forEach(ev => bind(drop, ev, (e) => { |
|||
e.preventDefault(); drop.classList.add('fw-drop--over'); |
|||
})); |
|||
['dragleave', 'drop'].forEach(ev => bind(drop, ev, (e) => { |
|||
e.preventDefault(); drop.classList.remove('fw-drop--over'); |
|||
})); |
|||
bind(drop, 'drop', (e) => { const f = e.dataTransfer?.files?.[0]; if (f) doUpload(f); }); |
|||
|
|||
// Re-run pre-flight button (lives in the pre-flight panel)
|
|||
bind(container.querySelector('#fw-btn-preflight'), 'click', doPreflight); |
|||
// Flash / Verify / Next / Back / Start-over buttons are in the footer nav,
|
|||
// (re)bound by renderNav() per step.
|
|||
|
|||
// log collapse
|
|||
const logCard = container.querySelector('#fw-log'); |
|||
bind(container.querySelector('#fw-log-toggle'), 'click', () => { |
|||
const hidden = logCard.style.display === 'none'; |
|||
logCard.style.display = hidden ? '' : 'none'; |
|||
const caret = container.querySelector('#fw-log-caret'); |
|||
if (caret) caret.textContent = hidden ? '▾' : '▸'; |
|||
}); |
|||
|
|||
renderPreflight(null); |
|||
// pause polling while the tab is hidden; resume on return (single timer)
|
|||
if (visHandler) document.removeEventListener('visibilitychange', visHandler); |
|||
visHandler = () => { if (document.hidden) stopPolling(); else startPolling(); }; |
|||
document.addEventListener('visibilitychange', visHandler); |
|||
startPolling(); |
|||
}, |
|||
|
|||
destroy() { |
|||
stopPolling(); |
|||
if (visHandler) { document.removeEventListener('visibilitychange', visHandler); visHandler = null; } |
|||
boundListeners.forEach(({ el, ev, fn, opts }) => el.removeEventListener(ev, fn, opts)); |
|||
boundListeners = []; |
|||
if (announceTimer) { clearTimeout(announceTimer); announceTimer = null; } |
|||
last = null; prevState = null; preflightOk = false; |
|||
sawReboot = false; backOnline = false; configChecked = false; |
|||
flashMode = ''; uploadLocked = false; uploading = false; rebootStart = 0; autoPreflightDone = false; |
|||
rebootOverdueAnnounced = false; flashStartedAt = 0; lastConfigResult = null; |
|||
builtCompSig = ''; logLines = []; |
|||
userStep = 'upload'; failureAck = false; commitAnnounced = false; |
|||
lastNavKey = null; lastStepperSig = ''; |
|||
// v1.11.10 (review #17): if a confirm modal is open, tear it down cleanly first —
|
|||
// removes its keydown listener and resolves the dangling Promise (false = cancelled).
|
|||
_modalCleanup?.(); _modalCleanup = null; |
|||
document.querySelector('.fw-modal-overlay')?.remove(); |
|||
}, |
|||
}; |
|||
|
|||
export function renderFirmwarePage(container) { |
|||
firmwarePage.render(container); |
|||
firmwarePage.mount(container); |
|||
} |
|||
|
|||
export default firmwarePage; |
|||
@ -0,0 +1,355 @@ |
|||
/** |
|||
* general-settings.js — General Settings Page |
|||
* |
|||
* v1.5.0 Phase 3 T1: register.js 분할 — Equipment Info + Protocol Selection + Odometer. |
|||
* Analog/Digital ports 제거 → sensor-io.js로 이동. |
|||
* Byte Order 제거 → modbus.js로 이동. |
|||
* |
|||
* Data source: state.protocol |
|||
*/ |
|||
|
|||
import { state, bindInput, bindRadio, bindCheckbox } from '../state.js'; |
|||
import { updateTabVisibility } from '../app.js'; |
|||
import { escapeHtml } from '../utils.js'; |
|||
import { icon } from '../icons.js'; |
|||
|
|||
const EQUIPMENT_MEID = { |
|||
ITV: '1000', STS: '2000', RTG: '3000', RMG: '4000', |
|||
RS: '5000', ECH: '6000', MHC: '9000', |
|||
// v1.4.6.2 F4: FL/CR 제거 (v1.4.6.1 B-2 equipment dropdown 정합)
|
|||
}; |
|||
|
|||
function renderToggle(label, name, currentValue) { |
|||
return ` |
|||
<div class="form-group"> |
|||
<label class="form-label">${label}</label> |
|||
<div class="radio-group"> |
|||
<label class="radio-label"> |
|||
<input type="radio" name="${name}" value="on" ${currentValue === 'on' ? 'checked' : ''}> |
|||
On |
|||
</label> |
|||
<label class="radio-label"> |
|||
<input type="radio" name="${name}" value="off" ${currentValue !== 'on' ? 'checked' : ''}> |
|||
Off |
|||
</label> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
} |
|||
|
|||
function renderGeneralSettingsPage(container) { |
|||
const p = state.protocol || {}; |
|||
|
|||
container.innerHTML = ` |
|||
<div class="page-header"> |
|||
<h1 class="page-header__title">${icon('sliders-horizontal', { size: 28 })} General Settings</h1> |
|||
<p class="page-header__desc">Equipment information, protocol selection, and odometer configuration.</p> |
|||
</div> |
|||
|
|||
<!-- Card: Equipment Info --> |
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title">${icon('building-2', { size: 18 })} Equipment Info</h2> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Equipment Type</label> |
|||
<select class="form-select" id="reg-equipment_type"> |
|||
${['RTLS', 'TIOT'].map(v => |
|||
`<option value="${v}" ${p.dev_type === v ? 'selected' : ''}>${v}</option>` |
|||
).join('')} |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Equipment</label> |
|||
<select class="form-select" id="reg-equipment"> |
|||
${['ITV', 'RS', 'ECH', 'RTG', 'RMG', 'MHC', 'STS'].map(v => |
|||
`<option value="${v}" ${p.equipment === v ? 'selected' : ''}>${v}</option>` |
|||
).join('')} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Equipment ID</label> |
|||
<input class="form-input form-input--mono" id="reg-equipment_id" value="${escapeHtml(p.equipment_id || '01')}" placeholder="01"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">MEID</label> |
|||
<input class="form-input form-input--mono" id="reg-MEID" value="${escapeHtml(p.MEID || EQUIPMENT_MEID[p.equipment] || '1000')}" placeholder="1000"> |
|||
</div> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Version</label> |
|||
<input class="form-input" id="reg-version" value="${escapeHtml(p.version || 'v1.0')}" placeholder="v1.0"> |
|||
</div> |
|||
<div class="form-group"></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Card: Protocol Selection --> |
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title">${icon('radio', { size: 18 })} Protocol Selection</h2> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Protocol</label> |
|||
<div class="radio-group"> |
|||
${['OPC_UA', 'MODBUS', 'NONE'].map(v => ` |
|||
<label class="radio-label"> |
|||
<input type="radio" name="reg-protocol" value="${v}" ${p.protocol === v ? 'checked' : ''}> |
|||
${v} |
|||
</label> |
|||
`).join('')}
|
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-row"> |
|||
${renderToggle('CAN Input', 'reg-can_input', p.can_input)} |
|||
${renderToggle('DR', 'reg-dr', p.dr_on)} |
|||
</div> |
|||
<div class="form-row"> |
|||
${renderToggle('Heading', 'reg-heading', p.heading_on)} |
|||
${renderToggle('Heading IMU', 'reg-heading_imu', p.heading_imu_on)} |
|||
</div> |
|||
<div class="form-row"> |
|||
${renderToggle('Fix Mode', 'reg-fix_mode', p.fix_mode_on)} |
|||
<div class="form-group"> |
|||
<label class="form-label">Speed Data</label> |
|||
<select class="form-select" id="reg-speed_data"> |
|||
${['', 'CAN', 'GPS'].map(v => |
|||
`<option value="${v}" ${p.speed_data === v ? 'selected' : ''}>${v || '— Select —'}</option>` |
|||
).join('')} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Card: Odometer (v1.4.6 C) — odo_on + speed/direction source fields --> |
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title">${icon('gauge', { size: 18 })} Odometer</h2> |
|||
</div> |
|||
<div class="form-row"> |
|||
${renderToggle('Odometer', 'reg-odo_on', p.odo_on)} |
|||
</div> |
|||
|
|||
<div id="odo-section" style="${p.odo_on === 'on' ? '' : 'display:none;'}"> |
|||
<h3 class="form-section-title" style="margin-top:1rem;">Odometer Speed</h3> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Source</label> |
|||
<select class="form-select" id="reg-odo_speed_source"> |
|||
${['can', 'hw'].map(v => `<option value="${v}" ${p.odo_speed?.source === v ? 'selected' : ''}>${v.toUpperCase()}</option>`).join('')} |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">CAN ID (hex)</label> |
|||
<input type="text" class="form-input" id="reg-odo_speed_id" value="${escapeHtml(p.odo_speed?.id || '')}" placeholder="0x56E"> |
|||
</div> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Shift</label> |
|||
<input type="number" class="form-input" id="reg-odo_speed_shift" value="${Number(p.odo_speed?.shift) || 0}" min="0" max="63"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Mask (hex)</label> |
|||
<input type="text" class="form-input" id="reg-odo_speed_mask" value="${escapeHtml(p.odo_speed?.mask || '')}" placeholder="0x0f"> |
|||
</div> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group" style="flex:1;"> |
|||
<label class="form-label">Expression</label> |
|||
<input type="text" class="form-input" id="reg-odo_speed_expr" value="${escapeHtml(p.odo_speed?.expr || '')}" placeholder="x*1000" maxlength="256"> |
|||
</div> |
|||
</div> |
|||
|
|||
<h3 class="form-section-title" style="margin-top:1rem;">Odometer Direction</h3> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Source</label> |
|||
<select class="form-select" id="reg-odo_direction_source"> |
|||
${['can', 'hw'].map(v => `<option value="${v}" ${p.odo_direction?.source === v ? 'selected' : ''}>${v.toUpperCase()}</option>`).join('')} |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">CAN ID (hex)</label> |
|||
<input type="text" class="form-input" id="reg-odo_direction_id" value="${escapeHtml(p.odo_direction?.id || '')}" placeholder="0x169"> |
|||
</div> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group"> |
|||
<label class="form-label">Shift</label> |
|||
<input type="number" class="form-input" id="reg-odo_direction_shift" value="${Number(p.odo_direction?.shift) || 0}" min="0" max="63"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Mask (hex)</label> |
|||
<input type="text" class="form-input" id="reg-odo_direction_mask" value="${escapeHtml(p.odo_direction?.mask || '')}" placeholder="0x0f"> |
|||
</div> |
|||
</div> |
|||
<div class="form-row"> |
|||
<div class="form-group" style="flex:1;"> |
|||
<label class="form-label">Expression</label> |
|||
<input type="text" class="form-input" id="reg-odo_direction_expr" value="${escapeHtml(p.odo_direction?.expr || '')}" placeholder="x-7" maxlength="256"> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
|
|||
// Equipment change → auto-fill MEID
|
|||
document.getElementById('reg-equipment').addEventListener('change', (e) => { |
|||
const meid = EQUIPMENT_MEID[e.target.value]; |
|||
if (meid) { |
|||
document.getElementById('reg-MEID').value = meid; |
|||
} |
|||
}); |
|||
|
|||
// Protocol change → update sidebar tab visibility
|
|||
document.querySelectorAll('input[name="reg-protocol"]').forEach(radio => { |
|||
radio.addEventListener('change', (e) => { |
|||
state.protocol.protocol = e.target.value; |
|||
updateTabVisibility(); |
|||
}); |
|||
}); |
|||
|
|||
// CAN input change → update sidebar tab visibility
|
|||
document.querySelectorAll('input[name="reg-can_input"]').forEach(radio => { |
|||
radio.addEventListener('change', (e) => { |
|||
state.protocol.can_input = e.target.value; |
|||
updateTabVisibility(); |
|||
}); |
|||
}); |
|||
|
|||
// Initialize tab visibility
|
|||
updateTabVisibility(); |
|||
|
|||
// Register data collector
|
|||
window.__pageCollectors.general = collectGeneralSettingsData; |
|||
// Legacy alias for backward compat
|
|||
window.__pageCollectors.register = collectGeneralSettingsData; |
|||
} |
|||
|
|||
function collectGeneralSettingsData() { |
|||
if (!state.protocol) state.protocol = {}; |
|||
const p = state.protocol; |
|||
|
|||
// Equipment info
|
|||
p.dev_type = document.getElementById('reg-equipment_type')?.value || ''; |
|||
p.equipment = document.getElementById('reg-equipment')?.value || ''; |
|||
p.equipment_id = document.getElementById('reg-equipment_id')?.value || ''; |
|||
p.MEID = document.getElementById('reg-MEID')?.value || ''; |
|||
p.version = document.getElementById('reg-version')?.value || ''; |
|||
|
|||
// Protocol
|
|||
p.protocol = document.querySelector('input[name="reg-protocol"]:checked')?.value || 'NONE'; |
|||
|
|||
// Toggles
|
|||
p.can_input = document.querySelector('input[name="reg-can_input"]:checked')?.value || 'off'; |
|||
p.dr_on = document.querySelector('input[name="reg-dr"]:checked')?.value || 'off'; |
|||
p.heading_on = document.querySelector('input[name="reg-heading"]:checked')?.value || 'off'; |
|||
p.heading_imu_on = document.querySelector('input[name="reg-heading_imu"]:checked')?.value || 'off'; |
|||
p.fix_mode_on = document.querySelector('input[name="reg-fix_mode"]:checked')?.value || 'off'; |
|||
p.speed_data = document.getElementById('reg-speed_data')?.value || ''; |
|||
|
|||
// v1.4.6 C: Odometer (odo_on toggle + odo_speed/odo_direction nested)
|
|||
p.odo_on = document.querySelector('input[name="reg-odo_on"]:checked')?.value || 'off'; |
|||
if (p.odo_on === 'on') { |
|||
p.odo_speed = { |
|||
source: document.getElementById('reg-odo_speed_source')?.value || 'can', |
|||
id: document.getElementById('reg-odo_speed_id')?.value || '', |
|||
shift: parseInt(document.getElementById('reg-odo_speed_shift')?.value, 10) || 0, |
|||
mask: document.getElementById('reg-odo_speed_mask')?.value || '', |
|||
expr: document.getElementById('reg-odo_speed_expr')?.value || '', |
|||
}; |
|||
p.odo_direction = { |
|||
source: document.getElementById('reg-odo_direction_source')?.value || 'can', |
|||
id: document.getElementById('reg-odo_direction_id')?.value || '', |
|||
shift: parseInt(document.getElementById('reg-odo_direction_shift')?.value, 10) || 0, |
|||
mask: document.getElementById('reg-odo_direction_mask')?.value || '', |
|||
expr: document.getElementById('reg-odo_direction_expr')?.value || '', |
|||
}; |
|||
} else { |
|||
// v1.4.6.1 B-4: odo_on=off 시 stale nested 차단
|
|||
delete p.odo_speed; |
|||
delete p.odo_direction; |
|||
} |
|||
} |
|||
|
|||
// ─── Select/Input field bindings ────────────────────────────
|
|||
const GS_INPUTS = [ |
|||
{ id: 'reg-equipment_type', key: 'dev_type' }, |
|||
{ id: 'reg-equipment', key: 'equipment' }, |
|||
{ id: 'reg-equipment_id', key: 'equipment_id' }, |
|||
{ id: 'reg-MEID', key: 'MEID' }, |
|||
{ id: 'reg-version', key: 'version' }, |
|||
{ id: 'reg-speed_data', key: 'speed_data' }, |
|||
]; |
|||
const GS_RADIOS = [ |
|||
{ name: 'reg-protocol', key: 'protocol' }, |
|||
{ name: 'reg-can_input', key: 'can_input' }, |
|||
{ name: 'reg-dr', key: 'dr_on' }, |
|||
{ name: 'reg-heading', key: 'heading_on' }, |
|||
{ name: 'reg-heading_imu', key: 'heading_imu_on' }, |
|||
{ name: 'reg-fix_mode', key: 'fix_mode_on' }, |
|||
]; |
|||
|
|||
// ─── Page Interface ─────────────────────────────────────────
|
|||
const generalSettingsPage = { |
|||
render(container) { |
|||
renderGeneralSettingsPage(container); |
|||
}, |
|||
|
|||
mount(container) { |
|||
const p = state.protocol || {}; |
|||
|
|||
// Select/input fields → state
|
|||
GS_INPUTS.forEach(({ id, key }) => { |
|||
bindInput(container, `#${id}`, |
|||
() => p[key] || '', |
|||
(val) => { |
|||
if (!state.protocol) state.protocol = {}; |
|||
state.protocol[key] = val; |
|||
state.isDirty = true; |
|||
} |
|||
); |
|||
}); |
|||
|
|||
// Radio groups → state
|
|||
GS_RADIOS.forEach(({ name, key }) => { |
|||
bindRadio(container, name, |
|||
() => p[key] || (key === 'protocol' ? 'NONE' : 'off'), |
|||
(val) => { |
|||
if (!state.protocol) state.protocol = {}; |
|||
state.protocol[key] = val; |
|||
state.isDirty = true; |
|||
} |
|||
); |
|||
}); |
|||
|
|||
// v1.4.6 C: odo_on toggle → show/hide #odo-section
|
|||
document.querySelectorAll('input[name="reg-odo_on"]').forEach(radio => { |
|||
radio.addEventListener('change', e => { |
|||
const section = document.getElementById('odo-section'); |
|||
if (section) section.style.display = e.target.value === 'on' ? '' : 'none'; |
|||
if (!state.protocol) state.protocol = {}; |
|||
state.protocol.odo_on = e.target.value; |
|||
state.isDirty = true; |
|||
}); |
|||
}); |
|||
|
|||
// Collectors
|
|||
window.__pageCollectors.general = collectGeneralSettingsData; |
|||
window.__pageCollectors.register = collectGeneralSettingsData; // legacy alias
|
|||
}, |
|||
|
|||
destroy() {}, |
|||
|
|||
validate() { |
|||
return []; |
|||
}, |
|||
}; |
|||
|
|||
export default generalSettingsPage; |
|||
File diff suppressed because it is too large
@ -0,0 +1,156 @@ |
|||
/** |
|||
* io.js — I/O Configuration Page (RS485 + CAN bus) |
|||
* |
|||
* v1.5.0 Phase 2: Ethernet and LTE cards moved to pages/ethernet.js. |
|||
* This file retains RS485 + CAN bus settings only (transitional state). |
|||
* Phase 3: RS485 → sensor-io.js, CAN → can-bus.js; io.js deleted. |
|||
*/ |
|||
|
|||
import { state, bindInput } from '../state.js'; |
|||
import { CAN_BAUDRATES, CAN_BAUDRATE_LABELS } from '../constants.js'; |
|||
|
|||
// ─── Render ──────────────────────────────────────────────────
|
|||
|
|||
export function renderIoPage(container) { |
|||
const device = state.device || {}; |
|||
const can = device.can || {}; |
|||
const rs485 = device.rs485 || {}; |
|||
|
|||
container.innerHTML = ` |
|||
<div class="page-header"> |
|||
<h1 class="page-header__title">🔌 I/O Settings</h1> |
|||
<p class="page-header__desc">RS485 and CAN bus communication settings. Ethernet and LTE interface IP are configured under Network → Ethernet.</p> |
|||
</div> |
|||
|
|||
<!-- CAN + RS485 --> |
|||
<div class="grid-2col"> |
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title"><span class="card__title-icon">🚌</span> CAN Bus</h2> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Frame Type</label> |
|||
<select class="form-select" id="can_type"> |
|||
<option value="standard" ${can.type === 'standard' ? 'selected' : ''}>Standard</option> |
|||
<option value="extended" ${can.type !== 'standard' ? 'selected' : ''}>Extended</option> |
|||
</select> |
|||
</div> |
|||
<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('')} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="card"> |
|||
<div class="card__header"> |
|||
<h2 class="card__title"><span class="card__title-icon">🔌</span> RS485</h2> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Mode</label> |
|||
<select class="form-select" id="rs485_mode"> |
|||
<option value="half" ${rs485.mode === 'half' ? 'selected' : ''}>Half Duplex</option> |
|||
<option value="full" ${rs485.mode === 'full' ? 'selected' : ''}>Full Duplex</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Baud Rate</label> |
|||
<select class="form-select" id="rs485_speed"> |
|||
${[9600, 115200].map(v => `<option value="${v}" ${String(rs485.speed) === String(v) ? 'selected' : ''}>${v}</option>`).join('')} |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Data Bits</label> |
|||
<select class="form-select" id="rs485_databits"> |
|||
${['5', '6', '7', '8'].map(v => `<option value="${v}" ${(rs485.databits || '8') === v ? 'selected' : ''}>${v}</option>`).join('')} |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Parity</label> |
|||
<select class="form-select" id="rs485_parity"> |
|||
<option value="none" ${rs485.parity !== 'even' && rs485.parity !== 'odd' ? 'selected' : ''}>None</option> |
|||
<option value="even" ${rs485.parity === 'even' ? 'selected' : ''}>Even</option> |
|||
<option value="odd" ${rs485.parity === 'odd' ? 'selected' : ''}>Odd</option> |
|||
</select> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">Stop Bits</label> |
|||
<select class="form-select" id="rs485_stopbits"> |
|||
${['0', '1', '2'].map(v => `<option value="${v}" ${(rs485.stopbits || '1') === v ? 'selected' : ''}>${v}</option>`).join('')} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
`;
|
|||
|
|||
// Register data collector
|
|||
window.__pageCollectors.io = collectIoData; |
|||
} |
|||
|
|||
// ─── Data Collection ─────────────────────────────────────────
|
|||
|
|||
function collectIoData() { |
|||
if (!state.device) state.device = {}; |
|||
|
|||
state.device.can = { |
|||
type: document.getElementById('can_type')?.value || '', |
|||
speed: document.getElementById('can_speed')?.value || '', |
|||
}; |
|||
|
|||
state.device.rs485 = { |
|||
mode: document.getElementById('rs485_mode')?.value || '', |
|||
speed: document.getElementById('rs485_speed')?.value || '', |
|||
databits: document.getElementById('rs485_databits')?.value || '8', |
|||
parity: document.getElementById('rs485_parity')?.value || '', |
|||
stopbits: document.getElementById('rs485_stopbits')?.value || '1', // v1.4.5.1 m2: dha baseline 일관
|
|||
}; |
|||
} |
|||
|
|||
// ─── Select field bindings ──────────────────────────────────
|
|||
const SELECT_FIELDS = [ |
|||
{ id: 'can_type', group: 'can', key: 'type' }, |
|||
{ id: 'can_speed', group: 'can', key: 'speed' }, |
|||
{ id: 'rs485_mode', group: 'rs485', key: 'mode' }, |
|||
{ id: 'rs485_speed', group: 'rs485', key: 'speed' }, |
|||
{ id: 'rs485_databits',group: 'rs485', key: 'databits' }, |
|||
{ id: 'rs485_parity', group: 'rs485', key: 'parity' }, |
|||
{ id: 'rs485_stopbits',group: 'rs485', key: 'stopbits' }, |
|||
]; |
|||
|
|||
// ─── New Page Interface ─────────────────────────────────────
|
|||
const ioPage = { |
|||
render(container) { |
|||
// Delegate to legacy render
|
|||
renderIoPage(container); |
|||
}, |
|||
|
|||
mount(container) { |
|||
// Select fields → state
|
|||
SELECT_FIELDS.forEach(({ id, group, key }) => { |
|||
bindInput(container, `#${id}`, |
|||
() => state.device?.[group]?.[key] || '', |
|||
(val) => { |
|||
if (!state.device) state.device = {}; |
|||
if (!state.device[group]) state.device[group] = {}; |
|||
state.device[group][key] = val; |
|||
state.isDirty = true; |
|||
} |
|||
); |
|||
}); |
|||
|
|||
// Legacy collector
|
|||
window.__pageCollectors.io = collectIoData; |
|||
}, |
|||
|
|||
destroy() {}, |
|||
|
|||
validate() { |
|||
// CAN and RS485 validation delegated to can.js and register.js respectively.
|
|||
// io.js only does existence check here.
|
|||
return []; |
|||
}, |
|||
}; |
|||
|
|||
export default ioPage; |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue