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.
834 lines
31 KiB
834 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));
|
|
}
|
|
|