/** * state.js — Application State Management * * Central shared state for device and protocol configuration. * All page modules read from and write to this state. */ import { DEFAULTS } from './constants.js'; const DEBUG = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1'); // Current loaded configs export const state = { device: null, protocol: null, currentPage: 'ssid', isDirty: false, /** B6: set true when loadAllData catches an error; cleared on successful load. * handleSaveAll refuses to run when true to prevent empty-defaults from wiping real DB. */ configLoadFailed: false, /** * v1.5.0 P1: per-page dirty matrix. * Phase 1: 9 기존 page id + firmware placeholder. * Phase 2-3에서 sensor-io / wifi / ethernet 등 신규 leaf 추가 시 entry 추가. */ pageDirty: { home: false, wifi: false, // v1.5.0 P2 NEW 'wifi-ap': false, // AP: self-contained config page ssid: false, // legacy alias ethernet: false, // v1.5.0 P2 NEW (Task 2) io: false, // legacy + RS485/CAN 잔존 'server-setting': false, // v1.5.0 P2 NEW (Task 3) network: false, // legacy alias general: false, // v1.5.0 P3 NEW 'sensor-io': false, // v1.5.0 P3 NEW 'can-bus': false, // v1.5.0 P3 NEW register: false, // legacy alias (PAGES) can: false, // legacy alias (PAGES) opcua: false, modbus: false, log: false, firmware: false, 'net-apply': false, // v1.6.0 uplink: false, // Telemetry Uplink — self-contained Save&Apply }, }; /** * Detect if data is in flat format (from original Java app.jar). * Flat format uses keys like wifi_ip, protocol_server_ip, WIFI_SSID etc. */ function isFlatDeviceFormat(data) { if (!data || typeof data !== 'object') return false; // Check for characteristic flat keys that don't exist in nested format return ('wifi_ip' in data || 'eth_ip' in data || 'protocol_server_ip' in data || 'WIFI_SSID' in data || 'wifi_static' in data || 'can_bus_type' in data || 'can_baudrate' in data || 'lte_server_ip' in data); } /** * Convert flat device config (Java app.jar format) to nested format (Web UI format). * * Flat keys: wifi_ip, protocol_server_ip, can_baudrate, WIFI_SSID, ... * Nested: { wifi: { ip }, server: { protocol_server_ip }, can: { speed }, ssid_list, ... } */ function convertFlatToNested(flat) { const device = createDefaultDevice(); // ── WiFi ── const wifiStatic = flat.wifi_static; device.wifi = { type: (wifiStatic === 'on') ? 'static' : 'dhcp', ip: flat.wifi_ip || '', netmask: flat.wifi_netmask || '', gateway: flat.wifi_gateway || '', dns1: flat.wifi_dns1 || '', dns2: flat.wifi_dns2 || '', country_code: (flat.wifi_country_code || DEFAULTS.WIFI_COUNTRY_CODE).toString().replace(/[^a-zA-Z]/g, '').slice(0, 2).toUpperCase() || DEFAULTS.WIFI_COUNTRY_CODE, }; // ── SSID List ── (WIFI_SSID → ssid_list with key rename) if (Array.isArray(flat.WIFI_SSID)) { device.ssid_list = flat.WIFI_SSID.map(entry => ({ ssid: entry.wifi_ssid || entry.ssid || '', password: entry.wifi_passwd || entry.password || '', security: entry.wifi_security || entry.security || 'wpa/wpa2', })); } // ── Ethernet ── device.eth = { ip: flat.eth_ip || '', netmask: flat.eth_netmask || '', gateway: flat.eth_gateway || '', }; // ── Server Endpoints (kept as flat sub-keys under server) ── device.server = {}; const serverKeys = [ 'protocol_server_ip', 'protocol_server_port', 'update_server_ip', 'update_server_port', 'rtcm_server_ip', 'rtcm_server_port', 'opc_ua_server_ip', 'opc_ua_server_port', 'modbus_server_ip', 'modbus_server_port', 'lte_server_ip', 'lte_server_port', ]; serverKeys.forEach(k => { if (flat[k] !== undefined) device.server[k] = flat[k]; }); // ── CAN Bus ── device.can = { type: flat.can_bus_type || 'extended', speed: flat.can_baudrate != null ? String(flat.can_baudrate) : 'NONE', }; // ── RS485 ── device.rs485 = { mode: flat.rs485_mode || 'half', speed: flat.rs485_baudrate != null ? String(flat.rs485_baudrate) : '9600', // v1.4.5 F1: DB Integer → UI String 비교/표시 (form select) databits: flat.rs485_databits != null ? String(flat.rs485_databits) : '8', parity: flat.rs485_parity || 'none', stopbits: flat.rs485_stopbits != null ? String(flat.rs485_stopbits) : '1', }; // ── LTE ── device.lte = { ip: flat.lte_ip || '', netmask: flat.lte_netmask || '', gateway: flat.lte_gateway || '', port: flat.lte_port || '', }; // ── Log ── device.log = { save: flat.log_save || 'on', max_size: flat.log_max_size != null ? Number(flat.log_max_size) : 100, max_days: flat.log_max_duration != null ? Number(flat.log_max_duration) : 1, auto_compress: flat.log_auto_compress || 'off', // v1.4.6.9 L1: DEFAULTS 단일 소스 — sibling fields와 일관 compress_size_mb: flat.log_compress_size_mb != null ? Number(flat.log_compress_size_mb) : DEFAULTS.LOG_COMPRESS_SIZE_MB, compress_age_days: flat.log_compress_age_days != null ? Number(flat.log_compress_age_days) : DEFAULTS.LOG_COMPRESS_AGE_DAYS, auto_cleanup: flat.log_auto_cleanup || DEFAULTS.LOG_AUTO_CLEANUP, cleanup_max_files: flat.log_cleanup_max_files != null ? Number(flat.log_cleanup_max_files) : DEFAULTS.LOG_CLEANUP_MAX_FILES, cleanup_max_size_mb: flat.log_cleanup_max_size_mb != null ? Number(flat.log_cleanup_max_size_mb) : DEFAULTS.LOG_CLEANUP_MAX_SIZE_MB, }; return device; } /** * Update device config in state. * Auto-detects flat format from original Java app and converts to nested. * @param {Object} data */ /** * v1.5.5.6+ — Discard baseline snapshot. * setDevice/setProtocol 호출 시점의 state 를 deep clone 으로 보관. * discardChanges() 는 전체 복원, discardPageChanges(pageId) 는 page-owned slice 만 복원. * Save 성공 후 commitChangesAsBaseline() 이 이 snapshot 을 최신 저장값으로 갱신. */ let _originalDevice = null; let _originalProtocol = null; function _deepClone(v) { return v == null ? v : JSON.parse(JSON.stringify(v)); } export function setDevice(data) { if (DEBUG) console.log('[State] setDevice called with:', data ? Object.keys(data).slice(0, 10) : null); if (data && isFlatDeviceFormat(data)) { if (DEBUG) console.log('[State] Detected flat device config format, converting to nested...'); state.device = convertFlatToNested(data); // v1.4.6.9 M6: WIFI_SSID에 wifi_passwd가 포함 — 평문 password가 console로 누출되던 결함. // top-level 키 목록만 로깅 (구조 진단 충분, 비밀 정보 차단). if (DEBUG) console.log('[State] After conversion — keys:', Object.keys(state.device || {})); } else if (data && (data.wifi || data.ssid_list || data.eth)) { if (DEBUG) console.log('[State] Detected nested device config format (already converted)'); state.device = data; } else { if (DEBUG) console.log('[State] No data or unrecognized format, using defaults'); state.device = data || createDefaultDevice(); } // Snapshot — 이후 변경 / Discard 복원의 baseline _originalDevice = _deepClone(state.device); } /** * Map of old protocol key names → new key names. * Used for backward compatibility when loading legacy configs. */ const PROTOCOL_KEY_MAP = { equipment_type: 'dev_type', input_level: 'analog_input_level', dr: 'dr_on', heading: 'heading_on', heading_imu: 'heading_imu_on', fix_mode: 'fix_mode_on', byte_order_2: 'two_byte_order', byte_order_4: 'four_byte_order', }; /** * Map of protocol VALUE aliases → the canonical value the deployed dpworldapp * binary on .56 accepts. The legacy UI / imports may carry 'OPC-UA' (hyphen) or * 'OPCUA' (no separator); the device config-reader contract only accepts * 'OPC_UA' / 'MODBUS' / 'CAN_BUS' / 'NONE', so an un-normalized value silently * resets to 'NONE' on save. Keyed by an upper-cased, separator-collapsed form so * 'opc-ua', 'OPC UA', 'OPC_UA' all map identically. */ const PROTOCOL_VALUE_ALIAS = { 'OPC-UA': 'OPC_UA', 'OPCUA': 'OPC_UA', 'OPC_UA': 'OPC_UA', 'MODBUS': 'MODBUS', 'CAN_BUS': 'CAN_BUS', 'CANBUS': 'CAN_BUS', 'NONE': 'NONE', }; /** * Convert old protocol key names to new ones (in-place). * If the data already uses new keys, they are preserved. */ function normalizeProtocolKeys(data) { if (!data || typeof data !== 'object') return data; for (const [oldKey, newKey] of Object.entries(PROTOCOL_KEY_MAP)) { if (oldKey in data && !(newKey in data)) { data[newKey] = data[oldKey]; delete data[oldKey]; } else if (oldKey in data && newKey in data) { // new key takes precedence; just remove old key delete data[oldKey]; } } return data; } /** * Update protocol config in state. * Auto-detects old key names and converts to new format. * @param {Object} data */ export function setProtocol(data) { if (DEBUG) console.log('[State] setProtocol called with:', data ? Object.keys(data).slice(0, 10) : null); if (data) { normalizeProtocolKeys(data); // Normalize the protocol VALUE too — 'OPC-UA'/'OPCUA' → 'OPC_UA' (the canonical // value the deployed dpworldapp binary accepts), otherwise an un-canonical value // silently resets to 'NONE' on save. if (data.protocol) { const lookup = String(data.protocol).toUpperCase().replace(/[-\s]+/g, '_'); data.protocol = PROTOCOL_VALUE_ALIAS[lookup] ?? data.protocol; } } state.protocol = data || createDefaultProtocol(); _originalProtocol = _deepClone(state.protocol); } /** * Full restore helper kept for callers that intentionally want to drop every * unsaved page. Navigation Discard uses discardPageChanges(pageId) instead. */ export function discardChanges() { if (_originalDevice != null) { state.device = _deepClone(_originalDevice); } if (_originalProtocol != null) { state.protocol = _deepClone(_originalProtocol); } state.isDirty = false; } const _PAGE_ALIASES = { ssid: 'wifi', network: 'server-setting', register: 'general', can: 'can-bus', }; const _DEVICE_TOP_LEVEL_KEYS_BY_PAGE = { wifi: ['wifi', 'ssid_list'], ethernet: ['eth', 'lte'], io: ['can', 'rs485'], 'sensor-io': ['rs485'], 'can-bus': ['can'], log: ['log'], }; const _SERVER_KEYS_BY_PAGE = { 'server-setting': [ 'protocol_server_ip', 'protocol_server_port', 'update_server_ip', 'update_server_port', 'rtcm_server_ip', 'rtcm_server_port', 'lte_server_ip', 'lte_server_port', ], opcua: ['opc_ua_server_ip', 'opc_ua_server_port'], modbus: ['modbus_server_ip', 'modbus_server_port'], }; const _PROTOCOL_TOP_LEVEL_KEYS_BY_PAGE = { general: [ 'dev_type', 'equipment', 'equipment_id', 'MEID', 'version', 'protocol', 'can_input', 'dr_on', 'heading_on', 'heading_imu_on', 'fix_mode_on', 'speed_data', 'odo_on', 'odo_speed', 'odo_direction', ], 'sensor-io': ['analog_input_level', 'ai0', 'ai1', 'di0', 'di1'], 'can-bus': ['CAN'], opcua: ['OPC_UA'], modbus: ['MODBUS', 'two_byte_order', 'four_byte_order'], }; function _canonicalPageId(pageId) { return _PAGE_ALIASES[pageId] || pageId; } function _hasOwn(obj, key) { return !!obj && Object.prototype.hasOwnProperty.call(obj, key); } function _ensureObject(root, key) { if (!root[key] || typeof root[key] !== 'object' || Array.isArray(root[key])) { root[key] = {}; } return root[key]; } function _restoreTopLevelKeys(targetRoot, sourceRoot, keys) { if (!targetRoot || !keys) return false; keys.forEach(key => { if (_hasOwn(sourceRoot, key)) targetRoot[key] = _deepClone(sourceRoot[key]); else delete targetRoot[key]; }); return keys.length > 0; } function _restoreNestedKeys(targetRoot, sourceRoot, parentKey, keys) { if (!targetRoot || !keys) return false; const targetParent = _ensureObject(targetRoot, parentKey); const sourceParent = sourceRoot && sourceRoot[parentKey]; keys.forEach(key => { if (_hasOwn(sourceParent, key)) targetParent[key] = _deepClone(sourceParent[key]); else delete targetParent[key]; }); return keys.length > 0; } /** * Restore only the state slice owned by one page. * * Navigation Discard is page-scoped: if Wi-Fi and Server Setting are both dirty, * discarding Server Setting must not silently throw away Wi-Fi edits. */ export function discardPageChanges(pageId) { const page = _canonicalPageId(pageId); let restored = false; if (state.device) { restored = _restoreTopLevelKeys( state.device, _originalDevice || {}, _DEVICE_TOP_LEVEL_KEYS_BY_PAGE[page] ) || restored; restored = _restoreNestedKeys( state.device, _originalDevice || {}, 'server', _SERVER_KEYS_BY_PAGE[page] ) || restored; } if (state.protocol) { restored = _restoreTopLevelKeys( state.protocol, _originalProtocol || {}, _PROTOCOL_TOP_LEVEL_KEYS_BY_PAGE[page] ) || restored; } return restored; } /** * v1.5.5.6 — Save 성공 후 호출 — 현재 state 를 새 baseline 으로 채택. * 이후 Discard 는 이 시점으로 복원. (호출 안 하면 옛 load 시점으로 되돌아가 정합성 깨짐.) */ export function commitChangesAsBaseline() { _originalDevice = _deepClone(state.device); _originalProtocol = _deepClone(state.protocol); } // ─── v1.7.0: Pending field-diff (computeChanges) ──────────────────────────── // // Spec §3.3: baseline(_originalDevice/_originalProtocol) vs 현재(state.device/state.protocol) // 를 재귀 비교해 페이지 귀속 변경 목록 [{page, domain, field, old, new}] 을 반환. // 페이지 귀속은 기존 discardPageChanges 맵을 역인덱스해 단일 출처 재사용(drift 없음). const _SECRET_RE = /password|psk|passwd/i; const _MASK = '***'; // Legacy page ids that exist only for discardPageChanges (forward map) but must // NOT win attribution in the reverse index — the live nav has no such pages. const _LEGACY_PAGES = new Set(['io']); /** Reverse-index the page→keys maps so a changed key resolves to its owning page. */ function _buildReverseIndex() { const deviceTop = {}; // top-level device key → page const serverKey = {}; // server.* leaf key → page const protoTop = {}; // top-level protocol key → page for (const [page, keys] of Object.entries(_DEVICE_TOP_LEVEL_KEYS_BY_PAGE)) { if (_LEGACY_PAGES.has(page)) continue; // skip: sensor-io/can-bus must win keys.forEach(k => { if (!(k in deviceTop)) deviceTop[k] = page; }); } for (const [page, keys] of Object.entries(_SERVER_KEYS_BY_PAGE)) { keys.forEach(k => { if (!(k in serverKey)) serverKey[k] = page; }); } for (const [page, keys] of Object.entries(_PROTOCOL_TOP_LEVEL_KEYS_BY_PAGE)) { keys.forEach(k => { if (!(k in protoTop)) protoTop[k] = page; }); } return { deviceTop, serverKey, protoTop }; } const _REVERSE = _buildReverseIndex(); /** Page attribution for a device change given its top-level key (server.* uses leaf). */ function _devicePage(topKey, serverLeaf) { if (topKey === 'server' && serverLeaf) { return _REVERSE.serverKey[serverLeaf] || 'server-setting'; } return _REVERSE.deviceTop[topKey] || 'device'; } function _protocolPage(topKey) { return _REVERSE.protoTop[topKey] || 'general'; } function _isPlainObject(v) { return v != null && typeof v === 'object' && !Array.isArray(v); } /** Display value for a scalar (null/undefined → ''), masking applied by caller. */ function _scalarStr(v) { if (v == null) return ''; return String(v); } /** * Summarize an array change. Length change → "N→M entries", else "(내용 변경)". * Returns {old, new} display strings (never per-row deep diff — spec non-goal). */ function _arraySummary(oldArr, newArr) { const o = Array.isArray(oldArr) ? oldArr : []; const n = Array.isArray(newArr) ? newArr : []; if (o.length !== n.length) { return { old: `${o.length} entries`, new: `${n.length} entries` }; } return { old: '(modified)', new: '(modified)' }; } /** * Walk one config tree (baseline vs current), emitting changes for scalar leaves and * array fields. `attribute(pathKey, leafKey)` returns the owning page; `domain` tags rows. * Only top-level keys recurse one level (server.* nested); deeper nesting compares by * JSON equality at the leaf (covers log.* which is one level under device). * * field naming: top-level scalar → "key"; one-level nested → "parent.child"; array → "key". */ function _diffTree(baseline, current, domain, attributeTop, attributeNested) { const out = []; const base = _isPlainObject(baseline) ? baseline : {}; const cur = _isPlainObject(current) ? current : {}; const keys = new Set([...Object.keys(base), ...Object.keys(cur)]); for (const key of keys) { const bv = base[key]; const cv = cur[key]; if (Array.isArray(bv) || Array.isArray(cv)) { if (JSON.stringify(bv) !== JSON.stringify(cv)) { const page = attributeTop(key); const sum = _arraySummary(bv, cv); out.push({ page, domain, field: key, old: sum.old, new: sum.new }); } continue; } if (_isPlainObject(bv) || _isPlainObject(cv)) { // Recurse one level (e.g. device.server.*, device.wifi.*, device.log.*). const sub = _isPlainObject(bv) ? bv : {}; const subCur = _isPlainObject(cv) ? cv : {}; const subKeys = new Set([...Object.keys(sub), ...Object.keys(subCur)]); for (const sk of subKeys) { const sbv = sub[sk]; const scv = subCur[sk]; if (Array.isArray(sbv) || Array.isArray(scv)) { if (JSON.stringify(sbv) !== JSON.stringify(scv)) { const page = attributeNested(key, sk); const sum = _arraySummary(sbv, scv); out.push({ page, domain, field: `${key}.${sk}`, old: sum.old, new: sum.new }); } continue; } if (_isPlainObject(sbv) || _isPlainObject(scv)) { // Deeper than one level — compare by JSON equality, summarize. if (JSON.stringify(sbv) !== JSON.stringify(scv)) { const page = attributeNested(key, sk); out.push({ page, domain, field: `${key}.${sk}`, old: '(modified)', new: '(modified)' }); } continue; } if (_scalarStr(sbv) !== _scalarStr(scv)) { const field = `${key}.${sk}`; const page = attributeNested(key, sk); const masked = _SECRET_RE.test(field); out.push({ page, domain, field, old: masked ? _MASK : _scalarStr(sbv), new: masked ? _MASK : _scalarStr(scv), }); } } continue; } // scalar leaf if (_scalarStr(bv) !== _scalarStr(cv)) { const page = attributeTop(key); const masked = _SECRET_RE.test(key); out.push({ page, domain, field: key, old: masked ? _MASK : _scalarStr(bv), new: masked ? _MASK : _scalarStr(cv), }); } } return out; } /** * v1.7.0 — 전 도메인 필드별 변경 목록 (baseline vs 현재). * @returns {Array<{page:string, domain:string, field:string, old:string, new:string}>} */ export function computeChanges() { const changes = []; changes.push(..._diffTree( _originalDevice, state.device, 'device', (topKey) => _devicePage(topKey, null), (topKey, leafKey) => _devicePage(topKey, leafKey), )); changes.push(..._diffTree( _originalProtocol, state.protocol, 'protocol', (topKey) => _protocolPage(topKey), (topKey) => _protocolPage(topKey), )); return changes; } /** * Get top-level value from device config. * @param {string} key - Top-level property key (e.g. 'wifi', 'eth', 'server') * @returns {*} */ export function getDeviceValue(key) { if (!state.device) return undefined; return state.device[key]; } /** * Set nested value in device config. */ export function setDeviceValue(key, value) { if (!state.device) state.device = createDefaultDevice(); state.device[key] = value; state.isDirty = true; } /** * Get protocol config value. */ export function getProtocolValue(key) { if (!state.protocol) return undefined; return state.protocol[key]; } /** * Set protocol config value. */ export function setProtocolValue(key, value) { if (!state.protocol) state.protocol = createDefaultProtocol(); state.protocol[key] = value; state.isDirty = true; } /** * Convert nested device config (Web UI format) back to the flat format the * device config-reader contract expects (the format the legacy app (app.jar) and * the deployed dpworldapp binary both read). * This is the reverse of convertFlatToNested(). * Ensures data saved to SQLite DB is compatible with dpworldapp. * * IMPORTANT: All keys must ALWAYS be included (even if empty string/null). * The legacy app only skips null-valued keys on save, not empty strings. * Missing keys cause dpworldapp to lose data. */ function convertNestedToFlat(device) { const flat = {}; // ── WiFi ── const wifi = device.wifi || {}; // v1.3.1: matched to convertFlatToNested which treats absent // wifi_static as 'dhcp' — drop the wifi.type===undefined branch. flat.wifi_static = (wifi.type === 'static') ? 'on' : 'off'; flat.wifi_ip = wifi.ip || ''; flat.wifi_netmask = wifi.netmask || ''; flat.wifi_gateway = wifi.gateway || ''; flat.wifi_dns1 = wifi.dns1 || ''; flat.wifi_dns2 = wifi.dns2 || ''; // Region — WiFi regulatory domain (ISO 3166-1 alpha-2). Always emitted so a // Python-side save never drops the value the legacy Java app persisted. flat.wifi_country_code = (wifi.country_code || DEFAULTS.WIFI_COUNTRY_CODE).toString().replace(/[^a-zA-Z]/g, '').slice(0, 2).toUpperCase() || DEFAULTS.WIFI_COUNTRY_CODE; // ── Ethernet ── const eth = device.eth || {}; flat.eth_ip = eth.ip || ''; flat.eth_netmask = eth.netmask || ''; flat.eth_gateway = eth.gateway || ''; // ── Server Endpoints ── // v1.4.6.7 C-2: ports는 정수 schema 일치 (dha baseline 운영 ground truth // 확정 2026-06-04). 이전 string fallback이 운영 DB에 string 잔존 → 정수 parse fail → wipe 위험. const portToInt = (v) => { if (v == null || v === '') return null; // null → legacy app가 null 키 drop const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; }; const server = device.server || {}; flat.protocol_server_ip = server.protocol_server_ip || ''; flat.protocol_server_port = portToInt(server.protocol_server_port); flat.update_server_ip = server.update_server_ip || ''; flat.update_server_port = portToInt(server.update_server_port); flat.rtcm_server_ip = server.rtcm_server_ip || ''; flat.rtcm_server_port = portToInt(server.rtcm_server_port); flat.opc_ua_server_ip = server.opc_ua_server_ip || ''; flat.opc_ua_server_port = portToInt(server.opc_ua_server_port); flat.modbus_server_ip = server.modbus_server_ip || ''; flat.modbus_server_port = portToInt(server.modbus_server_port); flat.lte_server_ip = server.lte_server_ip || ''; flat.lte_server_port = portToInt(server.lte_server_port); // ── CAN Bus ── const can = device.can || {}; flat.can_bus_type = can.type || 'extended'; // NONE = no baudrate field (Old Configurator omits field when NONE) if (can.speed != null && can.speed !== 'NONE') { flat.can_baudrate = Number(can.speed) || can.speed; } // ── RS485 ── const rs485 = device.rs485 || {}; flat.rs485_mode = rs485.mode || 'half'; flat.rs485_baudrate = rs485.speed != null ? (Number(rs485.speed) || rs485.speed) : 9600; // v1.4.5.1 I2: 0 보존 (UI 옵션 / dha 일치) — Number||fallback falsy override 방지 flat.rs485_databits = rs485.databits != null ? Number(rs485.databits) : 8; flat.rs485_parity = rs485.parity || 'none'; flat.rs485_stopbits = rs485.stopbits != null ? Number(rs485.stopbits) : 1; // ── LTE ── const lte = device.lte || {}; flat.lte_ip = lte.ip || ''; flat.lte_netmask = lte.netmask || ''; flat.lte_gateway = lte.gateway || ''; flat.lte_port = lte.port || ''; // ── Log ── const log = device.log || {}; flat.log_save = log.save || 'on'; flat.log_max_size = log.max_size != null ? Number(log.max_size) : 100; flat.log_max_duration = log.max_days != null ? Number(log.max_days) : 1; flat.log_auto_compress = log.auto_compress || 'off'; // v1.4.6.9 H10: missing 시 key omit → backend partial-merge가 기존 log_config 값 보존. // state.device.log reset path (loadAllData 실패, createDefault, import partial)에서 // 50/7 fallback이 운영자 입력 값을 overwrite하던 wipe vector 차단. if (log.compress_size_mb != null) flat.log_compress_size_mb = Number(log.compress_size_mb); if (log.compress_age_days != null) flat.log_compress_age_days = Number(log.compress_age_days); flat.log_auto_cleanup = log.auto_cleanup || DEFAULTS.LOG_AUTO_CLEANUP; flat.log_cleanup_max_files = log.cleanup_max_files != null ? Number(log.cleanup_max_files) : DEFAULTS.LOG_CLEANUP_MAX_FILES; flat.log_cleanup_max_size_mb = log.cleanup_max_size_mb != null ? Number(log.cleanup_max_size_mb) : DEFAULTS.LOG_CLEANUP_MAX_SIZE_MB; // ── WIFI_SSID ── (always include, even if empty array) const ssidList = device.ssid_list || []; flat.WIFI_SSID = ssidList.map(entry => ({ wifi_ssid: entry.ssid || '', wifi_passwd: entry.password || '', wifi_security: entry.security || 'wpa/wpa2', })); return flat; } /** * Build complete device config for saving (from state). * Converts nested UI format to flat format matching Java app.jar / dpworldapp. * Saved to SQLite board_config table as key="device_config". */ export function buildDevicePayload() { if (!state.device) return convertNestedToFlat(createDefaultDevice()); return convertNestedToFlat(state.device); } /** * Build complete protocol config for saving. * Ensures shift values in CAN/MODBUS/OPC_UA arrays remain strings * for compatibility with legacy Java Configurator (app.jar). */ 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', })); } }); return payload; } export function createDefaultDevice() { return { wifi: { country_code: DEFAULTS.WIFI_COUNTRY_CODE }, ssid_list: [], eth: {}, server: {}, can: {}, rs485: {}, lte: {}, log: { save: 'on', max_size: 100, max_days: 1, auto_compress: DEFAULTS.LOG_AUTO_COMPRESS, compress_size_mb: DEFAULTS.LOG_COMPRESS_SIZE_MB, compress_age_days: DEFAULTS.LOG_COMPRESS_AGE_DAYS, auto_cleanup: DEFAULTS.LOG_AUTO_CLEANUP, cleanup_max_files: DEFAULTS.LOG_CLEANUP_MAX_FILES, cleanup_max_size_mb: DEFAULTS.LOG_CLEANUP_MAX_SIZE_MB, }, }; } export function createDefaultProtocol() { return { dev_type: '', equipment: '', equipment_id: '', MEID: '', version: '', protocol: 'NONE', can_input: 'off', dr_on: 'off', heading_on: 'off', heading_imu_on: 'off', fix_mode_on: 'off', speed_data: '', analog_input_level: '', // v1.4.6.1 B-1: ai0/ai1/di0/di1만 (live device config + working DB baseline) ai0: '0', ai1: '0', di0: '0', di1: '0', two_byte_order: '', four_byte_order: '', // v1.4.6 C: Odometer (odo_on as a flat string key + nested speed/direction fields, per the device config-reader contract) odo_on: 'off', MODBUS: [], OPC_UA: [], CAN: [], }; } // ─── Reactive Binding Helpers ─────────────────────────────── /** * 단일 input/select → state 양방향 바인딩 */ export function bindInput(container, selector, getter, setter) { const el = container.querySelector(selector); if (!el) return; const val = getter(); if (val !== undefined && val !== null) el.value = String(val); el.addEventListener('input', () => setter(el.value)); el.addEventListener('change', () => setter(el.value)); } /** * radio 그룹 → state 바인딩 */ export function bindRadio(container, name, getter, setter) { const radios = container.querySelectorAll(`input[name="${name}"]`); const currentVal = getter(); radios.forEach(radio => { if (radio.value === currentVal) radio.checked = true; radio.addEventListener('change', () => { if (radio.checked) setter(radio.value); }); }); } /** * checkbox → state 바인딩 ("1"/"0" 또는 "on"/"off") */ export function bindCheckbox(container, selector, getter, setter) { const el = container.querySelector(selector); if (!el) return; el.checked = getter() === '1' || getter() === 'on'; el.addEventListener('change', () => setter(el.checked ? '1' : '0')); } /** * IP 옥텟 그룹 → state 바인딩 */ export function bindIpGroup(container, ipKey, getter, setter) { const octets = container.querySelectorAll(`.ip-octet[data-ip="${ipKey}"]`); if (octets.length !== 4) return; const ip = getter() || ''; const parts = ip.split('.'); octets.forEach((oct, i) => { oct.value = parts[i] || ''; }); const collect = () => { setter(Array.from(octets).map(o => o.value).join('.')); }; octets.forEach(oct => oct.addEventListener('input', collect)); }