You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
209 lines
8.3 KiB
209 lines
8.3 KiB
|
1 month ago
|
"""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
|