Browse Source

Release v1.12.4 - 레지스터 필드 디바이스 계약 정합 + 테이블 UI 정돈 (Modbus/OPC-UA/CAN)

release/v1.12.3
Hoseok(Jose) LEE 4 weeks ago
parent
commit
3e99bdcad8
  1. 12
      CHANGELOG.md
  2. 4
      DELIVERABLE-MANIFEST.txt
  3. 20
      RELEASE-NOTES.md
  4. 2
      VERSION
  5. 76
      src/config_validator.py
  6. 11
      src/firmware/config_safety.py
  7. 23
      src/migrations.py
  8. 9
      src/server.py
  9. 20
      src/static/js/constants.js
  10. 14
      src/static/js/pages/can-bus.js
  11. 10
      src/static/js/pages/can.js
  12. 36
      src/static/js/pages/modbus.js
  13. 29
      src/static/js/pages/opcua.js
  14. 21
      src/static/js/state.js

12
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 설정 무접촉. 워치독 자가복원 + 관리 인터페이스 보호.

4
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
================================================================================
이 패키지는 무엇인가

20
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`)을 아래에 일관 적용합니다.

2
VERSION

@ -1 +1 @@
v1.12.3
v1.12.4

76
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

11
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가 발생할 수 있었다.

23
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),
)

9
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

20
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',
};

14
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 = {
<div class="form-group">
<label class="form-label">Baud Rate</label>
<select class="form-select" id="can_speed">
${CAN_BAUDRATES.map(v => `<option value="${v}" ${String(can.speed) === String(v) ? 'selected' : ''}>${CAN_BAUDRATE_LABELS[v] || v}</option>`).join('')}
${CAN_BAUDRATES.filter(v => v !== 'NONE').map(v => `<option value="${v}" ${String(can.speed) === String(v) ? 'selected' : ''}>${CAN_BAUDRATE_LABELS[v] || v}</option>`).join('')}
</select>
</div>
</div>
@ -73,8 +73,8 @@ const canBusPage = {
<tr>
<th style="width:90px;">Field</th>
<th style="width:120px;">CAN ID</th>
<th style="width:100px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:110px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th style="width:60px;">Shift</th>
<th style="width:150px;">Expr</th>
<th style="width:80px;">Mask</th>

10
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) {
<tr>
<th style="width:90px;">Field</th>
<th style="width:120px;">CAN ID</th>
<th style="width:100px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:110px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th style="width:60px;">Shift</th>
<th style="width:150px;">Expr</th>
<th style="width:80px;">Mask</th>

36
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) {
<thead>
<tr>
<th style="width:90px;">Field</th>
<th style="width:70px;">Addr</th>
<th style="width:120px;">IDT</th>
<th style="width:120px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:60px;">Shift</th>
<th style="width:100px;">Expr</th>
<th style="width:80px;">Mask</th>
<th style="width:100px;">Address</th>
<th style="width:140px;">Input Type</th>
<th style="width:120px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th class="col-action"></th>
</tr>
</thead>
@ -132,12 +127,9 @@ export function renderModbusPage(container) {
<div class="legend-grid">
<div><strong>Field</strong> Data field name</div>
<div><strong>Addr</strong> Modbus Register address</div>
<div><strong>IDT</strong> — Input Data Type (integer / float64 / boolean / unsignedInteger / short / unsignedShort)</div>
<div><strong>ODT</strong> Output Data Type</div>
<div><strong>IDT</strong> — Input Data Type (integer / float / boolean / unsignedInteger / short / unsignedShort)</div>
<div><strong>ODT</strong> — Output Data Type (integer / float64 / boolean)</div>
<div><strong>DV</strong> Default Value (used on connection failure)</div>
<div><strong>Shift</strong> Bit shift value</div>
<div><strong>Expr</strong> Conversion expression (e.g. x*0.1)</div>
<div><strong>Mask</strong> Bitmask (e.g. 0xffff)</div>
</div>
</div>
</div>

29
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) {
<thead>
<tr>
<th style="width:90px;">Field</th>
<th style="width:60px;">NS</th>
<th style="width:80px;">Addr</th>
<th style="width:120px;">ODT</th>
<th style="width:60px;">DV</th>
<th style="width:60px;">Shift</th>
<th style="width:120px;">Expr</th>
<th style="width:80px;">Mask</th>
<th style="width:100px;">Namespace</th>
<th style="width:100px;">Node ID</th>
<th style="width:120px;">Output Type</th>
<th style="width:120px;">Default Value</th>
<th class="col-action"></th>
</tr>
</thead>

21
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 => ({
// 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;
}

Loading…
Cancel
Save