collabSupport/integrations/cisco-dect/statusXml.js
jmcqueen 17a8469592 Add DBS-210 status.xml parser + health verdict
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.
2026-07-02 15:35:34 -04:00

439 lines
17 KiB
JavaScript

// 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 <Reboot_Line> 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 </name>.
const savedPos = pos;
skipWs();
const closeTag = `</${name}>`;
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 </${name}>`);
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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&amp;/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
// <RPN_X> 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] }));
}