# 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 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 팀 협의 결과 |