diff --git a/src/migrations.py b/src/migrations.py index ea5e909..4109941 100644 --- a/src/migrations.py +++ b/src/migrations.py @@ -14,14 +14,25 @@ _CAN_BAUDRATE_OLD_TO_NEW = { } +def _existing_config(db, key): + cfg = db.get_config(key) + if not isinstance(cfg, dict) or not cfg: + return None + return cfg + + 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 + if _existing_config(db, "device_config") is None: + return False def _mut(cur): - cfg = dict(cur) if isinstance(cur, dict) else {} + if not isinstance(cur, dict) or not cur: + return cur + cfg = dict(cur) new = _CAN_BAUDRATE_OLD_TO_NEW.get(cfg.get("can_baudrate")) if new is not None: cfg["can_baudrate"] = new @@ -33,7 +44,7 @@ def migrate_can_baudrate_units(db) -> bool: cfg["can_baudrate"] = int(v.strip()) return cfg - db.update_config("device_config", _mut, default={}) + db.update_config("device_config", _mut) db.set_schema_meta("can_baudrate_units_migrated", "true") return True @@ -53,10 +64,9 @@ def migrate_log_compress_split(db) -> bool: # 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 + dev = _existing_config(db, "device_config") + if dev is None: + return False moved = {k: dev[k] for k in _LOG_COMPRESS_KEYS if k in dev} @@ -81,9 +91,8 @@ def migrate_log_compress_split(db) -> bool: # 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={}, + lambda cur: {k: v for k, v in cur.items() + if k not in _LOG_COMPRESS_KEYS} if isinstance(cur, dict) and cur else cur, ) db.set_schema_meta("log_compress_split_migrated", "true") @@ -103,16 +112,20 @@ def migrate_port_types(db) -> bool: dha baseline confirms Java schema Integer (2026-06-04 verify).""" if db.get_schema_meta("port_types_migrated") == "true": return False + if _existing_config(db, "device_config") is None: + return False def _mut(cur): - cfg = dict(cur) if isinstance(cur, dict) else {} + if not isinstance(cur, dict) or not cur: + return cur + cfg = dict(cur) 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.update_config("device_config", _mut) db.set_schema_meta("port_types_migrated", "true") return True @@ -131,21 +144,28 @@ def migrate_contract_canonical(db) -> bool: """ if db.get_schema_meta("contract_canonical_migrated") == "true": return False + if (_existing_config(db, "device_config") is None or + _existing_config(db, "protocol_config") is None): + return False # device_config: rs485_parity "no" → "none" def _mut_device(cur): - cfg = dict(cur) if isinstance(cur, dict) else {} + if not isinstance(cur, dict) or not cur: + return cur + cfg = dict(cur) if cfg.get("rs485_parity") == "no": cfg["rs485_parity"] = "none" return cfg - db.update_config("device_config", _mut_device, default={}) + db.update_config("device_config", _mut_device) # 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 {} + if not isinstance(cur, dict) or not cur: + return cur + cfg = dict(cur) for bo_key in ("two_byte_order", "four_byte_order"): v = cfg.get(bo_key) if v in _BYTE_ORDER_MAP: @@ -164,7 +184,7 @@ def migrate_contract_canonical(db) -> bool: cfg[arr_key] = new_arr return cfg - db.update_config("protocol_config", _mut_protocol, default={}) + db.update_config("protocol_config", _mut_protocol) db.set_schema_meta("contract_canonical_migrated", "true") return True diff --git a/src/network/apply_engine.py b/src/network/apply_engine.py index 435a9d6..fbf8368 100644 --- a/src/network/apply_engine.py +++ b/src/network/apply_engine.py @@ -130,20 +130,24 @@ class ApplyEngine: def _write_db(self, fields): def mut(cur): - base = cur if isinstance(cur, dict) else {} + if not isinstance(cur, dict) or not cur: + raise RuntimeError("device_config is not initialized") + base = cur out = dict(base); out.update(fields) # partial-merge (§4.1) return out - self.db.update_config("device_config", mut, default={}) + self.db.update_config("device_config", mut) def _write_db_restore(self, present, absent): """I1: 롤백 전용 — present 키 복원 + 스냅샷 시점 부재 키 pop.""" def mut(cur): - base = cur if isinstance(cur, dict) else {} + if not isinstance(cur, dict) or not cur: + raise RuntimeError("device_config is not initialized") + base = cur out = dict(base); out.update(present) for k in absent: out.pop(k, None) return out - self.db.update_config("device_config", mut, default={}) + self.db.update_config("device_config", mut) def _run(self, argv, timeout, apply_id, must_ok=False): t0 = self.clock() diff --git a/src/server.py b/src/server.py index 60362a0..06613a8 100644 --- a/src/server.py +++ b/src/server.py @@ -809,6 +809,14 @@ class ConfigHandler(BaseHTTPRequestHandler): # v1.4.0.3: case-only alias normalization (e.g. "ON"→"on", "WPA/WPA2"→"wpa/wpa2") # must run before enum validation so aliases pass validation correctly + current_device = db.get_config("device_config") + if not isinstance(current_device, dict) or not current_device: + self._send_json_response( + {"ok": False, "errors": ["device_config is not initialized"]}, + 409, + ) + return + data = normalize_device_input(data) # v1.4.4: Super_Relay 소유 키 strict reject (silent passthrough 방지) @@ -890,10 +898,12 @@ class ConfigHandler(BaseHTTPRequestHandler): _cleaned_device = cleaned # closure capture def _merge_device(current): - base = current if isinstance(current, dict) else {} + if not isinstance(current, dict) or not current: + return current + base = current return {**base, **_cleaned_device} - merged_device = db.update_config("device_config", _merge_device, default={}) + merged_device = db.update_config("device_config", _merge_device) if log_part: _log_part = log_part # closure capture @@ -938,6 +948,14 @@ class ConfigHandler(BaseHTTPRequestHandler): return # v1.4.0.3: case-only alias normalization (e.g. can_input "ON"→"on") + current_protocol = db.get_config("protocol_config") + if not isinstance(current_protocol, dict) or not current_protocol: + self._send_json_response( + {"ok": False, "errors": ["protocol_config is not initialized"]}, + 409, + ) + return + data = normalize_protocol_input(data) # v1.4.4: Super_Relay 소유 키 strict reject (silent passthrough 방지) @@ -1001,10 +1019,9 @@ class ConfigHandler(BaseHTTPRequestHandler): _cleaned_protocol = cleaned # closure capture def _merge_protocol(current): - base = current if isinstance(current, dict) else {} - if not isinstance(base, dict): - print(f"[WARN] non-dict protocol_config detected (type={type(base).__name__}), falling back to empty") - base = {} + if not isinstance(current, dict) or not current: + return current + base = current result = {**base, **_cleaned_protocol} # v1.4.6.8 C1: partial-merge AFTER process_protocol_config to preserve arrays result = process_protocol_config(result) @@ -1014,7 +1031,7 @@ class ConfigHandler(BaseHTTPRequestHandler): result.pop("odo_direction", None) return result - merged = db.update_config("protocol_config", _merge_protocol, default={}) + merged = db.update_config("protocol_config", _merge_protocol) self._send_json_response({ "success": True,