From 17a84695927a3946ac3d6f6c243fc6e3af471d46 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Thu, 2 Jul 2026 15:35:34 -0400 Subject: [PATCH] Add DBS-210 status.xml parser + health verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the DECT spike: the read-side "collector" that turns a raw /admin/status.xml body into a normalized JS object plus a pure health verdict. This is what will feed the /phonestatus base- station diagnostics section once we wire it in. - integrations/cisco-dect/statusXml.js: - xmlToObject(): 60-line hand-rolled parser targeted at the DBS-210's flat XML shape. No attributes, no CDATA, no comments — so we avoid pulling in a generic XML lib. Throws loudly on malformed input. - parseRebootLine(): decodes the reboot-log entries the device keeps in Reboot_Line_1..6, extracting timestamp + sequence # + reason name/code + firmware version. Unrecognized shapes come back marked `unrecognized:true` instead of being dropped. - parseStatusXml(): grouped, camelCased view of the device state (device / firmware / time / multiCell / rebootLog / rtp / network / security / emergencyNumbers / features). Every field is null-safe. - summarizeBaseHealth(): pure-function verdict. Flags recent reboots (uptime < 10 min), power-loss events in the log, DECT RF conflicts, non-zero rx/tx errors. Splits into warnings vs info so consumers can render at the right severity. - tests/statusXml.test.js: 23 tests covering the parser, the reboot-line decoder, the higher-level normalizer, and the health verdict — using a REDACTED inline copy of a real status.xml captured from a lab base. MAC/IP/RFPI/firmware-server URL are all obviously-fake so the fixture is safe to commit. --- integrations/cisco-dect/statusXml.js | 439 +++++++++++++++++++++++++++ tests/statusXml.test.js | 320 +++++++++++++++++++ 2 files changed, 759 insertions(+) create mode 100644 integrations/cisco-dect/statusXml.js create mode 100644 tests/statusXml.test.js diff --git a/integrations/cisco-dect/statusXml.js b/integrations/cisco-dect/statusXml.js new file mode 100644 index 0000000..0902316 --- /dev/null +++ b/integrations/cisco-dect/statusXml.js @@ -0,0 +1,439 @@ +// src/integrations/cisco-dect/statusXml.js +// +// Parser + normalizer for the Cisco DBS-210 DECT base station's +// `/admin/status.xml` endpoint. Pure — takes a raw XML string, +// returns a structured object. No axios, no I/O, no side-effects. +// +// Why hand-roll instead of pulling in fast-xml-parser? The DBS-210's +// status XML schema is trivially flat: +// - No attributes anywhere +// - No CDATA, no comments, no mixed content +// - Two levels of nesting at most (Status → Section → leaves; +// Section → Sub-object → leaves) +// - No repeated same-name siblings (Reboot_Line_1..6 are distinct +// tags, not arrays) +// A specialized 60-line reader is safer than dragging in a generic +// XML parser we'd then need to keep pinned on the eventual store-side +// relay agent (which we want to stay tiny). +// +// The output shape is deliberately camelCased and grouped for +// consumers — it's NOT a faithful reflection of the XML tag names. +// That's on purpose: the parser is the moment where we absorb the +// Cisco tag naming quirks so nothing else in the codebase has to. + +// ─── Low-level: XML → plain JS object ─────────────────────────────── + +/** + * Parse a very-simple XML string (single root, no attributes, no + * CDATA, no comments, no mixed content) into a plain JS object. + * Leaf elements become string values; parent elements become nested + * objects keyed by tag name. + * + * Whitespace between tags is discarded. Whitespace inside leaf text + * is preserved as-is (the DBS-210 uses meaningful spacing in some + * fields, e.g. `RFPI_Address` = "13508C9C; RPN:00"). + * + * Throws on malformed input rather than silently coercing — callers + * catch and log so a firmware change that breaks the schema shows up + * loudly instead of producing a mysteriously-empty object. + */ +export function xmlToObject(xml) { + if (typeof xml !== 'string') { + throw new TypeError('xmlToObject: expected a string'); + } + // Strip XML declaration if present. + const cleaned = xml.replace(/^\uFEFF/, '').replace(/<\?xml[^?]*\?>/, '').trim(); + if (!cleaned) throw new Error('xmlToObject: empty input'); + + let pos = 0; + + function skipWs() { + while (pos < cleaned.length && /\s/.test(cleaned[pos])) pos++; + } + + function readOpenTag() { + // Assumes cleaned[pos] === '<'. + if (cleaned[pos] !== '<') { + throw new Error(`xmlToObject: expected '<' at ${pos}`); + } + const end = cleaned.indexOf('>', pos); + if (end < 0) throw new Error(`xmlToObject: unterminated tag at ${pos}`); + const raw = cleaned.slice(pos + 1, end); + pos = end + 1; + const selfClosing = raw.endsWith('/'); + const inner = selfClosing ? raw.slice(0, -1).trim() : raw.trim(); + // Tag name = up to first whitespace. Attributes (if any) are + // ignored — DBS-210 XML doesn't use them and swallowing them + // silently keeps us robust to future additions. + const nameMatch = inner.match(/^([A-Za-z_][\w.-]*)/); + if (!nameMatch) throw new Error(`xmlToObject: bad tag name near ${pos}: ${inner}`); + return { name: nameMatch[1], selfClosing }; + } + + function parseElement() { + const { name, selfClosing } = readOpenTag(); + if (selfClosing) return { name, value: '' }; + + // Look ahead: if the next non-whitespace char is '<' AND it's not + // an immediate close of THIS tag, treat body as child elements. + // Otherwise treat body as leaf text up to . + const savedPos = pos; + skipWs(); + const closeTag = ``; + const nextOpen = cleaned.indexOf('<', pos); + + if (nextOpen === pos && !cleaned.startsWith(closeTag, pos)) { + // Container element with child elements. + const children = {}; + while (true) { + skipWs(); + if (cleaned.startsWith(closeTag, pos)) { + pos += closeTag.length; + return { name, value: children }; + } + if (pos >= cleaned.length) { + throw new Error(`xmlToObject: EOF while reading children of <${name}>`); + } + const child = parseElement(); + children[child.name] = child.value; + } + } + + // Leaf text (may be empty or whitespace-only). + pos = savedPos; + const closeIdx = cleaned.indexOf(closeTag, pos); + if (closeIdx < 0) throw new Error(`xmlToObject: missing `); + const rawText = cleaned.slice(pos, closeIdx); + pos = closeIdx + closeTag.length; + return { name, value: decodeEntities(rawText.trim()) }; + } + + const root = parseElement(); + return { [root.name]: root.value }; +} + +function decodeEntities(s) { + return s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&/g, '&'); // must run last +} + +// ─── High-level: raw parsed tree → structured status object ───────── + +/** + * Parse the two known-shape reboot line formats: + * "2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09" + * "2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09" + * + * Returns null for unrecognized shapes so callers can pass the raw + * string through instead of dropping the entry. + */ +export function parseRebootLine(line) { + if (typeof line !== 'string' || !line.trim()) return null; + const m = line.match( + /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+\((\d+)\)\s+(.+?)\s+\((\d+)\)\s+Firmware Version\s+(\S+)\s*$/ + ); + if (!m) return { raw: line, unrecognized: true }; + const [, date, time, seq, reasonName, reasonCode, firmware] = m; + return { + at: `${date}T${time}`, // ISO-ish local time (device doesn't include a TZ) + sequence: Number(seq), + reasonName: reasonName.trim(), + reasonCode: Number(reasonCode), + firmwareAtBoot: firmware, + raw: line, + }; +} + +/** + * Take raw /admin/status.xml text and return a normalized, grouped + * status object. Missing sections come back as `null` (or empty + * collections where an array/object shape is expected) rather than + * throwing — the DBS-210 sometimes omits sections depending on + * multi-cell role, and callers should be able to reason about + * partial data. + * + * @param {string} xml + * @returns {object} structured status + */ +export function parseStatusXml(xml) { + const tree = xmlToObject(xml); + const root = tree.Status || {}; + const sys = (typeof root === 'object' && root.System_Information) || {}; + const stats = (typeof root === 'object' && root.Statistics) || {}; + + const rebootLog = collectRebootLog(sys.Reboot_Log); + const rtp = sys.RTP_Usage || {}; + const netStats = stats.Network_Statistics || {}; + const emergency = extractEmergencyNumbers(sys.Emergency_Calls); + const deviceFwu = extractDeviceFwu(sys.Device_FWU_Info); + const rssi = extractRssiList(sys.RSSI_List); + + return { + device: { + model: str(sys.Phone_Type), // "IPDECT-V2 (DBS-210-3PC)" + systemType: str(sys.System_Type), // "Generic SIP (RFC 3261)" + unitName: str(sys.Unit_Name), // "SME VoIP" + unitIndex: str(sys.Unit_Index), // "Base Idx:0" + rfBand: str(sys.RF_Band), // "US" + productConfiguration: str(sys.Product_Configuration), + macAddress: normalizeMac(str(sys.MAC_Address)), + ipAddress: str(sys.IP_Address), + rfpiAddress: str(sys.RFPI_Address), // "13508C9C; RPN:00" + releasedBuild: yesNo(sys.Released_Build), + }, + firmware: { + version: str(sys.Firmware_Version), // "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29" + updateServer: str(sys.Firmware_URL?.Update_Server_Address), + updatePath: str(sys.Firmware_URL?.Path), + requiredFor: deviceFwu, + }, + time: { + currentLocalTime: str(sys.Current_Local_Time), + operatingTime: str(sys.Operating_Time), + operatingTimeSeconds: parseOperatingSeconds(sys.Operating_Time), + }, + multiCell: parseMultiCell(sys.Multi_Cell), + baseStatus: (str(sys.Base_Station_Status) || '').toLowerCase() || null, + conflictInfo: str(sys.Conflict_Info), + security: { + customCa: { + provisioningStatus: str(sys.Custom_CA_Status?.Custom_CA_Provisioning_Status), + info: str(sys.Custom_CA_Status?.Custom_CA_Info), + installed: (str(sys.Custom_CA_Status?.Custom_CA_Info) || '').toLowerCase() !== 'not installed', + }, + dot1x: { + transactionStatus: str(sys.Dot1x_Authentication?.Transaction_status), + protocol: str(sys.Dot1x_Authentication?.Protocol), + }, + }, + rebootLog, + rtp: { + total: numOrNull(rtp.Total_RTP), + max: numOrNull(rtp.Max_RTP), + current: numOrNull(rtp.Current_RTP), + currentLocal: numOrNull(rtp.Current_Local_RTP), + currentRelay: numOrNull(rtp.Current_Relay_RTP), + remoteRelay: numOrNull(rtp.Remote_Relay_RTP), + currentRecording: numOrNull(rtp.Current_Recording), + timeInMaxRtp: str(rtp.Time_In_Max_RTP), + }, + network: { + txPackets: numOrNull(netStats.Tx_Packets), + txBlocked: numOrNull(netStats.Tx_Blocked), + txDropped: numOrNull(netStats.Tx_Dropped), + txErrors: numOrNull(netStats.Tx_Errors), + txBroadcasts: numOrNull(netStats.Tx_Broadcasts), + rxPackets: numOrNull(netStats.Rx_Packets), + rxBlocked: numOrNull(netStats.Rx_Blocked), + rxDropped: numOrNull(netStats.Rx_Dropped), + rxErrors: numOrNull(netStats.Rx_Errors), + rxBroadcasts: numOrNull(netStats.Rx_Broadcasts), + }, + emergencyNumbers: emergency, + rssi, + features: { + pushToTalk: (str(sys.Push_To_Talk) || '').toLowerCase() === 'on', + }, + // The Statistics/Header_Line_Idx tag is a CSV schema descriptor + // for a companion (per-RPN) statistics section we haven't + // captured yet. Kept as raw so a future collector can align to + // it without reparsing here. + _rawStatisticsHeader: str(stats.Header_Line_Idx), + }; +} + +// ─── Health verdict ───────────────────────────────────────────────── + +// Cisco reboot reason codes. Not documented publicly — collected +// empirically from the DBS-210 sample and from Cisco community posts. +// We only classify the ones we've actually observed so a new code +// shows up as "unknown" instead of being silently reclassified. +const KNOWN_REBOOT_CODES = new Map([ + [21, { key: 'normal', severity: 'info', label: 'Normal reboot (admin- or firmware-initiated)' }], + [80, { key: 'power-loss', severity: 'warn', label: 'Power loss — mains interruption or PoE glitch' }], +]); + +/** + * Compute a pure-function health verdict from a parsed status object. + * No I/O. Returns { healthy, warnings, info } where warnings is an + * array of user-facing strings. Consumers decide how to render. + */ +export function summarizeBaseHealth(parsed) { + const warnings = []; + const info = []; + + if (!parsed || typeof parsed !== 'object') { + return { healthy: false, warnings: ['No status data parsed'], info: [] }; + } + + // Uptime — under 10 minutes = very recent reboot, worth flagging. + const uptimeSec = parsed.time?.operatingTimeSeconds; + if (Number.isFinite(uptimeSec) && uptimeSec < 600) { + warnings.push(`Base rebooted very recently (uptime ${Math.round(uptimeSec / 60)} min)`); + } + + // Reboot log — surface any power-loss in the last 6 boots (that's + // literally as far back as the device remembers). + const powerLosses = (parsed.rebootLog || []).filter((r) => r.reasonCode === 80); + if (powerLosses.length > 0) { + warnings.push( + `${powerLosses.length} recent power-loss reboot(s); most recent at ${powerLosses[0].at}`, + ); + } + + // RF conflict — non-"No Conflict" means DECT interference detected. + const conflict = parsed.conflictInfo || ''; + if (conflict && conflict.toLowerCase() !== 'no conflict') { + warnings.push(`DECT RF conflict reported: ${conflict}`); + } + + // Network drops. Non-zero rx_dropped is the classic "your switch + // port is misconfigured / the base is overwhelmed" signal. + const rxDropped = parsed.network?.rxDropped; + if (Number.isFinite(rxDropped) && rxDropped > 0) { + info.push(`Rx dropped packets: ${rxDropped} since last boot`); + } + const rxErrors = parsed.network?.rxErrors; + if (Number.isFinite(rxErrors) && rxErrors > 0) { + warnings.push(`Rx errors: ${rxErrors} since last boot`); + } + const txErrors = parsed.network?.txErrors; + if (Number.isFinite(txErrors) && txErrors > 0) { + warnings.push(`Tx errors: ${txErrors} since last boot`); + } + + // 802.1X — if enabled (protocol != 'N/A') and status isn't 'Authenticated' + // we flag it. The DBS-210 emits 'Unavailable' when 802.1X is off, so + // we specifically ignore that state. + const dot1xStatus = (parsed.security?.dot1x?.transactionStatus || '').toLowerCase(); + const dot1xProto = (parsed.security?.dot1x?.protocol || '').toLowerCase(); + if (dot1xProto && dot1xProto !== 'n/a' && dot1xStatus && dot1xStatus !== 'authenticated' && dot1xStatus !== 'unavailable') { + warnings.push(`802.1X in state "${parsed.security.dot1x.transactionStatus}" (protocol ${parsed.security.dot1x.protocol})`); + } + + // Custom CA — informational only; some fleets never install one. + if (parsed.security?.customCa?.installed) { + info.push(`Custom CA installed: ${parsed.security.customCa.info}`); + } + + return { + healthy: warnings.length === 0, + warnings, + info, + }; +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +function str(v) { + if (v == null) return null; + if (typeof v === 'string') return v; + return null; // an object where a string was expected → treat as absent +} + +function numOrNull(v) { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +function yesNo(v) { + if (v == null) return null; + return String(v).trim().toLowerCase() === 'yes'; +} + +function normalizeMac(mac) { + if (!mac) return null; + const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (hex.length !== 12) return mac; // not the expected 12-hex form, pass through + return hex.match(/../g).join(':'); +} + +/** + * Multi_Cell field is e.g.: + * "Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary" + * "Primary(TXT_STATE_PRIMARY) ..." + * "Secondary(TXT_STATE_SECONDARY) ..." + * We normalize to a role token + keep the flavor text. + */ +function parseMultiCell(raw) { + if (!raw) return { role: null, raw: null }; + const s = String(raw); + const m = s.match(/^([A-Za-z]+)/); + return { + role: m ? m[1].toLowerCase() : null, // "unchained" | "primary" | "secondary" + raw: s, + }; +} + +/** + * Operating_Time is formatted as "H:M:S" (e.g. "00:10:20 (H:M:S)"). + * Convert to seconds. Returns null if unrecognized. + */ +function parseOperatingSeconds(v) { + if (!v) return null; + const m = String(v).match(/(\d+):(\d+):(\d+)/); + if (!m) return null; + const [, h, mi, s] = m; + return Number(h) * 3600 + Number(mi) * 60 + Number(s); +} + +function collectRebootLog(rebootLogNode) { + if (!rebootLogNode || typeof rebootLogNode !== 'object') return []; + // Reboot_Line_1..N — sort by their numeric suffix and drop empties. + const entries = Object.entries(rebootLogNode) + .filter(([k]) => /^Reboot_Line_\d+$/.test(k)) + .sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0])) + .map(([, v]) => v); + return entries + .filter((v) => v && typeof v === 'string' && v.trim()) + .map(parseRebootLine) + .filter(Boolean); +} + +function extractEmergencyNumbers(node) { + if (!node || typeof node !== 'object') return []; + return Object.entries(node) + .filter(([k]) => /^Emergency_Number_\d+$/.test(k)) + .sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0])) + .map(([, v]) => (typeof v === 'string' ? v.trim() : '')) + .filter((v) => v && !/^no number set/i.test(v)); +} + +function extractDeviceFwu(node) { + if (!node || typeof node !== 'object') return {}; + const out = {}; + for (const [k, v] of Object.entries(node)) { + if (typeof v !== 'string') continue; + // "Base type:DBS-210-3PC - Required Version:501 Required Branch:309" + // "Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default" + const typeMatch = v.match(/(?:Base type|Device type):([^\s]+)/); + const versionMatch = v.match(/Required Version:(\S+)/); + const branchMatch = v.match(/Required Branch:(\S+)/); + const langMatch = v.match(/Language Pack:(\S+)/); + if (typeMatch) { + out[typeMatch[1]] = { + requiredVersion: versionMatch ? versionMatch[1] : null, + requiredBranch: branchMatch ? branchMatch[1] : null, + languagePack: langMatch ? langMatch[1] : null, + _sourceKey: k, + }; + } + } + return out; +} + +function extractRssiList(node) { + if (!node || typeof node !== 'object') return []; + // RSSI_List can be empty (as in our sample) or contain child + // entries. Whatever's here, we surface raw and let a + // future parser refine once we've seen a populated example. + const keys = Object.keys(node); + if (keys.length === 0) return []; + return keys.map((k) => ({ key: k, value: node[k] })); +} diff --git a/tests/statusXml.test.js b/tests/statusXml.test.js new file mode 100644 index 0000000..9ae1997 --- /dev/null +++ b/tests/statusXml.test.js @@ -0,0 +1,320 @@ +// Unit tests for integrations/cisco-dect/statusXml.js. +// +// Pure — no network, no fs. The fixture below is a REDACTED copy of a +// real /admin/status.xml pulled from a lab DBS-210 during the DECT +// spike. MAC, IP, RFPI, and firmware server URL are all changed to +// obviously-fake values so this file is safe to commit and safe to +// leave in CI logs. The XML SHAPE (tag nesting, whitespace, encoding +// oddities like `text/text` mimetype on the wire) is preserved +// verbatim — that's exactly what the parser has to be robust against. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + xmlToObject, + parseStatusXml, + parseRebootLine, + summarizeBaseHealth, +} from '../integrations/cisco-dect/statusXml.js'; + +const FIXTURE = ` + + +Yes +Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary +IPDECT-V2 (DBS-210-3PC) +Generic SIP (RFC 3261) +SME VoIP +Base Idx:0 +US +No Conflict +02-Jul-2026 13:22:48 +00:10:20 (H:M:S) +ABCDEF12; RPN:00 +001122334455 +10.0.0.100 +0000 +IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29 + +https://example.invalid +dms/dbS210 + + +2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09 +2026-07-02 13:09:50 (163) Normal Reboot (21) Firmware Version 05-01-03-0101-09 +2026-07-02 13:06:28 (162) Normal Reboot (21) Firmware Version 05-01-03-0101-09 +2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09 +2026-07-02 12:49:50 (160) Normal Reboot (21) Firmware Version 05-01-03-0101-09 +2026-07-02 12:48:40 (159) Normal Reboot (21) Firmware Version 05-01-03-0101-09 + +Idle + +N/A +Not Installed + + +Unavailable +N/A + + + + +2 +-1 +49710 days 06:28:15 [H:M:S] +0 +0 +0 +0 +0 + + + + +Base type:DBS-210-3PC - Required Version:501 Required Branch:309 +Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default +Device type:6825-RGD - Required Version:501 Required Branch:308 Language Pack:6825-RGD_default +Device type:6823 - Required Version:501 Required Branch:308 Language Pack:6823_default +Device type:RPT-110-3PC - Required Version:501 Required Branch:303 + +Off + +911 +1911 +933 +No Number set! +No Number set! + + + + + + + + +1959 +0 +0 +0 +0 +49318 +0 +9 +0 +5834 + +RPN, MAC-Addr, OP[s], DT[s] + +`; + +// ─── Low-level parser ─────────────────────────────────────────────── + +test('xmlToObject: parses the DBS-210 status.xml shape into a nested object', () => { + const tree = xmlToObject(FIXTURE); + assert.ok(tree.Status, 'root element is Status'); + assert.equal(tree.Status.System_Information.Phone_Type, 'IPDECT-V2 (DBS-210-3PC)'); + assert.equal(tree.Status.System_Information.MAC_Address, '001122334455'); + // Nested Firmware_URL is a child object, not a string. + assert.equal(typeof tree.Status.System_Information.Firmware_URL, 'object'); + assert.equal(tree.Status.System_Information.Firmware_URL.Path, 'dms/dbS210'); + // Empty \n becomes an empty leaf string — + // XML alone can't distinguish empty-container from empty-leaf, so + // the parser stays neutral. parseStatusXml() then normalizes both + // shapes into `[]` for callers that expect a container. + assert.equal(tree.Status.System_Information.RSSI_List, ''); + // Statistics has the massive CSV header preserved as a single string. + assert.match(tree.Status.Statistics.Header_Line_Idx, /RPN, MAC-Addr/); +}); + +test('xmlToObject: preserves whitespace inside leaf text values', () => { + const tree = xmlToObject(FIXTURE); + // RFPI has a `; ` separator that must survive intact. + assert.equal(tree.Status.System_Information.RFPI_Address, 'ABCDEF12; RPN:00'); +}); + +test('xmlToObject: throws on empty input', () => { + assert.throws(() => xmlToObject(''), /empty input/); + assert.throws(() => xmlToObject(''), /empty input/); +}); + +test('xmlToObject: throws on non-string input', () => { + assert.throws(() => xmlToObject(null), /expected a string/); + assert.throws(() => xmlToObject(123), /expected a string/); +}); + +// ─── Reboot line parser ───────────────────────────────────────────── + +test('parseRebootLine: normal reboot line', () => { + const p = parseRebootLine('2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09'); + assert.equal(p.at, '2026-07-02T13:11:46'); + assert.equal(p.sequence, 164); + assert.equal(p.reasonName, 'Normal Reboot'); + assert.equal(p.reasonCode, 21); + assert.equal(p.firmwareAtBoot, '05-01-03-0101-09'); +}); + +test('parseRebootLine: power loss line', () => { + const p = parseRebootLine('2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09'); + assert.equal(p.reasonName, 'Power Loss'); + assert.equal(p.reasonCode, 80); +}); + +test('parseRebootLine: marks unrecognized shapes without dropping them', () => { + const p = parseRebootLine('Something totally different'); + assert.equal(p.unrecognized, true); + assert.equal(p.raw, 'Something totally different'); +}); + +test('parseRebootLine: empty/null input', () => { + assert.equal(parseRebootLine(''), null); + assert.equal(parseRebootLine(' '), null); + assert.equal(parseRebootLine(null), null); + assert.equal(parseRebootLine(undefined), null); +}); + +// ─── High-level parseStatusXml ────────────────────────────────────── + +test('parseStatusXml: extracts core device identity', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.device.model, 'IPDECT-V2 (DBS-210-3PC)'); + assert.equal(s.device.macAddress, '00:11:22:33:44:55'); // normalized colon-form + assert.equal(s.device.ipAddress, '10.0.0.100'); + assert.equal(s.device.rfBand, 'US'); + assert.equal(s.device.rfpiAddress, 'ABCDEF12; RPN:00'); + assert.equal(s.device.releasedBuild, true); +}); + +test('parseStatusXml: firmware version and required-per-device map', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.firmware.version, 'IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29'); + assert.equal(s.firmware.updateServer, 'https://example.invalid'); + assert.equal(s.firmware.updatePath, 'dms/dbS210'); + // Device_Base_Station → keyed by model + assert.deepEqual(s.firmware.requiredFor['DBS-210-3PC'], { + requiredVersion: '501', + requiredBranch: '309', + languagePack: null, + _sourceKey: 'Device_Base_Station', + }); + // Handset lines carry language pack too + assert.equal(s.firmware.requiredFor['6825'].languagePack, '6825_default'); +}); + +test('parseStatusXml: operating time gets converted to seconds', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.time.operatingTime, '00:10:20 (H:M:S)'); + assert.equal(s.time.operatingTimeSeconds, 620); // 10m 20s +}); + +test('parseStatusXml: multi-cell role parsed to a normalized token', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.multiCell.role, 'unchained'); + assert.match(s.multiCell.raw, /TXT_STATE_UNCHAINED/); +}); + +test('parseStatusXml: reboot log is 6 sorted entries with structured fields', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.rebootLog.length, 6); + // First entry = the newest by sequence#, matching physical order in the XML. + assert.equal(s.rebootLog[0].sequence, 164); + assert.equal(s.rebootLog[0].reasonName, 'Normal Reboot'); + // The Power Loss event is entry #4 (sequence 161). + const powerLoss = s.rebootLog.find((r) => r.reasonCode === 80); + assert.ok(powerLoss); + assert.equal(powerLoss.reasonName, 'Power Loss'); + assert.equal(powerLoss.sequence, 161); +}); + +test('parseStatusXml: network stats decoded as numbers', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.network.txPackets, 1959); + assert.equal(s.network.rxPackets, 49318); + assert.equal(s.network.rxDropped, 9); + assert.equal(s.network.rxErrors, 0); +}); + +test('parseStatusXml: emergency numbers filter out "No Number set" placeholders', () => { + const s = parseStatusXml(FIXTURE); + assert.deepEqual(s.emergencyNumbers, ['911', '1911', '933']); +}); + +test('parseStatusXml: RTP usage counters', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.rtp.total, 2); + assert.equal(s.rtp.current, 0); + assert.equal(s.rtp.max, -1); +}); + +test('parseStatusXml: security / dot1x / customCA', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.security.customCa.installed, false); + assert.equal(s.security.customCa.info, 'Not Installed'); + assert.equal(s.security.dot1x.transactionStatus, 'Unavailable'); +}); + +test('parseStatusXml: pushToTalk feature flag', () => { + const s = parseStatusXml(FIXTURE); + assert.equal(s.features.pushToTalk, false); +}); + +// ─── Health verdict ───────────────────────────────────────────────── + +test('summarizeBaseHealth: fixture flags power-loss reboot and short uptime', () => { + const s = parseStatusXml(FIXTURE); + const verdict = summarizeBaseHealth(s); + assert.equal(verdict.healthy, false); + // Uptime 620s (~10 min) is < 600 → boundary, so no uptime warning. + // But we DO have a power loss in the log, so healthy=false via that. + assert.ok( + verdict.warnings.some((w) => /power-loss/i.test(w)), + `expected power-loss warning; got: ${JSON.stringify(verdict.warnings)}`, + ); + // rxDropped=9 → info, not warning + assert.ok( + verdict.info.some((i) => /rx dropped/i.test(i)), + `expected rx-dropped info; got: ${JSON.stringify(verdict.info)}`, + ); +}); + +test('summarizeBaseHealth: RF conflict is flagged as a warning', () => { + const s = parseStatusXml(FIXTURE.replace( + 'No Conflict', + 'Conflict Detected on RPN 00', + )); + const verdict = summarizeBaseHealth(s); + assert.ok(verdict.warnings.some((w) => /RF conflict/i.test(w))); +}); + +test('summarizeBaseHealth: very short uptime is flagged as recent reboot', () => { + const s = parseStatusXml(FIXTURE.replace( + '00:10:20 (H:M:S)', + '00:02:15 (H:M:S)', + )); + const verdict = summarizeBaseHealth(s); + assert.ok(verdict.warnings.some((w) => /rebooted very recently/i.test(w))); +}); + +test('summarizeBaseHealth: healthy base with clean stats returns healthy:true', () => { + // Wipe rx_dropped and the power-loss reboot line so nothing warns. + const cleaned = FIXTURE + .replace(/\d+<\/Rx_Dropped>/, '0') + .replace( + /[^<]+<\/Reboot_Line_4>/, + '2026-07-02 12:54:12 (161) Normal Reboot (21) Firmware Version 05-01-03-0101-09', + ) + .replace( + '00:10:20 (H:M:S)', + '24:15:00 (H:M:S)', + ); + const s = parseStatusXml(cleaned); + const verdict = summarizeBaseHealth(s); + assert.equal(verdict.healthy, true, `not healthy: ${JSON.stringify(verdict.warnings)}`); + assert.equal(verdict.warnings.length, 0); +}); + +test('summarizeBaseHealth: bad input degrades gracefully', () => { + assert.equal(summarizeBaseHealth(null).healthy, false); + assert.equal(summarizeBaseHealth(undefined).healthy, false); + assert.equal(summarizeBaseHealth('nope').healthy, false); +});