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.

680 lines
16 KiB

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