diff --git a/CHANGELOG.md b/CHANGELOG.md index 927ff6e..69bf254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ --- +## [v1.12.4] — 2026-07-09 +### Changed +- **프로토콜 레지스터 필드를 디바이스 config-reader 계약에 정렬 (Modbus/OPC-UA)**: + - Modbus 레지스터 `idt` 를 디바이스가 기대하는 **공백형**으로 저장 (`unsignedInteger`→`unsigned integer`, `unsignedShort`→`unsigned short`). UI 드롭다운 라벨은 그대로 유지(저장 시에만 변환, 로드 시 역변환). 이전에는 camelCase 로 저장돼 **Modbus 설정 전체가 로드 실패**할 수 있었습니다. + - **Modbus·OPC-UA 레지스터 폼에서 `Shift`/`Expr`/`Mask` 제거** — 해당 프로토콜에서는 디바이스가 사용하지 않는 필드입니다(`Expr` 은 저장되나 값에 미적용, `Shift`/`Mask` 는 Modbus/OPC-UA 레지스터 필드가 아님). **CAN 은 유지**(실제 적용됨). + - **ODT `string` 옵션**: Modbus·CAN 에서 제거(디바이스가 Modbus 는 거부, CAN 은 출력 없음), **OPC-UA 는 유지**. 백엔드도 Modbus/CAN 의 `string` 저장을 400 으로 거부. +- **레지스터 매핑 테이블 UI 정돈**: 컬럼 헤더를 축약어 없이 풀네임으로 통일 — Modbus `Field / Address / Input Type / Output Type / Default Value`, OPC-UA `Field / Namespace / Node ID / Output Type / Default Value`, CAN `Field / CAN ID / Output Type / Default Value / Shift / Expr / Mask`. CAN Baud Rate 드롭다운에서 미사용 `NONE` 항목을 표시에서 숨김(내부 데이터 계약·firmware 값 검증은 유지). +### Added +- 기존 디바이스 DB 의 위 표기를 **부팅 시 1회 자동 정합**(멱등 마이그레이션, 이전 계약-정합 마이그레이션을 거친 디바이스에서도 실행되도록 별도 플래그). +### Note +- 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`) 유지. dpworldapp 공유 DB·계약·타입 무변경(레지스터 표기 정합만). + ## [v1.12.1] — 2026-06-29 ### Added - **텔레메트리 Uplink (Ethernet 전환)**: dpworldapp 클라우드 텔레메트리(프로토콜·업데이트·RTCM) 송신을 웹 UI 에서 Wi-Fi↔Ethernet 전환. 호스트 라우트(/32)만 사용 — dpworldapp·OS 설정 무접촉. 워치독 자가복원 + 관리 인터페이스 보호. diff --git a/DELIVERABLE-MANIFEST.txt b/DELIVERABLE-MANIFEST.txt index a90d20f..c3722eb 100644 --- a/DELIVERABLE-MANIFEST.txt +++ b/DELIVERABLE-MANIFEST.txt @@ -1,7 +1,7 @@ DP World Smart Solutions — Web Configurator -파트너 전달물 — v1.12.3 (이전 전달 v1.12.1 이후 v1.12.2·v1.12.3 반영 — 상세: RELEASE-NOTES.md §0) +파트너 전달물 — v1.12.4 (이전 전달 v1.12.1 이후 v1.12.2·v1.12.3·v1.12.4 반영 — 상세: RELEASE-NOTES.md §0) BSP 이미지 포함용 클린 스냅샷. 커밋 히스토리 없음, 내부 전용 자료 없음. -일자: 2026-07-01 +일자: 2026-07-09 ================================================================================ 이 패키지는 무엇인가 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 1fe5640..3a76b81 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,15 +1,23 @@ -# 릴리스 노트 — Web Configurator **v1.12.3** +# 릴리스 노트 — Web Configurator **v1.12.4** -- **배포일**: 2026-07-01 +- **배포일**: 2026-07-09 - **대상**: 디바이스 BSP 이미지 포함용 (DP World Smart Solutions · IoT 디바이스) -- **패키지**: 내부 git 태그 `v1.12.3` (커밋 히스토리가 없는 **클린 스냅샷**) -- **이전 전달**: v1.12.1 — 본 전달은 v1.12.2·v1.12.3 변경을 함께 포함합니다. +- **패키지**: 내부 git 태그 `v1.12.4` (커밋 히스토리가 없는 **클린 스냅샷**) +- **이전 전달**: v1.12.1 — 본 전달은 v1.12.2·v1.12.3·v1.12.4 변경을 함께 포함합니다. --- -## 0. v1.12.1 → v1.12.3 신규/변경 (이번 전달의 핵심) +## 0. v1.12.1 → v1.12.4 신규/변경 (이번 전달의 핵심) -이번 전달은 v1.12.1 위에 **v1.12.2(신규/빈 디바이스 DB-init 안정화)** 와 **v1.12.3(Wi-Fi AP 채널 선택 + 네트워크 즉시적용 실측 검증)** 을 함께 반영한 것입니다. +이번 전달은 v1.12.1 위에 **v1.12.2·v1.12.3·v1.12.4** 를 함께 반영한 것입니다. + +### v1.12.4 — 프로토콜 레지스터 필드를 디바이스 계약에 정합 (Modbus/OPC-UA) +- **Modbus `idt` 공백형 저장**: 레지스터 입력 데이터타입을 디바이스 config-reader 가 기대하는 공백형으로 저장합니다(`unsignedInteger`→`unsigned integer`, `unsignedShort`→`unsigned short`). UI 라벨은 그대로 두고 저장 시에만 변환하며, 로드 시 역변환합니다. 이전에는 camelCase 로 저장돼 **Modbus 설정 전체가 로드 실패**할 수 있었습니다. +- **Modbus·OPC-UA 폼에서 `Shift`/`Expr`/`Mask` 제거**: 해당 프로토콜에서 디바이스가 사용하지 않는 필드입니다(`Expr` 은 저장되나 값에 미적용, `Shift`/`Mask` 는 레지스터 필드가 아님). **CAN 은 유지**(CAN 에서는 실제 적용됨). +- **ODT `string`**: Modbus·CAN 에서 제거(디바이스가 Modbus 는 거부, CAN 은 출력 없음), OPC-UA 는 유지(완전 지원). 잘못된 저장은 400 으로 거부. +- 기존 디바이스 DB 의 위 표기는 **부팅 시 1회 자동 정합**(멱등 마이그레이션, 이전 계약-정합 마이그레이션을 거친 디바이스에서도 실행되도록 별도 플래그). +- dpworldapp 공유 DB·계약·타입 무변경(레지스터 표기 정합만). 설치 경로(`/usr/lib/web-configurator`)·기본 포트(`:9090`) 유지. +- **레지스터 테이블 UI 정돈**: 3개 매핑 표(Modbus/OPC-UA/CAN)의 컬럼 헤더를 축약어 없이 풀네임으로 통일(`Output Type`·`Default Value`·`Input Type`·`Namespace`·`Node ID`·`Address`). CAN Baud Rate 드롭다운에서 미사용 `NONE` 항목을 표시에서 숨김(데이터 계약·firmware 값 검증은 유지). ### v1.12.2 — 신규/빈 디바이스 DB-init 안정화 (fail-soft) 펌웨어 업데이트 직후 동기화 문제로 `device_config`/`protocol_config` 가 비어 있을 때, **웹이 그 값을 생성·덮어쓰지 않도록** 하여 dpworldapp 의 시딩을 선점하지 않습니다. 단일 판정(`config_initialized`)을 아래에 일관 적용합니다. diff --git a/VERSION b/VERSION index 00f8626..21decde 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.12.3 +v1.12.4 diff --git a/src/config_validator.py b/src/config_validator.py index 53e4226..6b1c380 100644 --- a/src/config_validator.py +++ b/src/config_validator.py @@ -54,6 +54,25 @@ _REGISTER_MAX_ENTRIES = 30 # v2: reader truncates at 30 _REGISTER_STR_MAX_LEN = 64 # mask / id 문자열 상한 _REGISTER_SHIFT_RANGE = range(0, 64) # 0-63 inclusive +# 2026-07-08: the device config reader expects idt as space-separated lowercase +# (case-insensitive). The UI/Java emit camelCase for the two 'unsigned*' tokens +# (and legacy 'float64' for idt), which the device does not accept, causing it to +# reject the whole Modbus config. Canonicalize on store so the stored config +# matches the reader contract. Only the drifting tokens are mapped; matching +# tokens pass through. +_IDT_NATIVE_CANONICAL = { + "unsignedinteger": "unsigned integer", + "unsignedshort": "unsigned short", + "float64": "float", +} +# The device does not apply expr for Modbus/OPC-UA (the field is accepted but +# has no effect on the read value). Force a guaranteed-valid identity so the +# field is always present and valid. +_UNUSED_EXPR_DEFAULT = "x" +# odt='string': the device rejects it for Modbus (whole-config fail) and +# produces no telemetry for CAN (silent trap). OPC-UA fully supports string. +_ODT_STRING_UNSUPPORTED_PROTOCOLS = frozenset({"MODBUS", "CAN"}) + # v1.4.6.7 C-2: defense in depth — frontend convertNestedToFlat이 ports를 Integer로 emit # (state.js fix와 정합). 외부 도구 / Java app POST 회귀 / MMI 직접 입력에서 string port가 @@ -179,6 +198,52 @@ def process_protocol_config(data): if "CAN" not in result or result["CAN"] is None: result["CAN"] = [] + # NOTE: canonicalization of register entries is NOT done here. This function runs + # on the MERGED result (preserved + incoming), and canonicalizing the merged view + # would rewrite preserved/inactive arrays a partial POST never touched (breaking the + # C1 partial-POST preservation invariant). Canonicalization is applied to the + # INCOMING data in the POST handler (server.py) and once to existing data by + # migrate_protocol_field_cleanup. + return result + + +def _canonicalize_idt(value): + """Map a drifting idt token to the native space form (case-insensitive). + Non-string or already-canonical values pass through unchanged.""" + if not isinstance(value, str): + return value + return _IDT_NATIVE_CANONICAL.get(value.strip().lower(), value) + + +def canonicalize_protocol_entries(protocol_cfg): + """Align stored MODBUS / OPC_UA register entries with the device config-reader + contract. Returns a NEW dict; does not mutate the input. + + - MODBUS: canonicalize idt to the native space form; drop shift/mask + (not part of the device's Modbus register fields); force expr to the identity default. + - OPC_UA: drop shift/mask; force expr to the identity default. + - CAN: untouched - the device applies its shift/expr/mask. + """ + if not isinstance(protocol_cfg, dict): + return protocol_cfg + result = dict(protocol_cfg) + for arr_key, has_idt in (("MODBUS", True), ("OPC_UA", False)): + arr = result.get(arr_key) + if not isinstance(arr, list): + continue + new_arr = [] + for entry in arr: + if not isinstance(entry, dict): + new_arr.append(entry) + continue + e = dict(entry) + if has_idt and "idt" in e: + e["idt"] = _canonicalize_idt(e["idt"]) + e.pop("shift", None) + e.pop("mask", None) + e["expr"] = _UNUSED_EXPR_DEFAULT + new_arr.append(e) + result[arr_key] = new_arr return result @@ -525,7 +590,8 @@ def _is_numeric_value(v): return False -def _validate_register_entry(entry, label, errors, require_addr=False, require_id=False): +def _validate_register_entry(entry, label, errors, require_addr=False, require_id=False, + protocol_key=None): """Per-entry field validation for a single register mapping object.""" if not isinstance(entry, dict): errors.append(f"{label} must be an object") @@ -595,6 +661,13 @@ def _validate_register_entry(entry, label, errors, require_addr=False, require_i _odt_normalized = _odt_lower if _odt_normalized not in _REGISTER_ODT_VALUES: errors.append(f"{label}.odt={odt!r} not in reader odt set") + elif (_odt_normalized == "string" + and protocol_key in _ODT_STRING_UNSUPPORTED_PROTOCOLS): + # the device rejects Modbus odt=string (whole-config fail) and emits + # no telemetry for CAN string; only OPC-UA supports it. + errors.append( + f"{label}.odt='string' not supported for {protocol_key} " + f"(device rejects it / produces no output)") # dv — numeric normally; arbitrary string allowed when odt=string dv = entry.get("dv") @@ -655,6 +728,7 @@ def validate_register_entries(data, merged_data=None): _validate_register_entry( entry, f"{arr_key} entry {i}", errors, require_addr=require_addr, require_id=require_id, + protocol_key=arr_key, ) return errors diff --git a/src/firmware/config_safety.py b/src/firmware/config_safety.py index 095332d..4c732b9 100644 --- a/src/firmware/config_safety.py +++ b/src/firmware/config_safety.py @@ -203,8 +203,17 @@ def detect_and_restore(db_path, default_file_path, backups_dir): if os.path.isfile(proto): with open(proto, "r", encoding="utf-8") as f: proto_value = f.read() - if _as_dict(proto_value) is None: + proto_dict = _as_dict(proto_value) + if proto_dict is None: return RestoreResult(False, "backup protocol_config invalid", backup) + # 2026-07-08: a backup captured from a pre-feature device carries camelCase + # Modbus idt (and dead shift/mask/legacy expr). Restoring it verbatim and then + # restarting dpworldapp (fw_controller) would write a config the device + # rejects (config-load fail -> telemetry stops), and the startup migration + # already ran this boot so it never self-heals. Canonicalize on this third + # protocol_config write path too (besides the POST handler and the migration). + from config_validator import canonicalize_protocol_entries + proto_value = json.dumps(canonicalize_protocol_entries(proto_dict)) # Both validated — now write atomically. # v1.5.4.4 STAB-1 fix: 이전엔 두 _write 호출이 별도 connection / commit이라 # 두 번째 write 실패 시 device_config만 복원된 zombie state가 발생할 수 있었다. diff --git a/src/migrations.py b/src/migrations.py index f5e469d..7600e9d 100644 --- a/src/migrations.py +++ b/src/migrations.py @@ -193,6 +193,28 @@ def migrate_contract_canonical_protocol(db) -> bool: return True +def migrate_protocol_field_cleanup(db) -> bool: + """Canonicalize protocol_config MODBUS/OPC_UA register entries to the device + config-reader contract (idt space form, drop shift/mask, identity expr). CAN + untouched. Uses its own flag so it runs even on devices already past the + contract_canonical migration. Defers until protocol_config is seeded.""" + if db.get_schema_meta("protocol_field_cleanup_migrated") == "true": + return False + if _existing_config(db, "protocol_config") is None: + return False + + from config_validator import canonicalize_protocol_entries + + def _mut(cur): + if not isinstance(cur, dict) or not cur: + return cur + return canonicalize_protocol_entries(cur) + + db.update_config("protocol_config", _mut) + db.set_schema_meta("protocol_field_cleanup_migrated", "true") + return True + + # AP: seed — begin def migrate_seed_ap_config(db) -> bool: """ap_config 키 부재 시 기본값 1회 seed (spec §5.1). 존재하면 no-op.""" @@ -210,6 +232,7 @@ _MIGRATIONS = ( ("seed_ap_config", migrate_seed_ap_config), # AP: seed ("contract_canonical_device", migrate_contract_canonical_device), # v3 split ("contract_canonical_protocol", migrate_contract_canonical_protocol), # v3 split + ("protocol_field_cleanup", migrate_protocol_field_cleanup), ) diff --git a/src/server.py b/src/server.py index 59ac4be..fda76a3 100644 --- a/src/server.py +++ b/src/server.py @@ -39,6 +39,7 @@ from config_validator import ( normalize_device_port_types, validate_device_port_types_hard, process_protocol_config, + canonicalize_protocol_entries, ensure_meid_string, validate_device_config, validate_protocol_config, @@ -1031,6 +1032,14 @@ class ConfigHandler(BaseHTTPRequestHandler): # Strip None values cleaned = strip_none_values(data) + # 2026-07-08: canonicalize the arrays being WRITTEN (idt space-form, + # drop shift/mask, identity expr for Modbus/OPC-UA). Applied to the + # incoming data only, so preserved/inactive arrays (merged from the DB + # inside _merge_protocol) stay byte-for-byte unchanged (C1 partial-POST + # preservation invariant). Legacy stored data is canonicalized once by + # migrate_protocol_field_cleanup at startup. + cleaned = canonicalize_protocol_entries(cleaned) + # v1.5.1 T2: atomic RMW via update_config (H1/H2/H6 race fix). # Replaces the get_config + merge + save_config triple with a single # atomic BEGIN IMMEDIATE transaction so concurrent POSTs cannot lose diff --git a/src/static/js/constants.js b/src/static/js/constants.js index f429091..1d4992d 100644 --- a/src/static/js/constants.js +++ b/src/static/js/constants.js @@ -9,7 +9,7 @@ * Bump APP_VERSION on every release and add a matching CHANGELOG.md entry. */ export const APP_NAME = 'DP World Smart Solutions'; -export const APP_VERSION = 'v1.12.3'; +export const APP_VERSION = 'v1.12.4'; /** * v1.7.0 — pageId → 표시명 (Pending Changes 패널·배지에서 사용). @@ -93,6 +93,20 @@ export const CAN_BAUDRATE_LABELS = { 1000: '1M/s', }; export const RS485_BAUDRATES = [4800, 9600, 19200, 38400, 57600, 115200]; -// v2: 'float' (idt); reader accepts (case-insensitive) float not float64. ODT_OPTIONS unchanged. +// v2: 'float' (idt); reader accepts (case-insensitive) float not float64. export const IDT_OPTIONS = ['integer', 'float', 'boolean', 'unsignedInteger', 'short', 'unsignedShort']; -export const ODT_OPTIONS = ['integer', 'float64', 'boolean', 'string']; + +// 2026-07-08 protocol-field-cleanup: per-protocol output-data-type lists. A single +// shared list previously let 'string' leak into Modbus/CAN, which the device +// rejects for Modbus (whole-config fail) or does not emit for CAN (no telemetry). +// OPC-UA fully supports string. +export const OPCUA_ODT_OPTIONS = ['integer', 'float64', 'boolean', 'string']; +export const MODBUS_ODT_OPTIONS = ['integer', 'float64', 'boolean']; +export const CAN_ODT_OPTIONS = ['integer', 'float64', 'boolean']; + +// idt display alias: a stored native space-form re-selects the friendly camelCase +// dropdown option on load (Modbus register entries only carry idt). +export const IDT_DISPLAY_ALIAS = { + 'unsigned integer': 'unsignedInteger', + 'unsigned short': 'unsignedShort', +}; diff --git a/src/static/js/pages/can-bus.js b/src/static/js/pages/can-bus.js index ccc1771..7a39761 100644 --- a/src/static/js/pages/can-bus.js +++ b/src/static/js/pages/can-bus.js @@ -7,14 +7,14 @@ import { state } from '../state.js'; import { populateCrudTable } from '../components/crud-table.js'; -import { CAN_BAUDRATES, CAN_BAUDRATE_LABELS, ODT_OPTIONS } from '../constants.js'; +import { CAN_BAUDRATES, CAN_BAUDRATE_LABELS, CAN_ODT_OPTIONS } from '../constants.js'; import { icon } from '../icons.js'; -const CAN_COLUMNS = [ +export const CAN_COLUMNS = [ { key: 'field', label: 'Field', placeholder: 'CAN1', maxLength: 4 }, { key: 'id', label: 'CAN ID', placeholder: '0x18FEFC28' }, - { key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS }, - { key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' }, + { key: 'odt', label: 'Output Type', type: 'select', options: CAN_ODT_OPTIONS }, + { key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '90px' }, { key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' }, { key: 'expr', label: 'Expr', placeholder: 'x*0.05' }, { key: 'mask', label: 'Mask', placeholder: '0xff' }, @@ -50,7 +50,7 @@ const canBusPage = {
@@ -73,8 +73,8 @@ const canBusPage = { Field CAN ID - ODT - DV + Output Type + Default Value Shift Expr Mask diff --git a/src/static/js/pages/can.js b/src/static/js/pages/can.js index 308ddd7..ad48221 100644 --- a/src/static/js/pages/can.js +++ b/src/static/js/pages/can.js @@ -10,13 +10,13 @@ import { state } from '../state.js'; import { populateCrudTable } from '../components/crud-table.js'; -import { ODT_OPTIONS } from '../constants.js'; +import { CAN_ODT_OPTIONS } from '../constants.js'; const CAN_COLUMNS = [ { key: 'field', label: 'Field', placeholder: 'CAN1', maxLength: 4 }, { key: 'id', label: 'CAN ID', placeholder: '0x18FEFC28' }, - { key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS }, - { key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' }, + { key: 'odt', label: 'Output Type', type: 'select', options: CAN_ODT_OPTIONS }, + { key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '90px' }, { key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' }, { key: 'expr', label: 'Expr', placeholder: 'x*0.05' }, { key: 'mask', label: 'Mask', placeholder: '0xff' }, @@ -49,8 +49,8 @@ export function renderCanPage(container) { Field CAN ID - ODT - DV + Output Type + Default Value Shift Expr Mask diff --git a/src/static/js/pages/modbus.js b/src/static/js/pages/modbus.js index 04374c7..7b05503 100644 --- a/src/static/js/pages/modbus.js +++ b/src/static/js/pages/modbus.js @@ -9,7 +9,8 @@ * (LITTLE("little") / BIG("big")) 복구. Byte Order card에 two_byte_order * + four_byte_order 두 dropdown 모두 추가. * - * Columns: Field, Addr, IDT, ODT, DV, Shift, Expr, Mask + * Columns: Field, Addr, IDT, ODT, DV + * (shift/expr/mask removed 2026-07-08 - native Modbus ignores them; see spec.) * * Data source: state.protocol.MODBUS (array), state.device.server.*, * state.protocol.two_byte_order, state.protocol.four_byte_order @@ -17,21 +18,18 @@ import { state, bindIpGroup, bindInput } from '../state.js'; import { populateCrudTable } from '../components/crud-table.js'; -import { IDT_OPTIONS, ODT_OPTIONS } from '../constants.js'; +import { IDT_OPTIONS, MODBUS_ODT_OPTIONS } from '../constants.js'; import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js'; import { setupPortInputs } from '../components/port-input.js'; import { escapeHtml } from '../utils.js'; import { icon } from '../icons.js'; -const MODBUS_COLUMNS = [ +export const MODBUS_COLUMNS = [ { key: 'field', label: 'Field', placeholder: 'TEMP', maxLength: 4 }, - { key: 'addr', label: 'Addr', placeholder: '100' }, - { key: 'idt', label: 'IDT', type: 'select', options: IDT_OPTIONS }, - { key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS }, - { key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' }, - { key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' }, - { key: 'expr', label: 'Expr', placeholder: 'x*0.1' }, - { key: 'mask', label: 'Mask', placeholder: '0xffff' }, + { key: 'addr', label: 'Address', placeholder: '100', width: '90px' }, + { key: 'idt', label: 'Input Type', type: 'select', options: IDT_OPTIONS }, + { key: 'odt', label: 'Output Type', type: 'select', options: MODBUS_ODT_OPTIONS }, + { key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '100px' }, ]; let _modbusTable = null; @@ -107,13 +105,10 @@ export function renderModbusPage(container) { Field - Addr - IDT - ODT - DV - Shift - Expr - Mask + Address + Input Type + Output Type + Default Value @@ -132,12 +127,9 @@ export function renderModbusPage(container) {
Field — Data field name
Addr — Modbus Register address
-
IDT — Input Data Type (integer / float64 / boolean / unsignedInteger / short / unsignedShort)
-
ODT — Output Data Type
+
IDT — Input Data Type (integer / float / boolean / unsignedInteger / short / unsignedShort)
+
ODT — Output Data Type (integer / float64 / boolean)
DV — Default Value (used on connection failure)
-
Shift — Bit shift value
-
Expr — Conversion expression (e.g. x*0.1)
-
Mask — Bitmask (e.g. 0xffff)
diff --git a/src/static/js/pages/opcua.js b/src/static/js/pages/opcua.js index f65fa8b..4e6cce4 100644 --- a/src/static/js/pages/opcua.js +++ b/src/static/js/pages/opcua.js @@ -4,7 +4,8 @@ * v1.5.0 Phase 3: Server Endpoint card 추가 (opc_ua_server_ip + opc_ua_server_port) * + 기존 OPC-UA Node Mapping table 유지. * - * Columns: Field, NS (Namespace), Addr (Node ID), ODT, DV, Shift, Expr, Mask + * Columns: Field, NS (Namespace), Addr (Node ID), ODT, DV + * (shift/expr/mask removed 2026-07-08 - native OPC-UA ignores them; see spec.) * Note: No IDT column (differs from MODBUS) * * Data source: state.protocol.OPC_UA (array), state.device.server.* @@ -12,21 +13,18 @@ import { state, bindIpGroup, bindInput } from '../state.js'; import { populateCrudTable } from '../components/crud-table.js'; -import { ODT_OPTIONS } from '../constants.js'; +import { OPCUA_ODT_OPTIONS } from '../constants.js'; import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js'; import { setupPortInputs } from '../components/port-input.js'; import { escapeHtml } from '../utils.js'; import { icon } from '../icons.js'; -const OPCUA_COLUMNS = [ +export const OPCUA_COLUMNS = [ { key: 'field', label: 'Field', placeholder: 'TMP1', maxLength: 4 }, - { key: 'ns', label: 'NS', placeholder: '3', width: '60px' }, - { key: 'addr', label: 'Addr', placeholder: '1001' }, - { key: 'odt', label: 'ODT', type: 'select', options: ODT_OPTIONS }, - { key: 'dv', label: 'DV', placeholder: '-9', defaultValue: '-9', width: '60px' }, - { key: 'shift', label: 'Shift', placeholder: '0', defaultValue: '0', width: '60px' }, - { key: 'expr', label: 'Expr', placeholder: 'x*10' }, - { key: 'mask', label: 'Mask', placeholder: '0xffff' }, + { key: 'ns', label: 'Namespace', placeholder: '3', width: '80px' }, + { key: 'addr', label: 'Node ID', placeholder: '1001', width: '90px' }, + { key: 'odt', label: 'Output Type', type: 'select', options: OPCUA_ODT_OPTIONS }, + { key: 'dv', label: 'Default Value', placeholder: '-9', defaultValue: '-9', width: '100px' }, ]; let _opcuaTable = null; @@ -75,13 +73,10 @@ export function renderOpcuaPage(container) { Field - NS - Addr - ODT - DV - Shift - Expr - Mask + Namespace + Node ID + Output Type + Default Value diff --git a/src/static/js/state.js b/src/static/js/state.js index d00efe4..dfa8799 100644 --- a/src/static/js/state.js +++ b/src/static/js/state.js @@ -5,7 +5,7 @@ * All page modules read from and write to this state. */ -import { DEFAULTS } from './constants.js'; +import { DEFAULTS, IDT_DISPLAY_ALIAS } from './constants.js'; const DEBUG = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); @@ -261,6 +261,15 @@ export function setProtocol(data) { const lookup = String(data.protocol).toUpperCase().replace(/[-\s]+/g, '_'); data.protocol = PROTOCOL_VALUE_ALIAS[lookup] ?? data.protocol; } + // 2026-07-08: a stored device-format idt (space form, e.g. 'unsigned integer') re-selects + // the friendly camelCase dropdown option. Only Modbus entries carry idt. + if (Array.isArray(data.MODBUS)) { + data.MODBUS.forEach(entry => { + if (entry && typeof entry.idt === 'string' && IDT_DISPLAY_ALIAS[entry.idt]) { + entry.idt = IDT_DISPLAY_ALIAS[entry.idt]; + } + }); + } } state.protocol = data || createDefaultProtocol(); _originalProtocol = _deepClone(state.protocol); @@ -723,15 +732,15 @@ export function buildDevicePayload() { */ export function buildProtocolPayload() { const payload = state.protocol ? { ...state.protocol } : createDefaultProtocol(); - // Ensure shift values are always strings (legacy compatibility) - ['CAN', 'MODBUS', 'OPC_UA'].forEach(key => { - if (Array.isArray(payload[key])) { - payload[key] = payload[key].map(row => ({ - ...row, - shift: row.shift != null ? String(row.shift) : '0', - })); - } - }); + // Ensure CAN shift values are strings (legacy compatibility). Modbus/OPC-UA no + // longer carry shift (removed 2026-07-08); the backend chokepoint strips any + // that slip through, so we must NOT re-inject shift='0' into their rows here. + if (Array.isArray(payload.CAN)) { + payload.CAN = payload.CAN.map(row => ({ + ...row, + shift: row.shift != null ? String(row.shift) : '0', + })); + } return payload; }