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.
 
 
 
 
 
 

16 KiB

Migration 예시 — v1 → v2 → v3 변환

본 문서는 device_config / protocol_configschema 버전별 변환 예시 와 양측 (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 (현재):

{
  "wifi_static": "off",
  "log_save": "on"
}

v2 변환 후:

{
  "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:

{
  "lte_port": "534",
  "lte_server_port": "20111"
}

v2:

{
  "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:

{
  "WIFI_SSID": [
    {"wifi_ssid": "mobidigm", "wifi_passwd": "mobidigm", "wifi_security": "wpa/wpa2"}
  ]
}

v2:

{
  "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):

{
  "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 변환 후:

{
  "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:

{
  "dr_on": "on",
  "odo_on": "on",
  "ai0": "1",
  "ai3": "0"
}

v2:

{
  "dr_on": true,
  "odo_on": true,
  "ai0": true,
  "ai3": false
}

3.2 변환 예시 — MEID 정규화

v1 (default 는 int):

{"MEID": 7000}

v2:

{"meid": 7000}

3.3 변환 예시 — odo_speed.shift Integer

v1:

{
  "odo_speed": {
    "source": "CAN",
    "id": "0x18FEFC28",
    "shift": "8",
    "mask": "0xffff",
    "expr": "x*0.05+10.0"
  }
}

v2:

{
  "odo_speed": {
    "source": "CAN",
    "id": "0x18FEFC28",
    "shift": 8,
    "mask": "0xffff",
    "expr": "x*0.05+10.0"
  }
}

3.4 protocol v1 → v2 전체 예시

v1 일부:

{
  "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 변환:

{
  "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 일부:

{
  "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 변환:

{
  "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 일부:

{
  "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 변환:

{
  "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)

# 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.pymigrate_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:

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