"""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 if db.config_exists("device_config"): 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 if db.config_exists("device_config"): 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 if db.config_exists("device_config"): 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 if db.config_exists("protocol_config"): 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