Surface handset registrations and RSSI, tighten reboot health to 7 days, and consolidate Base / Handsets & RF / Network & RTP sections. Co-authored-by: Cursor <cursoragent@cursor.com>
683 lines
25 KiB
JavaScript
683 lines
25 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(/</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);
|
|
const devicePresence = extractDevicePresence(sys.Device_Presence);
|
|
const sipIdentityStatus = extractSipIdentityStatus(sys.SIP_Identity_Status);
|
|
const devices = extractDeviceInformation(root);
|
|
const perRpnStats = extractPerRpnStatistics(stats);
|
|
|
|
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,
|
|
devicePresence,
|
|
sipIdentityStatus,
|
|
devices,
|
|
perRpnStats,
|
|
features: {
|
|
pushToTalk: (str(sys.Push_To_Talk) || '').toLowerCase() === 'on',
|
|
},
|
|
// The Statistics/Header_Line_Idx tag is a CSV schema descriptor
|
|
// for companion per-RPN statistics rows (see perRpnStats).
|
|
statisticsHeader: str(stats.Header_Line_Idx),
|
|
_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)' }],
|
|
[43, { key: 'unexpected', severity: 'warn', label: 'Unexpected reboot' }],
|
|
[80, { key: 'power-loss', severity: 'warn', label: 'Power loss — mains interruption or PoE glitch' }],
|
|
]);
|
|
|
|
/** Reboot reason codes that affect health when seen within the warn window. */
|
|
const REBOOT_HEALTH_WARN_CODES = new Set([43, 80]);
|
|
const REBOOT_HEALTH_WARN_DAYS = 7;
|
|
|
|
function rebootEntryAgeMs(entry) {
|
|
if (!entry?.at) return null;
|
|
const t = Date.parse(entry.at);
|
|
return Number.isFinite(t) ? Date.now() - t : null;
|
|
}
|
|
|
|
function isRecentRebootConcern(entry, maxAgeMs = REBOOT_HEALTH_WARN_DAYS * 86400000) {
|
|
if (!REBOOT_HEALTH_WARN_CODES.has(entry?.reasonCode)) return false;
|
|
const ageMs = rebootEntryAgeMs(entry);
|
|
if (ageMs == null) return false;
|
|
return ageMs >= 0 && ageMs <= maxAgeMs;
|
|
}
|
|
|
|
/**
|
|
* 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 — power-loss or unexpected reboot within the last 7 days.
|
|
const badReboots = (parsed.rebootLog || []).filter((entry) => isRecentRebootConcern(entry));
|
|
if (badReboots.length > 0) {
|
|
const latest = badReboots[0];
|
|
const label = latest.reasonName || `code ${latest.reasonCode}`;
|
|
warnings.push(
|
|
`${badReboots.length} concerning reboot(s) in the last ${REBOOT_HEALTH_WARN_DAYS} days ` +
|
|
`(latest: ${label} at ${latest.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, state: null, raw: null };
|
|
const s = String(raw).trim();
|
|
|
|
const leadRole = s.match(/^(Primary|Secondary|Unchained)\b/i);
|
|
if (leadRole) {
|
|
return { role: leadRole[1].toLowerCase(), state: null, raw: s };
|
|
}
|
|
|
|
const leadState = s.match(/^([A-Za-z]+)/);
|
|
const state = leadState ? leadState[1].toLowerCase() : null;
|
|
|
|
const trailRole = s.match(/\)\s+(Primary|Secondary)\b/i);
|
|
const role = trailRole ? trailRole[1].toLowerCase() : null;
|
|
|
|
return {
|
|
role,
|
|
state: role && state && role !== state ? state : null,
|
|
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 s = String(v);
|
|
const daysMatch = s.match(/(\d+)\s+Days?\s+(\d+):(\d+):(\d+)/i);
|
|
if (daysMatch) {
|
|
const [, d, h, mi, sec] = daysMatch;
|
|
return Number(d) * 86400 + Number(h) * 3600 + Number(mi) * 60 + Number(sec);
|
|
}
|
|
const m = s.match(/(\d+):(\d+):(\d+)/);
|
|
if (!m) return null;
|
|
const [, h, mi, sec] = m;
|
|
return Number(h) * 3600 + Number(mi) * 60 + Number(sec);
|
|
}
|
|
|
|
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 [];
|
|
const out = [];
|
|
|
|
for (const entry of extractKeyedEntries(node, /^RPN_/)) {
|
|
out.push({ ...parseRssiEntry(entry.key, entry.value), raw: entry.value });
|
|
}
|
|
|
|
for (const entry of extractKeyedEntries(node, /^RSSI_Line_/)) {
|
|
const parsed = parseRssiLineText(entry.value);
|
|
if (parsed) out.push({ ...parsed, raw: entry.value });
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Parse RSSI_List child text. Shapes seen in the field:
|
|
* "MAC:6CAB05A1B2C3; RSSI:-58 dBm" (lab / synthetic)
|
|
* "RSSI for RPN:4 is -49 [dBm]" (store 933 live capture)
|
|
*/
|
|
function parseRssiEntry(key, value) {
|
|
const rpnMatch = key.match(/^RPN_(\d+)$/i);
|
|
const rpn = rpnMatch ? rpnMatch[1] : null;
|
|
const text = String(value || '');
|
|
const macMatch = text.match(/MAC:\s*([0-9A-Fa-f:]+)/i);
|
|
const rssiMatch = text.match(/RSSI:\s*(-?\d+)/i)
|
|
|| text.match(/(-?\d+)\s*dBm/i);
|
|
return {
|
|
rpn,
|
|
mac: macMatch ? normalizeMac(macMatch[1]) : null,
|
|
rssiDbm: rssiMatch ? Number(rssiMatch[1]) : null,
|
|
};
|
|
}
|
|
|
|
function parseRssiLineText(text) {
|
|
const m = String(text || '').match(/RPN:(\d+)\s+is\s+(-?\d+)/i);
|
|
if (!m) return null;
|
|
return { rpn: m[1], rssiDbm: Number(m[2]), mac: null };
|
|
}
|
|
|
|
function extractDevicePresence(node) {
|
|
if (!node || typeof node !== 'object') return [];
|
|
const rpnEntries = extractKeyedEntries(node, /^RPN_/).map((e) => ({
|
|
key: e.key,
|
|
present: /present/i.test(e.value),
|
|
raw: e.value,
|
|
}));
|
|
if (rpnEntries.length > 0) return rpnEntries;
|
|
|
|
return extractKeyedEntries(node, /^Device_Line_/).map((e) => {
|
|
const m = e.value.match(
|
|
/Device:([^\s]+)\s+(present|absent)\s+at\s+Extension\s+(\d+)/i,
|
|
);
|
|
return {
|
|
key: e.key,
|
|
deviceType: m?.[1] || null,
|
|
present: m ? /present/i.test(m[2]) : null,
|
|
extension: m?.[3] || null,
|
|
raw: e.value,
|
|
};
|
|
});
|
|
}
|
|
|
|
function extractSipIdentityStatus(node) {
|
|
if (!node || typeof node !== 'object') return [];
|
|
|
|
const uaEntries = Object.entries(node)
|
|
.filter(([k]) => /^SIP_UA_Id_/i.test(k))
|
|
.sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0]));
|
|
|
|
if (uaEntries.length > 0) {
|
|
return uaEntries.map(([key, val]) => {
|
|
if (typeof val !== 'object' || val == null) {
|
|
return { key, raw: String(val) };
|
|
}
|
|
return {
|
|
key,
|
|
sipIdx: str(val.SIP_Idx),
|
|
serverName: str(val.Server_Name),
|
|
sipUri: str(val.SIP_URI),
|
|
status: str(val.Status),
|
|
};
|
|
});
|
|
}
|
|
|
|
return extractKeyedEntries(node, /^Line_/).map((e) => ({
|
|
key: e.key,
|
|
status: e.value,
|
|
raw: e.value,
|
|
}));
|
|
}
|
|
|
|
function extractDeviceInformation(root) {
|
|
const di = (typeof root === 'object' && root.Device_Information) || {};
|
|
if (!di || typeof di !== 'object') return [];
|
|
|
|
return Object.entries(di)
|
|
.filter(([k]) => /^Device_Idx\d+$/i.test(k))
|
|
.sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0]))
|
|
.map(([key, dev]) => parseDeviceIdxEntry(key, dev))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseDeviceIdxEntry(key, dev) {
|
|
if (!dev || typeof dev !== 'object') return null;
|
|
const indexMatch = key.match(/(\d+)$/);
|
|
const index = indexMatch ? Number(indexMatch[1]) : null;
|
|
const battery = dev.Battery_RSSI_Info || {};
|
|
const location = dev.Location_Info || {};
|
|
const dect = dev.DECT_Info || {};
|
|
const sipAccount = findSipAccountDisplay(dev.SIP_Account);
|
|
|
|
const rssiRaw = str(battery.RSSI);
|
|
const rssiMatch = rssiRaw?.match(/(-?\d+)/);
|
|
|
|
const batteryRaw = str(battery.Battery_level);
|
|
const batteryMatch = batteryRaw?.match(/(\d+)/);
|
|
|
|
return {
|
|
index,
|
|
deviceType: str(dev.Device_Type),
|
|
ipei: str(dev.Ipei),
|
|
firmware: str(dev.SW_Version),
|
|
displayName: sipAccount?.displayName || null,
|
|
sipNumber: sipAccount?.number || null,
|
|
sipState: sipAccount?.state || null,
|
|
rssiDbm: rssiMatch ? Number(rssiMatch[1]) : null,
|
|
batteryPercent: batteryMatch ? Number(batteryMatch[1]) : null,
|
|
registeredRpn: str(location.RPN),
|
|
lockedRpn: str(location.Locked_RPN),
|
|
dectState: str(dect.DECT_State),
|
|
fwuProgress: str(dev.FWU_Progress),
|
|
};
|
|
}
|
|
|
|
function findSipAccountDisplay(sipAccountNode) {
|
|
if (!sipAccountNode || typeof sipAccountNode !== 'object') return null;
|
|
for (const val of Object.values(sipAccountNode)) {
|
|
if (!val || typeof val !== 'object') continue;
|
|
const displayName = str(val.Display_Name);
|
|
const number = str(val.Number);
|
|
const state = str(val.State);
|
|
if (displayName || number) {
|
|
return { displayName, number, state };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractKeyedEntries(node, keyPattern) {
|
|
if (!node || typeof node !== 'object') return [];
|
|
return Object.entries(node)
|
|
.filter(([k]) => keyPattern.test(k))
|
|
.sort(([a], [b]) => {
|
|
const na = Number((a.match(/\d+$/) || [0])[0]);
|
|
const nb = Number((b.match(/\d+$/) || [0])[0]);
|
|
return na - nb;
|
|
})
|
|
.map(([key, value]) => ({
|
|
key,
|
|
value: typeof value === 'string' ? value.trim() : String(value ?? ''),
|
|
}))
|
|
.filter((e) => e.value);
|
|
}
|
|
|
|
function extractPerRpnStatistics(statsNode) {
|
|
if (!statsNode || typeof statsNode !== 'object') return [];
|
|
const header = str(statsNode.Header_Line_Idx) || '';
|
|
const columns = header.split(',').map((c) => c.trim()).filter(Boolean);
|
|
|
|
const lines = Object.entries(statsNode)
|
|
.filter(([k]) => /^Statistics_Line_\d+$/.test(k) || /^Value_Line_\d+$/.test(k))
|
|
.sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0]))
|
|
.map(([, v]) => (typeof v === 'string' ? v.trim() : ''));
|
|
|
|
return lines.filter(Boolean).map((line) => {
|
|
const parts = parseStatisticsCsvLine(line);
|
|
const row = { raw: line };
|
|
if (columns.length === parts.length) {
|
|
for (let i = 0; i < columns.length; i++) {
|
|
const col = columns[i].toLowerCase();
|
|
if (/^rpn$/i.test(col)) row.rpn = parts[i].replace(/^'|'$/g, '');
|
|
else if (/mac/i.test(col)) row.mac = normalizeMac(parts[i]);
|
|
else if (/^op\[s\]/i.test(col)) row.opSeconds = numOrNull(parts[i]);
|
|
else if (/^dt\[s\]/i.test(col)) row.dtSeconds = numOrNull(parts[i]);
|
|
else if (/call cnt/i.test(col)) row.callCount = numOrNull(parts[i]);
|
|
}
|
|
} else if (parts.length >= 4) {
|
|
row.rpn = parts[0].replace(/^'|'$/g, '');
|
|
row.mac = normalizeMac(parts[1]);
|
|
row.opSeconds = numOrNull(parts[2]);
|
|
row.dtSeconds = numOrNull(parts[3]);
|
|
if (parts.length >= 5) row.callCount = numOrNull(parts[4]);
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
function parseStatisticsCsvLine(line) {
|
|
const parts = [];
|
|
const re = /'([^']*)'|([^,]+)/g;
|
|
let m;
|
|
while ((m = re.exec(line)) !== null) {
|
|
parts.push((m[1] != null ? m[1] : m[2]).trim());
|
|
}
|
|
return parts.length > 0 ? parts : line.split(',').map((p) => p.trim());
|
|
}
|