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.

835 lines
31 KiB

/**
* 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));
}