From 2b8c4e06aa22626823164400562e9587870d00b5 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 28 Jul 2026 09:02:55 -0400 Subject: [PATCH] Improve /dectstatus with handset RF context and cleaner base cards. Surface handset registrations and RSSI, tighten reboot health to 7 days, and consolidate Base / Handsets & RF / Network & RTP sections. Co-authored-by: Cursor --- commands/dectStatus.js | 6 +- commands/help.js | 5 +- dect-relay-agent/README.md | 22 +- dect-relay-agent/index.js | 24 +- index.js | 24 + integrations/cisco-dect/statusXml.js | 286 ++++++- integrations/cisco-dect/statusXmlInventory.js | 91 +++ services/dectCollectorService.js | 141 +++- services/dectDiscovery.js | 1 + services/dectRelayHub.js | 10 + services/dectStatus/buildHandsetContext.js | 70 ++ services/dectStatus/captureRawXml.js | 93 +++ services/dectStatus/matchHandsetByMac.js | 50 ++ services/renderers/dectStatusRenderer.js | 337 +++++--- tests/dectDiscovery.test.js | 10 + tests/dectStatus.buildHandsetContext.test.js | 34 + .../dect/status-933-f635de.inventory.json | 740 ++++++++++++++++++ .../dect/status-933-f635de.parsed.json | 169 ++++ tests/fixtures/dect/status-933-f635de.xml | 190 +++++ .../dect/status-933-f63642.inventory.json | 386 +++++++++ .../dect/status-933-f63642.parsed.json | 169 ++++ tests/fixtures/dect/status-933-f63642.xml | 92 +++ tests/fixtures/dect/status-with-handsets.xml | 86 ++ tests/renderers.test.js | 96 ++- tests/statusXml.handsets.test.js | 81 ++ tests/statusXml.test.js | 49 +- 26 files changed, 3128 insertions(+), 134 deletions(-) create mode 100644 integrations/cisco-dect/statusXmlInventory.js create mode 100644 services/dectStatus/buildHandsetContext.js create mode 100644 services/dectStatus/captureRawXml.js create mode 100644 services/dectStatus/matchHandsetByMac.js create mode 100644 tests/dectStatus.buildHandsetContext.test.js create mode 100644 tests/fixtures/dect/status-933-f635de.inventory.json create mode 100644 tests/fixtures/dect/status-933-f635de.parsed.json create mode 100644 tests/fixtures/dect/status-933-f635de.xml create mode 100644 tests/fixtures/dect/status-933-f63642.inventory.json create mode 100644 tests/fixtures/dect/status-933-f63642.parsed.json create mode 100644 tests/fixtures/dect/status-933-f63642.xml create mode 100644 tests/fixtures/dect/status-with-handsets.xml create mode 100644 tests/statusXml.handsets.test.js diff --git a/commands/dectStatus.js b/commands/dectStatus.js index 6cf7715..72a1ede 100644 --- a/commands/dectStatus.js +++ b/commands/dectStatus.js @@ -4,8 +4,7 @@ // on-prem relay agent. Complements the compact // follow-up that /phonestatus already posts: // this is the full status.xml dump (device, -// firmware, reboot log, network, RTP, security, -// emergency numbers) plus chat-only action cards +// firmware, reboot log, network, RTP) plus chat-only action cards // for reboot / force-reboot / factory-reset. // // Chat surface @@ -30,6 +29,7 @@ import { discoverDectBases } from '../services/dectDiscovery.js'; import { collectAll, execAction } from '../services/dectCollectorService.js'; import { getDectRelayHub } from '../services/dectRelayHub.js'; import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js'; +import { buildHandsetContext } from '../services/dectStatus/buildHandsetContext.js'; import { pendingDectActions } from '../utils/pendingDectActions.js'; import { extractRequester, describeRequester } from '../utils/requester.js'; import { logger } from '../utils/logger.js'; @@ -144,10 +144,12 @@ export async function handleDectStatus(bot, trigger) { ); const results = await collectAll(targets); + const handsetCtx = buildHandsetContext(phoneData); const md = renderDectStatusMarkdown(results, { storeNum, relay: relayStatus, discoveryWarnings, + handsetCtx, }); await bot.say('markdown', md || 'No data available.'); diff --git a/commands/help.js b/commands/help.js index 278bf71..bc8f66a 100644 --- a/commands/help.js +++ b/commands/help.js @@ -19,7 +19,7 @@ const SHORT_HELP = { // AV / phones avstatus: 'AV / device status for a store (alias: /wostatus)', phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)', - dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)', + dectstatus: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)', voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards', callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)', calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)', @@ -80,7 +80,8 @@ const LONG_HELP = { ], notes: [ 'Aliases: `/dect`.', - 'Pulls the full `status.xml` dump from every reachable DBS-210 base at the store via the on-prem DECT relay agent (device, firmware, reboot log, network stats, RTP, security, emergency numbers, health verdict).', + 'Pulls the full `status.xml` dump from every reachable DBS-210 base at the store via the on-prem DECT relay agent (device, firmware, handsets & RF, reboot log, network stats, RTP, health verdict).', + 'Also shows **Webex handset registrations** per base (extension, last registration) and **RF signal (RSSI)** from the base when handsets are registered.', 'Optional second arg filters to one base by IP, MAC, or name substring.', 'Chat-only action cards per reachable base: **Reboot**, **Force Reboot**, **Factory Reset**. Each requires a confirm click; outcomes are audited under `dect:audit`.', 'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center. When the relay is offline the command still reports discovery results and the offline state.', diff --git a/dect-relay-agent/README.md b/dect-relay-agent/README.md index fd30e7f..9695a4a 100644 --- a/dect-relay-agent/README.md +++ b/dect-relay-agent/README.md @@ -121,10 +121,30 @@ All frames are JSON, one per WebSocket message. { "type": "hello", "agentVersion": "0.1.0", "hostname": "dc-dect-relay-01", - "capabilities": ["collect","reboot","force-reboot","reboot-chain", + "capabilities": ["collect","collect-raw","reboot","force-reboot","reboot-chain", "force-reboot-chain","factory-reset","reconfigure-tree"] } ``` +**Bot → Agent (collect with raw XML for fixture capture):** +```json +{ "id": "cmd_", "type": "collect", "baseIp": "10.4.11.87", "includeRaw": true } +``` + +Legacy agents may also accept `{ "type": "collect-raw", "baseIp": "..." }`. + +**Agent → Bot (collect + includeRaw reply):** +```json +{ "id": "cmd_", "ok": true, "elapsedMs": 812, + "result": { "rawXml": "...", "byteLength": 12345, + "sectionInventory": [...], "parsed": { ... }, "verdict": { ... } } } +``` + +**Capture script (from repo root, bot running with relay connected):** +```bash +node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/ +# Optional: --base 10.4.11.87 --url https://your-bot-host/CollabSupport +``` + **Bot → Agent (command):** ```json { "id": "cmd_", "type": "collect", "baseIp": "10.4.11.87" } diff --git a/dect-relay-agent/index.js b/dect-relay-agent/index.js index 5671c1b..112ad6c 100644 --- a/dect-relay-agent/index.js +++ b/dect-relay-agent/index.js @@ -36,6 +36,7 @@ import { parseStatusXml, summarizeBaseHealth, } from '../integrations/cisco-dect/statusXml.js'; +import { inventoryStatusXml } from '../integrations/cisco-dect/statusXmlInventory.js'; // ─── Config ───────────────────────────────────────────────────────── @@ -49,9 +50,10 @@ const CFG = { reconnectMaxMs: Number(process.env.DECT_RELAY_RECONNECT_MAX_MS) || 30_000, }; -const AGENT_VERSION = '0.1.0'; +const AGENT_VERSION = '0.2.0'; const CAPABILITIES = [ 'collect', + 'collect-raw', 'reboot', 'force-reboot', 'reboot-chain', @@ -247,7 +249,8 @@ async function dispatch(cmd) { }); switch (cmd.type) { - case 'collect': { + case 'collect': + case 'collect-raw': { const started = Date.now(); const resp = await client.get('/admin/status.xml'); const elapsedMs = Date.now() - started; @@ -256,9 +259,22 @@ async function dispatch(cmd) { err.code = 'BASE_BAD_STATUS'; throw err; } - const parsed = parseStatusXml(resp.data); + const rawXml = resp.data; + const parsed = parseStatusXml(rawXml); const verdict = summarizeBaseHealth(parsed); - return { parsed, verdict, fetchedInMs: elapsedMs }; + const base = { parsed, verdict, fetchedInMs: elapsedMs }; + const wantRaw = cmd.includeRaw === true + || cmd.includeRaw === 'true' + || cmd.type === 'collect-raw'; + if (wantRaw) { + return { + ...base, + rawXml, + byteLength: Buffer.byteLength(rawXml, 'utf8'), + sectionInventory: inventoryStatusXml(rawXml), + }; + } + return base; } // All mutating actions are one-shot GETs (see integrations/cisco- diff --git a/index.js b/index.js index 95e462e..1241a8a 100644 --- a/index.js +++ b/index.js @@ -169,6 +169,30 @@ app.get('/phone/devices/build/:storeNumber', dataAuthGate, async (req, res) => { } }); +import { captureDectRawXml } from './services/dectStatus/captureRawXml.js'; +app.get('/api/dect/raw-xml/:storeNumber', dataAuthGate, async (req, res) => { + const storeNumber = (req.params.storeNumber || '').trim(); + const baseFilter = (req.query.base || req.query.ip || req.query.mac || '').trim(); + + try { + const result = await captureDectRawXml(storeNumber, { baseFilter: baseFilter || undefined }); + res.json(result); + } catch (err) { + const code = err?.code || 'CAPTURE_FAILED'; + const status = code === 'INVALID_STORE' ? 400 + : code === 'BASE_NOT_FOUND' ? 404 + : code === 'RELAY_NOT_CONFIGURED' ? 503 + : 500; + logger('dect:capture', `raw-xml failed for store ${storeNumber}: ${err.message}`, 'error'); + res.status(status).json({ + success: false, + code, + message: err.message, + knownBases: err.knownBases || undefined, + }); + } +}); + app.get('/api/av/device/:storeNumber/:identifier', dataAuthGate, async (req, res) => { const result = await getShapedDeviceData(req.params.storeNumber, req.params.identifier); res.json(result); diff --git a/integrations/cisco-dect/statusXml.js b/integrations/cisco-dect/statusXml.js index 0902316..82adb2c 100644 --- a/integrations/cisco-dect/statusXml.js +++ b/integrations/cisco-dect/statusXml.js @@ -172,6 +172,10 @@ export function parseStatusXml(xml) { 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: { @@ -236,13 +240,16 @@ export function parseStatusXml(xml) { }, 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 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. + // for companion per-RPN statistics rows (see perRpnStats). + statisticsHeader: str(stats.Header_Line_Idx), _rawStatisticsHeader: str(stats.Header_Line_Idx), }; } @@ -255,9 +262,27 @@ export function parseStatusXml(xml) { // 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 @@ -277,12 +302,14 @@ export function summarizeBaseHealth(parsed) { 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) { + // 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( - `${powerLosses.length} recent power-loss reboot(s); most recent at ${powerLosses[0].at}`, + `${badReboots.length} concerning reboot(s) in the last ${REBOOT_HEALTH_WARN_DAYS} days ` + + `(latest: ${label} at ${latest.at})`, ); } @@ -362,11 +389,23 @@ function normalizeMac(mac) { * 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]+)/); + 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: m ? m[1].toLowerCase() : null, // "unchained" | "primary" | "secondary" + role, + state: role && state && role !== state ? state : null, raw: s, }; } @@ -377,10 +416,16 @@ function parseMultiCell(raw) { */ function parseOperatingSeconds(v) { if (!v) return null; - const m = String(v).match(/(\d+):(\d+):(\d+)/); + 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, s] = m; - return Number(h) * 3600 + Number(mi) * 60 + Number(s); + const [, h, mi, sec] = m; + return Number(h) * 3600 + Number(mi) * 60 + Number(sec); } function collectRebootLog(rebootLogNode) { @@ -430,10 +475,209 @@ function extractDeviceFwu(node) { 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] })); + 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()); } diff --git a/integrations/cisco-dect/statusXmlInventory.js b/integrations/cisco-dect/statusXmlInventory.js new file mode 100644 index 0000000..625acae --- /dev/null +++ b/integrations/cisco-dect/statusXmlInventory.js @@ -0,0 +1,91 @@ +// integrations/cisco-dect/statusXmlInventory.js +// +// Walk a parsed status.xml tree and list every leaf path with a short +// preview. Used by collect-raw tooling to spot populated sections +// (RSSI_List, Device_Presence, SIP_Identity_Status, etc.) without +// hand-reading XML. + +import { xmlToObject } from './statusXml.js'; + +const HANDSET_RELATED = /rssi|presence|sip_identity|device_line|statistics|rpn|mac/i; + +/** + * @typedef {object} SectionInventoryEntry + * @property {string} path dot-separated path from root + * @property {boolean} isEmpty true when leaf is absent or whitespace-only + * @property {string|null} preview truncated leaf value (null for empty containers) + * @property {boolean} handsetRelated heuristic flag for operator summaries + */ + +/** + * Build a flat inventory of leaf paths from raw status.xml text. + * + * @param {string} xml + * @returns {SectionInventoryEntry[]} + */ +export function inventoryStatusXml(xml) { + if (typeof xml !== 'string' || !xml.trim()) return []; + const tree = xmlToObject(xml); + const entries = []; + walk(tree, '', entries); + return entries; +} + +/** + * Summarize non-empty handset-related paths for console output. + * + * @param {SectionInventoryEntry[]} inventory + * @returns {{ nonEmpty: number, handsetRelated: number, paths: string[] }} + */ +export function summarizeInventory(inventory) { + const list = Array.isArray(inventory) ? inventory : []; + const nonEmpty = list.filter((e) => !e.isEmpty); + const handsetPaths = nonEmpty + .filter((e) => e.handsetRelated) + .map((e) => e.path); + return { + nonEmpty: nonEmpty.length, + handsetRelated: handsetPaths.length, + paths: handsetPaths, + }; +} + +function walk(node, prefix, out) { + if (node == null) { + pushLeaf(prefix, '', out); + return; + } + if (typeof node === 'string') { + pushLeaf(prefix, node, out); + return; + } + if (typeof node !== 'object') { + pushLeaf(prefix, String(node), out); + return; + } + const keys = Object.keys(node); + if (keys.length === 0) { + pushLeaf(prefix, '', out); + return; + } + for (const key of keys) { + const path = prefix ? `${prefix}.${key}` : key; + walk(node[key], path, out); + } +} + +function pushLeaf(path, value, out) { + const str = value == null ? '' : String(value).trim(); + const isEmpty = str === ''; + out.push({ + path, + isEmpty, + preview: isEmpty ? null : truncate(str, 120), + handsetRelated: HANDSET_RELATED.test(path), + }); +} + +function truncate(s, max) { + if (s.length <= max) return s; + return `${s.slice(0, max - 1)}…`; +} diff --git a/services/dectCollectorService.js b/services/dectCollectorService.js index af5d724..b056a1a 100644 --- a/services/dectCollectorService.js +++ b/services/dectCollectorService.js @@ -10,6 +10,7 @@ // the Jira poller in Phase 3) owns presentation. import { getDectRelayHub, RelayErrorCodes } from './dectRelayHub.js'; +import { parseStatusXml, summarizeBaseHealth } from '../integrations/cisco-dect/statusXml.js'; import { logger } from '../utils/logger.js'; const LOG_SCOPE = 'dect:collector'; @@ -65,6 +66,119 @@ export async function collectAll(bases, opts = {}) { * command's individual "refresh this base" flow — collectAll uses it * internally. */ +/** + * @typedef {object} BaseCollectRawResult + * @property {BaseTarget} base + * @property {boolean} ok + * @property {string|null} rawXml + * @property {number|null} byteLength + * @property {object|null} data + * @property {object|null} verdict + * @property {Array|null} sectionInventory + * @property {number|null} elapsedMs + * @property {object|null} error + */ + +/** + * Run `collect-raw` against every base (includes raw XML + inventory). + * + * @param {BaseTarget[]} bases + * @param {object} [opts] + * @returns {Promise} + */ +export async function collectRawAll(bases, opts = {}) { + const list = Array.isArray(bases) ? bases : []; + if (list.length === 0) return []; + const hub = opts.hub || getDectRelayHub(); + const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; + + logger(LOG_SCOPE, `Fanning out collect-raw() to ${list.length} base(s)`, 'debug'); + + const results = await Promise.all(list.map((base) => collectRawOne(hub, base, timeoutMs))); + + const okCount = results.filter((r) => r.ok).length; + logger(LOG_SCOPE, `Collect-raw finished: ${okCount}/${list.length} succeeded`, 'debug'); + return results; +} + +export async function collectRawOne(hub, base, timeoutMs = DEFAULT_TIMEOUT_MS) { + if (!base?.ip) { + return { + base, ok: false, rawXml: null, byteLength: null, data: null, verdict: null, + sectionInventory: null, elapsedMs: null, + error: { code: 'NO_IP', message: 'base has no IP address' }, + }; + } + const started = Date.now(); + try { + let result; + let elapsedMs; + try { + ({ result, elapsedMs } = await hub.collectRaw(base.ip, { timeoutMs })); + } catch (err) { + if (err?.code === 'UNKNOWN_COMMAND' || /unknown command type/i.test(err?.message || '')) { + ({ result, elapsedMs } = await hub.collectRawLegacy(base.ip, { timeoutMs })); + } else { + throw err; + } + } + + if (!result?.rawXml) { + return { + base, + ok: false, + rawXml: null, + byteLength: null, + data: result?.parsed || null, + verdict: result?.verdict || null, + sectionInventory: null, + elapsedMs: elapsedMs ?? (Date.now() - started), + error: { + code: 'AGENT_OUTDATED', + message: 'Relay agent returned parsed status but not raw XML', + hint: + 'Redeploy dect-relay-agent on the DC host (separate from the bot container). ' + + 'From the repo root: ./dect-relay-agent/bundle.sh, then install on the DC host ' + + 'and docker compose restart dect-relay-agent. Agent v0.2.0+ supports includeRaw on collect.', + }, + }; + } + + return { + base, + ok: true, + rawXml: result.rawXml, + byteLength: result.byteLength ?? null, + data: result.parsed || null, + verdict: result.verdict || null, + sectionInventory: result.sectionInventory || null, + elapsedMs: elapsedMs ?? (Date.now() - started), + error: null, + }; + } catch (err) { + const code = err?.code || 'UNKNOWN'; + const hint = hintFor(code) + || (/unknown command type/i.test(err?.message || '') + ? 'Redeploy dect-relay-agent on the DC host — the bot container does not run the relay agent.' + : null); + return { + base, + ok: false, + rawXml: null, + byteLength: null, + data: null, + verdict: null, + sectionInventory: null, + elapsedMs: Date.now() - started, + error: { + code, + message: err?.message || String(err), + hint, + }, + }; + } +} + export async function collectOne(hub, base, timeoutMs = DEFAULT_TIMEOUT_MS) { if (!base?.ip) { return { @@ -74,12 +188,33 @@ export async function collectOne(hub, base, timeoutMs = DEFAULT_TIMEOUT_MS) { } const started = Date.now(); try { - const { result, elapsedMs } = await hub.collect(base.ip, { timeoutMs }); + let result; + let elapsedMs; + try { + ({ result, elapsedMs } = await hub.collectRaw(base.ip, { timeoutMs })); + } catch (err) { + if (err?.code === 'UNKNOWN_COMMAND' || /unknown command type/i.test(err?.message || '')) { + try { + ({ result, elapsedMs } = await hub.collectRawLegacy(base.ip, { timeoutMs })); + } catch { + ({ result, elapsedMs } = await hub.collect(base.ip, { timeoutMs })); + } + } else { + throw err; + } + } + + // Prefer bot-side parsing so handset/RSSI fields stay current even when + // the DC relay agent ships an older statusXml.js. + const data = result?.rawXml + ? parseStatusXml(result.rawXml) + : (result?.parsed || result || null); + return { base, ok: true, - data: result?.parsed || result || null, - verdict: result?.verdict || null, + data, + verdict: data ? summarizeBaseHealth(data) : (result?.verdict || null), elapsedMs: elapsedMs ?? (Date.now() - started), error: null, }; diff --git a/services/dectDiscovery.js b/services/dectDiscovery.js index a96a9f3..263dc2a 100644 --- a/services/dectDiscovery.js +++ b/services/dectDiscovery.js @@ -82,6 +82,7 @@ export function discoverDectBases(phoneStatus) { mac, ip, name, + webexId: base.id || null, // Track where the IP came from — useful in logs if a base is // reachable via one source but not the other. source: base.meraki?.ip ? 'meraki' : 'webex', diff --git a/services/dectRelayHub.js b/services/dectRelayHub.js index 59a8534..78eba27 100644 --- a/services/dectRelayHub.js +++ b/services/dectRelayHub.js @@ -370,6 +370,16 @@ export class DectRelayHub { return this.rpc({ type: 'collect', baseIp }, opts); } + /** Fetch status.xml including raw XML + section inventory (capture tooling). */ + collectRaw(baseIp, opts) { + return this.rpc({ type: 'collect', baseIp, includeRaw: true }, opts); + } + + /** Legacy alias — some agents only expose collect-raw as a distinct type. */ + collectRawLegacy(baseIp, opts) { + return this.rpc({ type: 'collect-raw', baseIp }, opts); + } + /** * Convenience: execute one of the mutating actions the agent * exposes (reboot / force-reboot / reboot-chain / force-reboot-chain diff --git a/services/dectStatus/buildHandsetContext.js b/services/dectStatus/buildHandsetContext.js new file mode 100644 index 0000000..24f6bc5 --- /dev/null +++ b/services/dectStatus/buildHandsetContext.js @@ -0,0 +1,70 @@ +// services/dectStatus/buildHandsetContext.js +// +// Group Webex DECT handset inventory for /dectstatus rendering. + +/** + * @param {object|null} phoneData collectPhoneStatus() output + * @returns {object} + */ +export function buildHandsetContext(phoneData) { + const basestations = Array.isArray(phoneData?.dectBasestations) + ? phoneData.dectBasestations + : []; + const handsets = Array.isArray(phoneData?.dectHandsets) + ? phoneData.dectHandsets + : []; + + const handsetsByWebexId = new Map(); + const linesRegisteredByWebexId = new Map(); + const webexIdByMac = new Map(); + + for (const base of basestations) { + if (base.id) { + handsetsByWebexId.set(base.id, []); + linesRegisteredByWebexId.set(base.id, base.linesRegistered ?? null); + } + const mac = normalizeMacLoose(base.mac); + if (base.id && mac) webexIdByMac.set(mac, base.id); + } + + const unassignedHandsets = []; + + for (const h of handsets) { + const baseId = h.baseStationId || null; + if (baseId && handsetsByWebexId.has(baseId)) { + handsetsByWebexId.get(baseId).push(h); + } else if (!baseId) { + unassignedHandsets.push(h); + } else { + // Assigned to a base id we don't know — treat as unassigned for display. + unassignedHandsets.push(h); + } + } + + return { + handsetsByWebexId, + linesRegisteredByWebexId, + webexIdByMac, + unassignedHandsets, + dectNetwork: phoneData?.dectNetwork || null, + }; +} + +/** + * Handsets registered to a base (by Webex base id). + * + * @param {object} handsetCtx buildHandsetContext() output + * @param {string|null} webexId + * @returns {object[]} + */ +export function handsetsForBase(handsetCtx, webexId) { + if (!webexId || !handsetCtx?.handsetsByWebexId) return []; + return handsetCtx.handsetsByWebexId.get(webexId) || []; +} + +function normalizeMacLoose(mac) { + if (!mac || typeof mac !== 'string') return null; + const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (hex.length !== 12) return null; + return hex.match(/../g).join(':'); +} diff --git a/services/dectStatus/captureRawXml.js b/services/dectStatus/captureRawXml.js new file mode 100644 index 0000000..b91972d --- /dev/null +++ b/services/dectStatus/captureRawXml.js @@ -0,0 +1,93 @@ +// services/dectStatus/captureRawXml.js +// +// Fetch raw status.xml from store DECT bases via the on-prem relay. +// Used by GET /api/dect/raw-xml/:storeNumber and scripts/fetchDectStatusXml.js. + +import { collectPhoneStatus } from '../phoneService.js'; +import { discoverDectBases } from '../dectDiscovery.js'; +import { collectRawAll } from '../dectCollectorService.js'; +import { getDectRelayHub } from '../dectRelayHub.js'; +import { logger } from '../../utils/logger.js'; + +const LOG_SCOPE = 'dect:capture'; + +/** + * @param {string} storeNum + * @param {object} [opts] + * @param {string} [opts.baseFilter] optional IP or MAC filter + * @returns {Promise} + */ +export async function captureDectRawXml(storeNum, opts = {}) { + const store = String(storeNum || '').trim(); + if (!store || !/^\d{2,4}$/.test(store)) { + const err = new Error('storeNum must be a 2–4 digit store number'); + err.code = 'INVALID_STORE'; + throw err; + } + + if (!process.env.DECT_RELAY_AGENT_TOKEN) { + const err = new Error('DECT_RELAY_AGENT_TOKEN is not configured on this bot'); + err.code = 'RELAY_NOT_CONFIGURED'; + throw err; + } + + let relay = null; + try { + relay = getDectRelayHub().status(); + } catch (err) { + logger(LOG_SCOPE, `Relay status unavailable: ${err.message}`, 'warn'); + relay = { connected: false }; + } + + const phoneData = await collectPhoneStatus(store); + const { bases, warnings: discoveryWarnings } = discoverDectBases(phoneData || {}); + + let targets = bases; + const baseFilter = (opts.baseFilter || '').trim(); + if (baseFilter) { + targets = filterBases(bases, baseFilter); + if (targets.length === 0) { + const err = new Error(`No discovered base matched "${baseFilter}" for store ${store}`); + err.code = 'BASE_NOT_FOUND'; + err.knownBases = bases.map((b) => ({ ip: b.ip, mac: b.mac, name: b.name })); + throw err; + } + } + + const results = await collectRawAll(targets); + + return { + storeNum: store, + relay, + discoveryWarnings, + bases: results.map((r) => ({ + mac: r.base?.mac || null, + ip: r.base?.ip || null, + webexId: r.base?.webexId || null, + name: r.base?.name || null, + ok: r.ok, + byteLength: r.byteLength, + rawXml: r.rawXml, + sectionInventory: r.sectionInventory, + parsed: r.data, + verdict: r.verdict, + elapsedMs: r.elapsedMs, + error: r.error, + })), + }; +} + +function filterBases(bases, raw) { + const needle = String(raw).trim().toLowerCase(); + const needleMac = needle.replace(/[^0-9a-f]/g, ''); + return bases.filter((b) => { + if (b.ip && b.ip.toLowerCase() === needle) return true; + if (b.mac) { + const macHex = b.mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (macHex === needleMac && needleMac.length === 12) return true; + if (b.mac.toLowerCase() === needle) return true; + } + if (b.name && b.name.toLowerCase().includes(needle)) return true; + return false; + }); +} diff --git a/services/dectStatus/matchHandsetByMac.js b/services/dectStatus/matchHandsetByMac.js new file mode 100644 index 0000000..1dce3e5 --- /dev/null +++ b/services/dectStatus/matchHandsetByMac.js @@ -0,0 +1,50 @@ +// services/dectStatus/matchHandsetByMac.js +// +// Correlate base-local RSSI / statistics rows with Webex handset records. + +import { normalizeMac } from '../dectDiscovery.js'; + +/** + * @param {object|null} handset Webex handset record + * @param {object} rssiRow parsed RSSI entry from status.xml + * @returns {boolean} + */ +export function handsetMatchesRssiRow(handset, rssiRow) { + const hMac = normalizeMac(handset?.mac); + const rMac = normalizeMac(rssiRow?.mac); + return !!(hMac && rMac && hMac === rMac); +} + +/** + * Find the Webex handset matching an RSSI row (by MAC). + * + * @param {object} rssiRow + * @param {object[]} handsets + * @returns {object|null} + */ +export function findHandsetForRssiRow(rssiRow, handsets) { + const list = Array.isArray(handsets) ? handsets : []; + return list.find((h) => handsetMatchesRssiRow(h, rssiRow)) || null; +} + +/** + * Build display rows merging RSSI with optional Webex handset metadata. + * + * @param {object[]} rssiRows parsed status.rssi + * @param {object[]} handsets Webex handsets for this base + * @returns {object[]} + */ +export function mergeRssiWithHandsets(rssiRows, handsets) { + const rows = Array.isArray(rssiRows) ? rssiRows : []; + const list = Array.isArray(handsets) ? handsets : []; + return rows.map((row) => { + const handset = findHandsetForRssiRow(row, list); + return { + rpn: row.rpn, + mac: row.mac, + rssiDbm: row.rssiDbm, + handsetName: handset?.name || null, + extension: handset?.extension || null, + }; + }); +} diff --git a/services/renderers/dectStatusRenderer.js b/services/renderers/dectStatusRenderer.js index 7675bf6..8f9b81c 100644 --- a/services/renderers/dectStatusRenderer.js +++ b/services/renderers/dectStatusRenderer.js @@ -1,15 +1,11 @@ // src/services/renderers/dectStatusRenderer.js // // Full CLI-style DECT base dump for `/dectstatus`. Complements the -// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus): -// that one is "exceptions only"; this one is the complete status.xml -// picture (device, firmware, reboot log, network, RTP, security, -// emergency numbers, health verdict). -// -// Input is the same collectAll() result array used by the compact -// renderer. Pure — no I/O, no env. +// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus). -import { formatDisplayTime } from '../../utils/time.js'; +import { formatDisplayTime, simpleTimeAgo } from '../../utils/time.js'; +import { handsetsForBase } from '../dectStatus/buildHandsetContext.js'; +import { mergeRssiWithHandsets } from '../dectStatus/matchHandsetByMac.js'; /** * @param {Array} results collectAll() output @@ -18,6 +14,7 @@ import { formatDisplayTime } from '../../utils/time.js'; * @param {boolean} [opts.footer=true] * @param {object} [opts.relay] optional hub.status() snapshot * @param {Array} [opts.discoveryWarnings] + * @param {object} [opts.handsetCtx] buildHandsetContext() output * @returns {string} */ export function renderDectStatusMarkdown(results, opts = {}) { @@ -26,11 +23,18 @@ export function renderDectStatusMarkdown(results, opts = {}) { footer = true, relay = null, discoveryWarnings = [], + handsetCtx = null, } = opts; const list = Array.isArray(results) ? results : []; const lines = [`**DECT Status — Store ${storeNum}**`, '']; + if (handsetCtx?.dectNetwork) { + const net = handsetCtx.dectNetwork; + lines.push(`_DECT network: ${net.name || '—'} (assigned handsets: ${net.handsetsCount ?? '—'})_`); + lines.push(''); + } + if (relay) { if (relay.connected) { const agent = relay.agent?.hostname || relay.agent?.version || 'connected'; @@ -51,6 +55,7 @@ export function renderDectStatusMarkdown(results, opts = {}) { lines.push(`- ${who}: ${w.reason}`); } } + lines.push(...renderUnassignedHandsets(handsetCtx)); if (footer) { lines.push(''); lines.push(`_Pulled at ${formatDisplayTime()}._`); @@ -60,9 +65,11 @@ export function renderDectStatusMarkdown(results, opts = {}) { for (let i = 0; i < list.length; i++) { if (i > 0) lines.push('', '---', ''); - lines.push(...renderOneBaseFull(list[i])); + lines.push(...renderOneBaseFull(list[i], handsetCtx)); } + lines.push(...renderUnassignedHandsets(handsetCtx)); + if (discoveryWarnings.length > 0) { lines.push('', '**Discovery notes (skipped bases):**'); for (const w of discoveryWarnings) { @@ -82,10 +89,11 @@ export function renderDectStatusMarkdown(results, opts = {}) { return lines.join('\n').trim(); } -function renderOneBaseFull(r) { +function renderOneBaseFull(r, handsetCtx) { const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`; const ip = r.base?.ip || '?'; const mac = r.base?.mac || '?'; + const webexId = r.base?.webexId || null; const lines = []; if (!r.ok) { @@ -94,6 +102,7 @@ function renderOneBaseFull(r) { lines.push(`- Collect failed: ${r.error?.message || 'unknown error'}`); if (r.error?.hint) lines.push(`- _${r.error.hint}_`); if (r.elapsedMs != null) lines.push(`- Elapsed: ${r.elapsedMs}ms`); + lines.push(...renderHandsetsAndRfSection({}, handsetCtx, webexId)); return lines; } @@ -101,98 +110,248 @@ function renderOneBaseFull(r) { const verdict = r.verdict || {}; const icon = verdict.healthy ? '✅' : '⚠️'; lines.push(`${icon} **${label}**`); + lines.push(...renderHealthNotes(verdict)); - // ── Device ── - lines.push('', '**Device**'); - row(lines, 'Model', p.device?.model); - row(lines, 'System type', p.device?.systemType); - row(lines, 'Unit', [p.device?.unitName, p.device?.unitIndex].filter(Boolean).join(' · ') || null); - row(lines, 'MAC', p.device?.macAddress || mac); - row(lines, 'IP', p.device?.ipAddress || ip); - row(lines, 'RFPI', p.device?.rfpiAddress); - row(lines, 'RF band', p.device?.rfBand); - row(lines, 'Multi-cell', p.multiCell?.role || p.multiCell?.raw); - row(lines, 'Base status', p.baseStatus); - row(lines, 'Conflict', p.conflictInfo); + lines.push(...renderHandsetsAndRfSection(p, handsetCtx, webexId)); + lines.push(...renderBaseSection(p, { mac, ip, elapsedMs: r.elapsedMs })); + lines.push(...renderRebootLogSection(p.rebootLog)); + lines.push(...renderTrafficSection(p.network, p.rtp)); - // ── Firmware ── - lines.push('', '**Firmware**'); - row(lines, 'Version', p.firmware?.version); - row(lines, 'Update server', p.firmware?.updateServer); - row(lines, 'Update path', p.firmware?.updatePath); + return lines; +} - // ── Time ── - lines.push('', '**Time / uptime**'); - row(lines, 'Local time', p.time?.currentLocalTime); - row(lines, 'Uptime', p.time?.operatingTime); - if (r.elapsedMs != null) row(lines, 'Collect latency', `${r.elapsedMs}ms`); +function renderHealthNotes(verdict) { + const warnings = verdict.warnings || []; + const info = verdict.info || []; + if (warnings.length === 0 && info.length === 0) return []; - // ── Reboot log ── - lines.push('', '**Reboot log** (newest first)'); - const log = Array.isArray(p.rebootLog) ? p.rebootLog : []; + const lines = []; + for (const w of warnings) lines.push(`- ⚠️ ${w}`); + for (const i of info) lines.push(`- ℹ️ ${i}`); + return lines; +} + +function renderBaseSection(p, { mac, ip, elapsedMs }) { + const lines = ['', '**Base**']; + const modelBits = [p.device?.model, p.device?.systemType].filter(Boolean); + if (modelBits.length) lines.push(`- ${modelBits.join(' · ')}`); + + const identityBits = [ + p.device?.macAddress || mac, + p.device?.ipAddress || ip, + p.device?.rfpiAddress, + p.device?.rfBand ? `${p.device.rfBand} band` : null, + ].filter(Boolean); + if (identityBits.length) lines.push(`- ${identityBits.join(' · ')}`); + + const unitBits = [p.device?.unitName, p.device?.unitIndex].filter(Boolean); + const statusBits = [ + formatMultiCell(p.multiCell), + p.baseStatus, + p.conflictInfo, + ].filter(Boolean); + const opsBits = [...unitBits, ...statusBits].filter(Boolean); + if (opsBits.length) lines.push(`- ${opsBits.join(' · ')}`); + + const fw = p.firmware?.version; + const uptimeBits = [ + p.time?.operatingTime ? `uptime ${p.time.operatingTime}` : null, + p.time?.currentLocalTime ? `local ${p.time.currentLocalTime}` : null, + elapsedMs != null ? `collect ${elapsedMs}ms` : null, + ].filter(Boolean); + const fwLine = [fw, ...uptimeBits].filter(Boolean); + if (fwLine.length) lines.push(`- FW ${fwLine.join(' · ')}`); + + return lines; +} + +function renderRebootLogSection(rebootLog) { + const log = Array.isArray(rebootLog) ? rebootLog : []; + const lines = ['', '**Reboot log** (newest first)']; if (log.length === 0) { lines.push('- _(none)_'); - } else { - for (const entry of log) { - if (entry.unrecognized) { - lines.push(`- ??? ${entry.raw || ''}`); - continue; - } - const tag = entry.reasonCode === 80 ? '⚡' : '•'; - lines.push( - `- ${tag} #${entry.sequence} ${entry.at} **${entry.reasonName}** (${entry.reasonCode}) fw=${entry.firmwareAtBoot || '?'}`, - ); + return lines; + } + + for (const entry of log) { + if (entry.unrecognized) { + lines.push(`- ??? ${entry.raw || ''}`); + continue; + } + const tag = (entry.reasonCode === 80 || entry.reasonCode === 43) ? '⚡' : '•'; + lines.push( + `- ${tag} #${entry.sequence} ${entry.at} **${entry.reasonName}** (${entry.reasonCode}) fw=${entry.firmwareAtBoot || '?'}`, + ); + } + return lines; +} + +function renderTrafficSection(net, rtp) { + const lines = []; + const hasNet = net && Object.values(net).some((v) => v != null); + const hasRtp = rtp && Object.values(rtp).some((v) => v != null); + if (!hasNet && !hasRtp) return lines; + + lines.push('', '**Network & RTP** (since boot)'); + if (hasNet) { + lines.push(`- **TX:** ${formatNetCounters(net, 'tx')}`); + lines.push(`- **RX:** ${formatNetCounters(net, 'rx')}`); + } + if (hasRtp) { + lines.push( + `- **RTP:** ${netVal(rtp.total)} total · ${netVal(rtp.current)} active · ` + + `${netVal(rtp.currentLocal)} local · ${netVal(rtp.currentRelay)} relay`, + ); + } + return lines; +} + +const NET_COUNTER_FIELDS = { + tx: [ + ['txPackets', 'pkts'], + ['txBlocked', 'blocked'], + ['txDropped', 'dropped'], + ['txErrors', 'errors'], + ['txBroadcasts', 'bcast'], + ], + rx: [ + ['rxPackets', 'pkts'], + ['rxBlocked', 'blocked'], + ['rxDropped', 'dropped'], + ['rxErrors', 'errors'], + ['rxBroadcasts', 'bcast'], + ], +}; + +function formatNetCounters(net, direction) { + return NET_COUNTER_FIELDS[direction] + .map(([key, label]) => `${netVal(net[key])} ${label}`) + .join(' · '); +} + +function netVal(v) { + return Number.isFinite(v) ? v : 0; +} + +function formatMultiCell(multiCell) { + if (!multiCell) return null; + const role = multiCell.role ? capitalize(multiCell.role) : null; + const state = multiCell.state ? multiCell.state : null; + if (role && state) return `multi-cell ${role} (${state})`; + if (role) return `multi-cell ${role}`; + return multiCell.raw ? `multi-cell ${multiCell.raw}` : null; +} + +function capitalize(s) { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; +} + +/** + * Webex handset inventory + base status.xml handset/RSSI/presence/SIP data. + */ +function renderHandsetsAndRfSection(parsed, handsetCtx, webexId) { + const lines = ['', '**Handsets & RF**']; + let hasDetail = false; + + const linesReg = webexId && handsetCtx?.linesRegisteredByWebexId + ? handsetCtx.linesRegisteredByWebexId.get(webexId) + : null; + if (linesReg != null) { + lines.push(`- Webex lines registered on base: **${linesReg}**`); + hasDetail = true; + } + + const registered = handsetsForBase(handsetCtx, webexId); + const devices = Array.isArray(parsed?.devices) ? parsed.devices : []; + const coveredMacs = new Set( + devices.map((d) => normalizeMacLoose(d.mac)).filter(Boolean), + ); + + for (const h of registered) { + const idx = h.index != null ? `${h.index}-` : ''; + const reg = h.lastRegistrationTime + ? simpleTimeAgo(h.lastRegistrationTime) + : '—'; + const macBit = h.mac && h.mac !== '—' ? ` · ${h.mac}` : ''; + lines.push( + `- **Webex** ${idx}${h.name || 'Handset'} (ext ${h.extension || '—'}) · last reg ${reg}${macBit}`, + ); + hasDetail = true; + } + + for (const d of devices) { + const who = d.displayName || `Handset ${d.index ?? '?'}`; + const rssi = d.rssiDbm != null ? `${d.rssiDbm} dBm` : '?'; + const bat = d.batteryPercent != null ? ` · battery ${d.batteryPercent}%` : ''; + const rpn = d.lockedRpn || d.registeredRpn; + const rpnBit = rpn ? ` · ${rpn}` : ''; + const sip = d.sipState ? ` · SIP ${d.sipState}` : ''; + lines.push(`- **Base** ${who} (${d.deviceType || '?'}) · **${rssi}**${bat}${rpnBit}${sip}`); + hasDetail = true; + } + + const rssiRows = Array.isArray(parsed?.rssi) ? parsed.rssi : []; + const merged = mergeRssiWithHandsets(rssiRows, registered); + for (const row of merged) { + if (row.mac && coveredMacs.has(normalizeMacLoose(row.mac))) continue; + const rssi = row.rssiDbm != null ? `${row.rssiDbm} dBm` : '?'; + const who = row.handsetName + ? `${row.handsetName} (ext ${row.extension || '—'})` + : (row.mac || `RPN ${row.rpn ?? '?'}`); + lines.push(`- **RSSI** ${who}: **${rssi}**`); + hasDetail = true; + } + + const presence = Array.isArray(parsed?.devicePresence) ? parsed.devicePresence : []; + for (const p of presence) { + if (p.extension != null) { + const state = p.present === true ? 'present' : (p.present === false ? 'absent' : p.raw); + lines.push(`- **Presence** ext ${p.extension} (${p.deviceType || '?'}) · ${state}`); + hasDetail = true; + continue; + } + if (p.present != null) { + lines.push(`- **Presence** ${p.key}: ${p.present ? 'present' : 'absent'}`); + hasDetail = true; } } - // ── Network ── - lines.push('', '**Network stats** (since boot)'); - row(lines, 'Tx packets', p.network?.txPackets); - row(lines, 'Tx dropped', p.network?.txDropped); - row(lines, 'Tx errors', p.network?.txErrors); - row(lines, 'Rx packets', p.network?.rxPackets); - row(lines, 'Rx dropped', p.network?.rxDropped); - row(lines, 'Rx errors', p.network?.rxErrors); - row(lines, 'Rx broadcasts', p.network?.rxBroadcasts); - - // ── RTP ── - lines.push('', '**RTP**'); - row(lines, 'Total since boot', p.rtp?.total); - row(lines, 'Current active', p.rtp?.current); - row(lines, 'Current local', p.rtp?.currentLocal); - row(lines, 'Current relay', p.rtp?.currentRelay); - - // ── Security ── - lines.push('', '**Security**'); - row( - lines, - 'Custom CA', - p.security?.customCa?.installed - ? (p.security.customCa.info || 'installed') - : 'Not installed', - ); - row(lines, '802.1X protocol', p.security?.dot1x?.protocol); - row(lines, '802.1X status', p.security?.dot1x?.transactionStatus); - - // ── Emergency ── - const emerg = Array.isArray(p.emergencyNumbers) ? p.emergencyNumbers : []; - lines.push('', '**Emergency numbers**'); - lines.push(emerg.length ? `- ${emerg.join(', ')}` : '- _(none configured)_'); - - // ── Verdict ── - lines.push('', '**Health verdict**'); - lines.push(`- healthy: **${verdict.healthy ? 'YES' : 'NO'}**`); - for (const w of verdict.warnings || []) { - lines.push(`- ⚠️ ${w}`); + const sip = Array.isArray(parsed?.sipIdentityStatus) ? parsed.sipIdentityStatus : []; + for (const s of sip) { + const status = s.status || s.value || '?'; + const lineMatch = s.key?.match(/^Line_(\d+)$/i); + const who = s.sipIdx != null + ? `line ${s.sipIdx}` + : (lineMatch ? `line ${lineMatch[1]}` : s.key); + const server = s.serverName ? ` (${s.serverName})` : ''; + lines.push(`- **SIP** ${who}${server}: **${status}**`); + hasDetail = true; } - for (const i of verdict.info || []) { - lines.push(`- ℹ️ ${i}`); + + if (!hasDetail && registered.length === 0) { + lines.push('- _(no handset or RF data from Webex or base status.xml)_'); } return lines; } -function row(lines, label, value) { - if (value == null || value === '') return; - lines.push(`- **${label}:** ${value}`); +function normalizeMacLoose(mac) { + if (!mac) return null; + const hex = String(mac).replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + return hex.length === 12 ? hex : null; +} + +function renderUnassignedHandsets(handsetCtx) { + const unassigned = handsetCtx?.unassignedHandsets || []; + if (unassigned.length === 0) return []; + + const lines = ['', '**Unassigned handsets** (Webex)']; + for (const h of unassigned) { + const idx = h.index != null ? `${h.index}-` : ''; + const reg = h.lastRegistrationTime + ? simpleTimeAgo(h.lastRegistrationTime) + : '—'; + lines.push(`- **${idx}${h.name || 'Handset'}** (ext ${h.extension || '—'}) · last reg ${reg}`); + } + return lines; } diff --git a/tests/dectDiscovery.test.js b/tests/dectDiscovery.test.js index 2ea0327..04edc87 100644 --- a/tests/dectDiscovery.test.js +++ b/tests/dectDiscovery.test.js @@ -89,10 +89,20 @@ test('discoverDectBases: happy path — one Meraki-enriched base on 10.x', () => mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A', + webexId: null, source: 'meraki', }); }); +test('discoverDectBases: passes through Webex base id as webexId', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ id: 'webex-base-99', meraki: { ip: '10.4.11.87' } }), + ], + })); + assert.equal(result.bases[0].webexId, 'webex-base-99'); +}); + test('discoverDectBases: prefers Meraki IP over Webex IP', () => { const result = discoverDectBases(fixture({ dectBasestations: [ diff --git a/tests/dectStatus.buildHandsetContext.test.js b/tests/dectStatus.buildHandsetContext.test.js new file mode 100644 index 0000000..7d72f01 --- /dev/null +++ b/tests/dectStatus.buildHandsetContext.test.js @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildHandsetContext, handsetsForBase } from '../services/dectStatus/buildHandsetContext.js'; +import { mergeRssiWithHandsets } from '../services/dectStatus/matchHandsetByMac.js'; + +test('buildHandsetContext: groups handsets by base and tracks unassigned', () => { + const ctx = buildHandsetContext({ + dectNetwork: { name: 'Store DECT', handsetsCount: 3 }, + dectBasestations: [ + { id: 'base-1', mac: '6c:ab:05:f6:28:19', linesRegistered: 2 }, + { id: 'base-2', mac: '6c:ab:05:aa:bb:cc', linesRegistered: 0 }, + ], + dectHandsets: [ + { id: 'h1', name: 'Handset A', extension: '1001', baseStationId: 'base-1', mac: '6c:ab:05:a1:b2:c3' }, + { id: 'h2', name: 'Handset B', extension: '1002', baseStationId: 'base-1', mac: '6c:ab:05:d4:e5:f6' }, + { id: 'h3', name: 'Floater', extension: '1003', baseStationId: null, mac: '6c:ab:05:11:22:33' }, + ], + }); + + assert.equal(handsetsForBase(ctx, 'base-1').length, 2); + assert.equal(ctx.unassignedHandsets.length, 1); + assert.equal(ctx.linesRegisteredByWebexId.get('base-1'), 2); +}); + +test('mergeRssiWithHandsets: joins by MAC', () => { + const merged = mergeRssiWithHandsets( + [{ rpn: '00', mac: '6c:ab:05:a1:b2:c3', rssiDbm: -58 }], + [{ name: 'Handset A', extension: '1001', mac: '6C:AB:05:A1:B2:C3' }], + ); + assert.equal(merged[0].handsetName, 'Handset A'); + assert.equal(merged[0].extension, '1001'); + assert.equal(merged[0].rssiDbm, -58); +}); diff --git a/tests/fixtures/dect/status-933-f635de.inventory.json b/tests/fixtures/dect/status-933-f635de.inventory.json new file mode 100644 index 0000000..b6a54de --- /dev/null +++ b/tests/fixtures/dect/status-933-f635de.inventory.json @@ -0,0 +1,740 @@ +[ + { + "path": "Status.System_Information.Released_Build", + "isEmpty": false, + "preview": "Yes", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Multi_Cell", + "isEmpty": false, + "preview": "Ready(TXT_STATE_KEEP_ALIVE) Primary", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Phone_Type", + "isEmpty": false, + "preview": "IPDECT-V2 (DBS-210-3PC)", + "handsetRelated": false + }, + { + "path": "Status.System_Information.System_Type", + "isEmpty": false, + "preview": "Generic SIP (RFC 3261)", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Unit_Name", + "isEmpty": false, + "preview": "SME VoIP", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Unit_Index", + "isEmpty": false, + "preview": "Base Idx:0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RF_Band", + "isEmpty": false, + "preview": "US", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Conflict_Info", + "isEmpty": false, + "preview": "No Conflict", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Current_Local_Time", + "isEmpty": false, + "preview": "27-Jul-2026 15:51:57", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Operating_Time", + "isEmpty": false, + "preview": "39 Days 04:36:56 (H:M:S)", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RFPI_Address", + "isEmpty": false, + "preview": "135098C1; RPN:00", + "handsetRelated": false + }, + { + "path": "Status.System_Information.MAC_Address", + "isEmpty": false, + "preview": "6cab05f635de", + "handsetRelated": true + }, + { + "path": "Status.System_Information.IP_Address", + "isEmpty": false, + "preview": "10.52.38.149", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Product_Configuration", + "isEmpty": false, + "preview": "0000", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Firmware_Version", + "isEmpty": false, + "preview": "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Firmware_URL.Update_Server_Address", + "isEmpty": false, + "preview": "https://cisco.sipflash.com", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Firmware_URL.Path", + "isEmpty": false, + "preview": "dms/dbS210", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_1", + "isEmpty": false, + "preview": "2026-06-18 11:09:04 (126) Power Loss (80) Firmware Version 05-01-03-0101-09", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_2", + "isEmpty": false, + "preview": "2026-05-31 06:44:34 (125) Power Loss (80) Firmware Version 05-01-03-0101-09", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_3", + "isEmpty": false, + "preview": "2026-05-28 00:49:01 (124) Forced Reboot (81) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_4", + "isEmpty": false, + "preview": "2026-05-10 07:11:22 (123) Power Loss (80) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_5", + "isEmpty": false, + "preview": "2026-04-12 09:21:45 (122) Power Loss (80) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_6", + "isEmpty": false, + "preview": "2026-02-17 00:21:42 (121) Power Loss (80) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Base_Station_Status", + "isEmpty": false, + "preview": "Idle", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Custom_CA_Status.Custom_CA_Provisioning_Status", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Custom_CA_Status.Custom_CA_Info", + "isEmpty": false, + "preview": "Not Installed", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Dot1x_Authentication.Transaction_status", + "isEmpty": false, + "preview": "Unavailable", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Dot1x_Authentication.Protocol", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RSSI_List.RSSI_Line_1", + "isEmpty": false, + "preview": "RSSI for RPN:4 is -47 [dBm]", + "handsetRelated": true + }, + { + "path": "Status.System_Information.RTP_Usage.Total_RTP", + "isEmpty": false, + "preview": "4", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Max_RTP", + "isEmpty": false, + "preview": "-1", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Time_In_Max_RTP", + "isEmpty": false, + "preview": "49710 days 06:28:15 [H:M:S]", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_Local_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_Relay_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Remote_Relay_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_Recording", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Device_Presence.Device_Line_1", + "isEmpty": false, + "preview": "Device:6825 present at Extension 1", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_Presence.Device_Line_2", + "isEmpty": false, + "preview": "Device:6825 present at Extension 2", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Base_Station", + "isEmpty": false, + "preview": "Base type:DBS-210-3PC - Required Version:501 Required Branch:309", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_0", + "isEmpty": false, + "preview": "Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_1", + "isEmpty": false, + "preview": "Device type:6825-RGD - Required Version:501 Required Branch:308 Language Pack:6825-RGD_default", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_2", + "isEmpty": false, + "preview": "Device type:6823 - Required Version:501 Required Branch:308 Language Pack:6823_default", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_3", + "isEmpty": false, + "preview": "Device type:RPT-110-3PC - Required Version:501 Required Branch:303", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Push_To_Talk", + "isEmpty": false, + "preview": "Off", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_1", + "isEmpty": false, + "preview": "911", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_2", + "isEmpty": false, + "preview": "1911", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_3", + "isEmpty": false, + "preview": "933", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_4", + "isEmpty": false, + "preview": "No Number set!", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_5", + "isEmpty": false, + "preview": "No Number set!", + "handsetRelated": false + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_4.SIP_Idx", + "isEmpty": false, + "preview": "1", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_4.Server_Id", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_4.SIP_URI", + "isEmpty": false, + "preview": "jdph354xak_HH_9768C5EF4172_2@31134724.cisco-bcld.com", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_4.Server_Name", + "isEmpty": false, + "preview": "BroadCloud", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_4.Status", + "isEmpty": false, + "preview": "OK", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_5.SIP_Idx", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_5.Server_Id", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_5.SIP_URI", + "isEmpty": false, + "preview": "jdph354xak@31134724.cisco-bcld.com", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_5.Server_Name", + "isEmpty": false, + "preview": "BroadCloud", + "handsetRelated": true + }, + { + "path": "Status.System_Information.SIP_Identity_Status.SIP_UA_Id_5.Status", + "isEmpty": false, + "preview": "Registering", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx1.Ipei", + "isEmpty": false, + "preview": "0329908355", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Device_Type", + "isEmpty": false, + "preview": "6825", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.SW_Version", + "isEmpty": false, + "preview": "05-01-03-0101-08", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.FWU_Progress", + "isEmpty": false, + "preview": "FWU Complete", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Location_Info.Time_Date", + "isEmpty": false, + "preview": "27-Jul-2026 19:33:27", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Location_Info.RPN", + "isEmpty": false, + "preview": "Registered@RPN:00", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx1.Location_Info.Locked_RPN", + "isEmpty": false, + "preview": "Locked@RPN:00", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx1.Battery_RSSI_Info.Time_Date", + "isEmpty": false, + "preview": "12:33 [mm:ss]", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx1.Battery_RSSI_Info.Battery_level", + "isEmpty": false, + "preview": "81 [%]", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx1.Battery_RSSI_Info.RSSI", + "isEmpty": false, + "preview": "-76 [dBm]", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx1.Hs_Config_Update_Time", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Tracking_Info.Tracking_State", + "isEmpty": false, + "preview": "Tracking Activated", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Tracking_Info.Time_Date", + "isEmpty": false, + "preview": "27-Jul-2026 19:33:27", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.DECT_Info.DECT_State", + "isEmpty": false, + "preview": "Present", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.DECT_Info.Call_Instance.MmInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.DECT_Info.Call_Instance.CissInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.DECT_Info.Call_Instance.CcOutInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.DECT_Info.Call_Instance.CcIncInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Push_To_Talk", + "isEmpty": false, + "preview": "Off", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Reset_Info.Time_Date", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.Reset_Info.Number", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.SIP_Account.SIP_IDX1.Index_of_Relation", + "isEmpty": false, + "preview": "SIP account with Idx 1 is linked to device Idx 1", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.SIP_Account.SIP_IDX1.Display_Name", + "isEmpty": false, + "preview": "1-50933", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.SIP_Account.SIP_IDX1.Number", + "isEmpty": false, + "preview": "jdph354xak", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx1.SIP_Account.SIP_IDX1.State", + "isEmpty": false, + "preview": "Not in use", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Ipei", + "isEmpty": false, + "preview": "032990831B", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Device_Type", + "isEmpty": false, + "preview": "6825", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.SW_Version", + "isEmpty": false, + "preview": "05-01-03-0101-08", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.FWU_Progress", + "isEmpty": false, + "preview": "FWU Complete", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Location_Info.Time_Date", + "isEmpty": false, + "preview": "27-Jul-2026 19:39:02", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Location_Info.RPN", + "isEmpty": false, + "preview": "Registered@RPN:00", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx2.Location_Info.Locked_RPN", + "isEmpty": false, + "preview": "Locked@RPN:04", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx2.Battery_RSSI_Info.Time_Date", + "isEmpty": false, + "preview": "53:38 [mm:ss]", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx2.Battery_RSSI_Info.Battery_level", + "isEmpty": false, + "preview": "98 [%]", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx2.Battery_RSSI_Info.RSSI", + "isEmpty": false, + "preview": "-76 [dBm]", + "handsetRelated": true + }, + { + "path": "Status.Device_Information.Device_Idx2.Hs_Config_Update_Time", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Tracking_Info.Tracking_State", + "isEmpty": false, + "preview": "Tracking Activated", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Tracking_Info.Time_Date", + "isEmpty": false, + "preview": "27-Jul-2026 19:39:02", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.DECT_Info.DECT_State", + "isEmpty": false, + "preview": "Present", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.DECT_Info.Call_Instance.MmInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.DECT_Info.Call_Instance.CissInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.DECT_Info.Call_Instance.CcOutInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.DECT_Info.Call_Instance.CcIncInstance", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Push_To_Talk", + "isEmpty": false, + "preview": "Off", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Reset_Info.Time_Date", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.Reset_Info.Number", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.SIP_Account.SIP_IDX2.Index_of_Relation", + "isEmpty": false, + "preview": "SIP account with Idx 2 is linked to device Idx 2", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.SIP_Account.SIP_IDX2.Display_Name", + "isEmpty": false, + "preview": "2-50933", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.SIP_Account.SIP_IDX2.Number", + "isEmpty": false, + "preview": "jdph354xak_HH_9768C5EF4172_2", + "handsetRelated": false + }, + { + "path": "Status.Device_Information.Device_Idx2.SIP_Account.SIP_IDX2.State", + "isEmpty": false, + "preview": "Not in use", + "handsetRelated": false + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Packets", + "isEmpty": false, + "preview": "3518964", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Blocked", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Dropped", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Errors", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Broadcasts", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Packets", + "isEmpty": false, + "preview": "4940939", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Blocked", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Dropped", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Errors", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Broadcasts", + "isEmpty": false, + "preview": "6239", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Header_Line_Idx", + "isEmpty": false, + "preview": "RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duratio…", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Value_Line_0", + "isEmpty": false, + "preview": "'00','6CAB05F635DE',3386184,5238180,942,1,8,78353,0,4,0,0,255,0,0,892,0,0,0,35,0,0,285,94,7662,1,1,0,24,0,0,90,109,74,1…", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Value_Line_1", + "isEmpty": false, + "preview": "'00','6CAB05F63642',3386126,5238247,160,0,0,12875,0,2,0,0,1,1,8,146,0,0,0,2,0,0,30,10,167,2,2,2,25,0,0,60,118,72,105,52…", + "handsetRelated": true + } +] \ No newline at end of file diff --git a/tests/fixtures/dect/status-933-f635de.parsed.json b/tests/fixtures/dect/status-933-f635de.parsed.json new file mode 100644 index 0000000..08dcbea --- /dev/null +++ b/tests/fixtures/dect/status-933-f635de.parsed.json @@ -0,0 +1,169 @@ +{ + "parsed": { + "device": { + "model": "IPDECT-V2 (DBS-210-3PC)", + "systemType": "Generic SIP (RFC 3261)", + "unitName": "SME VoIP", + "unitIndex": "Base Idx:0", + "rfBand": "US", + "productConfiguration": "0000", + "macAddress": "6c:ab:05:f6:35:de", + "ipAddress": "10.52.38.149", + "rfpiAddress": "135098C1; RPN:00", + "releasedBuild": true + }, + "firmware": { + "version": "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29", + "updateServer": "https://cisco.sipflash.com", + "updatePath": "dms/dbS210", + "requiredFor": { + "6823": { + "requiredVersion": "501", + "requiredBranch": "308", + "languagePack": "6823_default", + "_sourceKey": "Device_Line_2" + }, + "6825": { + "requiredVersion": "501", + "requiredBranch": "308", + "languagePack": "6825_default", + "_sourceKey": "Device_Line_0" + }, + "DBS-210-3PC": { + "requiredVersion": "501", + "requiredBranch": "309", + "languagePack": null, + "_sourceKey": "Device_Base_Station" + }, + "6825-RGD": { + "requiredVersion": "501", + "requiredBranch": "308", + "languagePack": "6825-RGD_default", + "_sourceKey": "Device_Line_1" + }, + "RPT-110-3PC": { + "requiredVersion": "501", + "requiredBranch": "303", + "languagePack": null, + "_sourceKey": "Device_Line_3" + } + } + }, + "time": { + "currentLocalTime": "27-Jul-2026 15:51:57", + "operatingTime": "39 Days 04:36:56 (H:M:S)", + "operatingTimeSeconds": 16616 + }, + "multiCell": { + "role": "ready", + "raw": "Ready(TXT_STATE_KEEP_ALIVE) Primary" + }, + "baseStatus": "idle", + "conflictInfo": "No Conflict", + "security": { + "customCa": { + "provisioningStatus": "N/A", + "info": "Not Installed", + "installed": false + }, + "dot1x": { + "transactionStatus": "Unavailable", + "protocol": "N/A" + } + }, + "rebootLog": [ + { + "at": "2026-06-18T11:09:04", + "sequence": 126, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-03-0101-09", + "raw": "2026-06-18 11:09:04 (126) Power Loss (80) Firmware Version 05-01-03-0101-09" + }, + { + "at": "2026-05-31T06:44:34", + "sequence": 125, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-03-0101-09", + "raw": "2026-05-31 06:44:34 (125) Power Loss (80) Firmware Version 05-01-03-0101-09" + }, + { + "at": "2026-05-28T00:49:01", + "sequence": 124, + "reasonName": "Forced Reboot", + "reasonCode": 81, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-05-28 00:49:01 (124) Forced Reboot (81) Firmware Version 05-01-02-0101-05" + }, + { + "at": "2026-05-10T07:11:22", + "sequence": 123, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-05-10 07:11:22 (123) Power Loss (80) Firmware Version 05-01-02-0101-05" + }, + { + "at": "2026-04-12T09:21:45", + "sequence": 122, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-04-12 09:21:45 (122) Power Loss (80) Firmware Version 05-01-02-0101-05" + }, + { + "at": "2026-02-17T00:21:42", + "sequence": 121, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-02-17 00:21:42 (121) Power Loss (80) Firmware Version 05-01-02-0101-05" + } + ], + "rtp": { + "total": 4, + "max": -1, + "current": 0, + "currentLocal": 0, + "currentRelay": 0, + "remoteRelay": 0, + "currentRecording": 0, + "timeInMaxRtp": "49710 days 06:28:15 [H:M:S]" + }, + "network": { + "txPackets": 3518964, + "txBlocked": 0, + "txDropped": 0, + "txErrors": 0, + "txBroadcasts": 0, + "rxPackets": 4940939, + "rxBlocked": 0, + "rxDropped": 0, + "rxErrors": 0, + "rxBroadcasts": 6239 + }, + "emergencyNumbers": [ + "911", + "1911", + "933" + ], + "rssi": [], + "devicePresence": [], + "sipIdentityStatus": [], + "perRpnStats": [], + "features": { + "pushToTalk": false + }, + "statisticsHeader": "RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duration[s], Min Latency[ms], Avg Latency[ms], Max Latency[ms], G711U, G711A, G729, G722, G726, OPUS, BV32, Handovers, Failed Handovers, SIP reg failed, Handset Removed, Searching, RcHeapFree Running, New SyncSource, LAN Sync Lost, LAN Primary Lost, Freq[0][0], Freq[0][1], Freq[0][2], Freq[0][3], Freq[0][4], Freq[0][5], Freq[0][6], Freq[0][7], Freq[0][8], Freq[0][9], Freq[0][10], Freq[0][11], Freq[1][0], Freq[1][1], Freq[1][2], Freq[1][3], Freq[1][4], Freq[1][5], Freq[1][6], Freq[1][7], Freq[1][8], Freq[1][9], Freq[1][10], Freq[1][11], Freq[2][0], Freq[2][1], Freq[2][2], Freq[2][3], Freq[2][4], Freq[2][5], Freq[2][6], Freq[2][7], Freq[2][8], Freq[2][9], Freq[2][10], Freq[2][11], Freq[3][0], Freq[3][1], Freq[3][2], Freq[3][3], Freq[3][4], Freq[3][5], Freq[3][6], Freq[3][7], Freq[3][8], Freq[3][9], Freq[3][10], Freq[3][11], Freq[4][0], Freq[4][1], Freq[4][2], Freq[4][3], Freq[4][4], Freq[4][5], Freq[4][6], Freq[4][7], Freq[4][8], Freq[4][9], Freq[4][10], Freq[4][11], Freq[5][0], Freq[5][1], Freq[5][2], Freq[5][3], Freq[5][4], Freq[5][5], Freq[5][6], Freq[5][7], Freq[5][8], Freq[5][9], Freq[5][10], Freq[5][11], Freq[6][0], Freq[6][1], Freq[6][2], Freq[6][3], Freq[6][4], Freq[6][5], Freq[6][6], Freq[6][7], Freq[6][8], Freq[6][9], Freq[6][10], Freq[6][11], Freq[7][0], Freq[7][1], Freq[7][2], Freq[7][3], Freq[7][4], Freq[7][5], Freq[7][6], Freq[7][7], Freq[7][8], Freq[7][9], Freq[7][10], Freq[7][11], Freq[8][0], Freq[8][1], Freq[8][2], Freq[8][3], Freq[8][4], Freq[8][5], Freq[8][6], Freq[8][7], Freq[8][8], Freq[8][9], Freq[8][10], Freq[8][11], Freq[9][0], Freq[9][1], Freq[9][2], Freq[9][3], Freq[9][4], Freq[9][5], Freq[9][6], Freq[9][7], Freq[9][8], Freq[9][9], Freq[9][10], Freq[9][11], R-Idx[0], R-OP[0][s], R-Busy[0], R-Busy Duration[0][s], Max Calls[0], Searching[0], Recovery[0], New SyncSource[0], Wide Band[0], Narrow Band[0], R-Idx[1], R-OP[1][s], R-Busy[1], R-Busy Duration[1][s], Max Calls[1], Searching[1], Recovery[1], New SyncSource[1], Wide Band[1], Narrow Band[1], R-Idx[2], R-OP[2][s], R-Busy[2], R-Busy Duration[2][s], Max Calls[2], Searching[2], Recovery[2], New SyncSource[2], Wide Band[2], Narrow Band[2]", + "_rawStatisticsHeader": "RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duration[s], Min Latency[ms], Avg Latency[ms], Max Latency[ms], G711U, G711A, G729, G722, G726, OPUS, BV32, Handovers, Failed Handovers, SIP reg failed, Handset Removed, Searching, RcHeapFree Running, New SyncSource, LAN Sync Lost, LAN Primary Lost, Freq[0][0], Freq[0][1], Freq[0][2], Freq[0][3], Freq[0][4], Freq[0][5], Freq[0][6], Freq[0][7], Freq[0][8], Freq[0][9], Freq[0][10], Freq[0][11], Freq[1][0], Freq[1][1], Freq[1][2], Freq[1][3], Freq[1][4], Freq[1][5], Freq[1][6], Freq[1][7], Freq[1][8], Freq[1][9], Freq[1][10], Freq[1][11], Freq[2][0], Freq[2][1], Freq[2][2], Freq[2][3], Freq[2][4], Freq[2][5], Freq[2][6], Freq[2][7], Freq[2][8], Freq[2][9], Freq[2][10], Freq[2][11], Freq[3][0], Freq[3][1], Freq[3][2], Freq[3][3], Freq[3][4], Freq[3][5], Freq[3][6], Freq[3][7], Freq[3][8], Freq[3][9], Freq[3][10], Freq[3][11], Freq[4][0], Freq[4][1], Freq[4][2], Freq[4][3], Freq[4][4], Freq[4][5], Freq[4][6], Freq[4][7], Freq[4][8], Freq[4][9], Freq[4][10], Freq[4][11], Freq[5][0], Freq[5][1], Freq[5][2], Freq[5][3], Freq[5][4], Freq[5][5], Freq[5][6], Freq[5][7], Freq[5][8], Freq[5][9], Freq[5][10], Freq[5][11], Freq[6][0], Freq[6][1], Freq[6][2], Freq[6][3], Freq[6][4], Freq[6][5], Freq[6][6], Freq[6][7], Freq[6][8], Freq[6][9], Freq[6][10], Freq[6][11], Freq[7][0], Freq[7][1], Freq[7][2], Freq[7][3], Freq[7][4], Freq[7][5], Freq[7][6], Freq[7][7], Freq[7][8], Freq[7][9], Freq[7][10], Freq[7][11], Freq[8][0], Freq[8][1], Freq[8][2], Freq[8][3], Freq[8][4], Freq[8][5], Freq[8][6], Freq[8][7], Freq[8][8], Freq[8][9], Freq[8][10], Freq[8][11], Freq[9][0], Freq[9][1], Freq[9][2], Freq[9][3], Freq[9][4], Freq[9][5], Freq[9][6], Freq[9][7], Freq[9][8], Freq[9][9], Freq[9][10], Freq[9][11], R-Idx[0], R-OP[0][s], R-Busy[0], R-Busy Duration[0][s], Max Calls[0], Searching[0], Recovery[0], New SyncSource[0], Wide Band[0], Narrow Band[0], R-Idx[1], R-OP[1][s], R-Busy[1], R-Busy Duration[1][s], Max Calls[1], Searching[1], Recovery[1], New SyncSource[1], Wide Band[1], Narrow Band[1], R-Idx[2], R-OP[2][s], R-Busy[2], R-Busy Duration[2][s], Max Calls[2], Searching[2], Recovery[2], New SyncSource[2], Wide Band[2], Narrow Band[2]" + }, + "verdict": { + "healthy": false, + "warnings": [ + "5 recent power-loss reboot(s); most recent at 2026-06-18T11:09:04" + ], + "info": [] + }, + "fetchedAt": "2026-07-27T19:51:57.077Z" +} \ No newline at end of file diff --git a/tests/fixtures/dect/status-933-f635de.xml b/tests/fixtures/dect/status-933-f635de.xml new file mode 100644 index 0000000..0e32fb8 --- /dev/null +++ b/tests/fixtures/dect/status-933-f635de.xml @@ -0,0 +1,190 @@ + + + +Yes +Ready(TXT_STATE_KEEP_ALIVE) Primary +IPDECT-V2 (DBS-210-3PC) +Generic SIP (RFC 3261) +SME VoIP +Base Idx:0 +US +No Conflict +27-Jul-2026 15:51:57 +39 Days 04:36:56 (H:M:S) +135098C1; RPN:00 +6cab05f635de +10.52.38.149 +0000 +IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29 + +https://cisco.sipflash.com +dms/dbS210 + + +2026-06-18 11:09:04 (126) Power Loss (80) Firmware Version 05-01-03-0101-09 +2026-05-31 06:44:34 (125) Power Loss (80) Firmware Version 05-01-03-0101-09 +2026-05-28 00:49:01 (124) Forced Reboot (81) Firmware Version 05-01-02-0101-05 +2026-05-10 07:11:22 (123) Power Loss (80) Firmware Version 05-01-02-0101-05 +2026-04-12 09:21:45 (122) Power Loss (80) Firmware Version 05-01-02-0101-05 +2026-02-17 00:21:42 (121) Power Loss (80) Firmware Version 05-01-02-0101-05 + +Idle + +N/A +Not Installed + + +Unavailable +N/A + + +RSSI for RPN:4 is -47 [dBm] + + +4 +-1 +49710 days 06:28:15 [H:M:S] +0 +0 +0 +0 +0 + + +Device:6825 present at Extension 1 +Device:6825 present at Extension 2 + + +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! + + + +1 +0 +jdph354xak_HH_9768C5EF4172_2@31134724.cisco-bcld.com +BroadCloud +OK + + +0 +0 +jdph354xak@31134724.cisco-bcld.com +BroadCloud +Registering + + + + + +0329908355 +6825 +05-01-03-0101-08 +FWU Complete + +27-Jul-2026 19:33:27 +Registered@RPN:00 +Locked@RPN:00 + + +12:33 [mm:ss] +81 [%] +-76 [dBm] + +N/A +Tracking Activated +27-Jul-2026 19:33:27 + + +Present +0 +0 +0 +0 + + +Off + +N/A +0 + + + +SIP account with Idx 1 is linked to device Idx 1 +1-50933 +jdph354xak +Not in use + + + + +032990831B +6825 +05-01-03-0101-08 +FWU Complete + +27-Jul-2026 19:39:02 +Registered@RPN:00 +Locked@RPN:04 + + +53:38 [mm:ss] +98 [%] +-76 [dBm] + +N/A +Tracking Activated +27-Jul-2026 19:39:02 + + +Present +0 +0 +0 +0 + + +Off + +N/A +0 + + + +SIP account with Idx 2 is linked to device Idx 2 +2-50933 +jdph354xak_HH_9768C5EF4172_2 +Not in use + + + + + + +3518964 +0 +0 +0 +0 +4940939 +0 +0 +0 +6239 + +RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duration[s], Min Latency[ms], Avg Latency[ms], Max Latency[ms], G711U, G711A, G729, G722, G726, OPUS, BV32, Handovers, Failed Handovers, SIP reg failed, Handset Removed, Searching, RcHeapFree Running, New SyncSource, LAN Sync Lost, LAN Primary Lost, Freq[0][0], Freq[0][1], Freq[0][2], Freq[0][3], Freq[0][4], Freq[0][5], Freq[0][6], Freq[0][7], Freq[0][8], Freq[0][9], Freq[0][10], Freq[0][11], Freq[1][0], Freq[1][1], Freq[1][2], Freq[1][3], Freq[1][4], Freq[1][5], Freq[1][6], Freq[1][7], Freq[1][8], Freq[1][9], Freq[1][10], Freq[1][11], Freq[2][0], Freq[2][1], Freq[2][2], Freq[2][3], Freq[2][4], Freq[2][5], Freq[2][6], Freq[2][7], Freq[2][8], Freq[2][9], Freq[2][10], Freq[2][11], Freq[3][0], Freq[3][1], Freq[3][2], Freq[3][3], Freq[3][4], Freq[3][5], Freq[3][6], Freq[3][7], Freq[3][8], Freq[3][9], Freq[3][10], Freq[3][11], Freq[4][0], Freq[4][1], Freq[4][2], Freq[4][3], Freq[4][4], Freq[4][5], Freq[4][6], Freq[4][7], Freq[4][8], Freq[4][9], Freq[4][10], Freq[4][11], Freq[5][0], Freq[5][1], Freq[5][2], Freq[5][3], Freq[5][4], Freq[5][5], Freq[5][6], Freq[5][7], Freq[5][8], Freq[5][9], Freq[5][10], Freq[5][11], Freq[6][0], Freq[6][1], Freq[6][2], Freq[6][3], Freq[6][4], Freq[6][5], Freq[6][6], Freq[6][7], Freq[6][8], Freq[6][9], Freq[6][10], Freq[6][11], Freq[7][0], Freq[7][1], Freq[7][2], Freq[7][3], Freq[7][4], Freq[7][5], Freq[7][6], Freq[7][7], Freq[7][8], Freq[7][9], Freq[7][10], Freq[7][11], Freq[8][0], Freq[8][1], Freq[8][2], Freq[8][3], Freq[8][4], Freq[8][5], Freq[8][6], Freq[8][7], Freq[8][8], Freq[8][9], Freq[8][10], Freq[8][11], Freq[9][0], Freq[9][1], Freq[9][2], Freq[9][3], Freq[9][4], Freq[9][5], Freq[9][6], Freq[9][7], Freq[9][8], Freq[9][9], Freq[9][10], Freq[9][11], R-Idx[0], R-OP[0][s], R-Busy[0], R-Busy Duration[0][s], Max Calls[0], Searching[0], Recovery[0], New SyncSource[0], Wide Band[0], Narrow Band[0], R-Idx[1], R-OP[1][s], R-Busy[1], R-Busy Duration[1][s], Max Calls[1], Searching[1], Recovery[1], New SyncSource[1], Wide Band[1], Narrow Band[1], R-Idx[2], R-OP[2][s], R-Busy[2], R-Busy Duration[2][s], Max Calls[2], Searching[2], Recovery[2], New SyncSource[2], Wide Band[2], Narrow Band[2] +'00','6CAB05F635DE',3386184,5238180,942,1,8,78353,0,4,0,0,255,0,0,892,0,0,0,35,0,0,285,94,7662,1,1,0,24,0,0,90,109,74,117,75,107,69,112,98,108,80,112,99,142,107,130,90,116,100,119,125,129,107,143,104,136,118,130,106,106,95,141,103,159,97,147,102,139,123,146,123,140,95,166,125,139,127,154,116,164,136,162,113,127,105,164,128,155,128,151,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0 +'00','6CAB05F63642',3386126,5238247,160,0,0,12875,0,2,0,0,1,1,8,146,0,0,0,2,0,0,30,10,167,2,2,2,25,0,0,60,118,72,105,52,118,48,144,57,127,63,122,109,126,88,129,134,129,140,109,127,128,128,142,132,134,85,133,114,117,106,126,97,117,131,133,142,134,82,141,129,137,144,141,124,141,117,122,98,145,78,141,120,146,98,114,90,134,88,123,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0 + + diff --git a/tests/fixtures/dect/status-933-f63642.inventory.json b/tests/fixtures/dect/status-933-f63642.inventory.json new file mode 100644 index 0000000..c98f386 --- /dev/null +++ b/tests/fixtures/dect/status-933-f63642.inventory.json @@ -0,0 +1,386 @@ +[ + { + "path": "Status.System_Information.Released_Build", + "isEmpty": false, + "preview": "Yes", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Multi_Cell", + "isEmpty": false, + "preview": "Ready(TXT_STATE_KEEP_ALIVE) Secondary", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Phone_Type", + "isEmpty": false, + "preview": "IPDECT-V2 (DBS-210-3PC)", + "handsetRelated": false + }, + { + "path": "Status.System_Information.System_Type", + "isEmpty": false, + "preview": "Generic SIP (RFC 3261)", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Unit_Name", + "isEmpty": false, + "preview": "SME VoIP", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Unit_Index", + "isEmpty": false, + "preview": "Base Idx:1", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RF_Band", + "isEmpty": false, + "preview": "US", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Conflict_Info", + "isEmpty": false, + "preview": "No Conflict", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Current_Local_Time", + "isEmpty": false, + "preview": "27-Jul-2026 15:51:57", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Operating_Time", + "isEmpty": false, + "preview": "39 Days 04:36:13 (H:M:S)", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RFPI_Address", + "isEmpty": false, + "preview": "135098C1; RPN:04", + "handsetRelated": false + }, + { + "path": "Status.System_Information.MAC_Address", + "isEmpty": false, + "preview": "6cab05f63642", + "handsetRelated": true + }, + { + "path": "Status.System_Information.IP_Address", + "isEmpty": false, + "preview": "10.52.38.152", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Product_Configuration", + "isEmpty": false, + "preview": "0000", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Firmware_Version", + "isEmpty": false, + "preview": "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Firmware_URL.Update_Server_Address", + "isEmpty": false, + "preview": "https://cisco.sipflash.com", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Firmware_URL.Path", + "isEmpty": false, + "preview": "dms/dbS210", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_1", + "isEmpty": false, + "preview": "2026-06-18 11:09:04 (135) Power Loss (80) Firmware Version 05-01-03-0101-09", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_2", + "isEmpty": false, + "preview": "2026-05-31 06:44:34 (134) Power Loss (80) Firmware Version 05-01-03-0101-09", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_3", + "isEmpty": false, + "preview": "2026-05-28 00:47:55 (133) Forced Reboot (81) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_4", + "isEmpty": false, + "preview": "2026-05-10 07:19:17 (132) Unexpected Reboot (43) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_5", + "isEmpty": false, + "preview": "2026-05-10 07:11:23 (131) Power Loss (80) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Reboot_Log.Reboot_Line_6", + "isEmpty": false, + "preview": "2026-04-12 09:21:45 (130) Power Loss (80) Firmware Version 05-01-02-0101-05", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Base_Station_Status", + "isEmpty": false, + "preview": "Idle", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Custom_CA_Status.Custom_CA_Provisioning_Status", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Custom_CA_Status.Custom_CA_Info", + "isEmpty": false, + "preview": "Not Installed", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Dot1x_Authentication.Transaction_status", + "isEmpty": false, + "preview": "Unavailable", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Dot1x_Authentication.Protocol", + "isEmpty": false, + "preview": "N/A", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RSSI_List.RSSI_Line_1", + "isEmpty": false, + "preview": "RSSI for RPN:4 is -49 [dBm]", + "handsetRelated": true + }, + { + "path": "Status.System_Information.RTP_Usage.Total_RTP", + "isEmpty": false, + "preview": "4", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Max_RTP", + "isEmpty": false, + "preview": "-1", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Time_In_Max_RTP", + "isEmpty": false, + "preview": "49710 days 06:28:15 [H:M:S]", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_Local_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_Relay_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Remote_Relay_RTP", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.RTP_Usage.Current_Recording", + "isEmpty": false, + "preview": "0", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Device_Presence", + "isEmpty": true, + "preview": null, + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Base_Station", + "isEmpty": false, + "preview": "Base type:DBS-210-3PC - Required Version:501 Required Branch:309", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_0", + "isEmpty": false, + "preview": "Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_1", + "isEmpty": false, + "preview": "Device type:6825-RGD - Required Version:501 Required Branch:308 Language Pack:6825-RGD_default", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_2", + "isEmpty": false, + "preview": "Device type:6823 - Required Version:501 Required Branch:308 Language Pack:6823_default", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Device_FWU_Info.Device_Line_3", + "isEmpty": false, + "preview": "Device type:RPT-110-3PC - Required Version:501 Required Branch:303", + "handsetRelated": true + }, + { + "path": "Status.System_Information.Push_To_Talk", + "isEmpty": false, + "preview": "Off", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_1", + "isEmpty": false, + "preview": "911", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_2", + "isEmpty": false, + "preview": "1911", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_3", + "isEmpty": false, + "preview": "933", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_4", + "isEmpty": false, + "preview": "No Number set!", + "handsetRelated": false + }, + { + "path": "Status.System_Information.Emergency_Calls.Emergency_Number_5", + "isEmpty": false, + "preview": "No Number set!", + "handsetRelated": false + }, + { + "path": "Status.System_Information.SIP_Identity_Status", + "isEmpty": true, + "preview": null, + "handsetRelated": true + }, + { + "path": "Status.Device_Information", + "isEmpty": true, + "preview": null, + "handsetRelated": false + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Packets", + "isEmpty": false, + "preview": "3813285", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Blocked", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Dropped", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Errors", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Tx_Broadcasts", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Packets", + "isEmpty": false, + "preview": "5485843", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Blocked", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Dropped", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Errors", + "isEmpty": false, + "preview": "0", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Network_Statistics.Rx_Broadcasts", + "isEmpty": false, + "preview": "4773", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Header_Line_Idx", + "isEmpty": false, + "preview": "RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duratio…", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Value_Line_0", + "isEmpty": false, + "preview": "'00','6CAB05F635DE',3386184,5238180,942,1,8,78353,0,4,0,0,255,0,0,892,0,0,0,35,0,0,285,94,7662,1,1,0,24,0,0,90,109,74,1…", + "handsetRelated": true + }, + { + "path": "Status.Statistics.Value_Line_1", + "isEmpty": false, + "preview": "'00','6CAB05F63642',3386126,5238247,160,0,0,12875,0,2,0,0,1,1,8,146,0,0,0,2,0,0,30,10,167,2,2,2,25,0,0,60,118,72,105,52…", + "handsetRelated": true + } +] \ No newline at end of file diff --git a/tests/fixtures/dect/status-933-f63642.parsed.json b/tests/fixtures/dect/status-933-f63642.parsed.json new file mode 100644 index 0000000..32779f1 --- /dev/null +++ b/tests/fixtures/dect/status-933-f63642.parsed.json @@ -0,0 +1,169 @@ +{ + "parsed": { + "device": { + "model": "IPDECT-V2 (DBS-210-3PC)", + "systemType": "Generic SIP (RFC 3261)", + "unitName": "SME VoIP", + "unitIndex": "Base Idx:1", + "rfBand": "US", + "productConfiguration": "0000", + "macAddress": "6c:ab:05:f6:36:42", + "ipAddress": "10.52.38.152", + "rfpiAddress": "135098C1; RPN:04", + "releasedBuild": true + }, + "firmware": { + "version": "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29", + "updateServer": "https://cisco.sipflash.com", + "updatePath": "dms/dbS210", + "requiredFor": { + "6823": { + "requiredVersion": "501", + "requiredBranch": "308", + "languagePack": "6823_default", + "_sourceKey": "Device_Line_2" + }, + "6825": { + "requiredVersion": "501", + "requiredBranch": "308", + "languagePack": "6825_default", + "_sourceKey": "Device_Line_0" + }, + "DBS-210-3PC": { + "requiredVersion": "501", + "requiredBranch": "309", + "languagePack": null, + "_sourceKey": "Device_Base_Station" + }, + "6825-RGD": { + "requiredVersion": "501", + "requiredBranch": "308", + "languagePack": "6825-RGD_default", + "_sourceKey": "Device_Line_1" + }, + "RPT-110-3PC": { + "requiredVersion": "501", + "requiredBranch": "303", + "languagePack": null, + "_sourceKey": "Device_Line_3" + } + } + }, + "time": { + "currentLocalTime": "27-Jul-2026 15:51:57", + "operatingTime": "39 Days 04:36:13 (H:M:S)", + "operatingTimeSeconds": 16573 + }, + "multiCell": { + "role": "ready", + "raw": "Ready(TXT_STATE_KEEP_ALIVE) Secondary" + }, + "baseStatus": "idle", + "conflictInfo": "No Conflict", + "security": { + "customCa": { + "provisioningStatus": "N/A", + "info": "Not Installed", + "installed": false + }, + "dot1x": { + "transactionStatus": "Unavailable", + "protocol": "N/A" + } + }, + "rebootLog": [ + { + "at": "2026-06-18T11:09:04", + "sequence": 135, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-03-0101-09", + "raw": "2026-06-18 11:09:04 (135) Power Loss (80) Firmware Version 05-01-03-0101-09" + }, + { + "at": "2026-05-31T06:44:34", + "sequence": 134, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-03-0101-09", + "raw": "2026-05-31 06:44:34 (134) Power Loss (80) Firmware Version 05-01-03-0101-09" + }, + { + "at": "2026-05-28T00:47:55", + "sequence": 133, + "reasonName": "Forced Reboot", + "reasonCode": 81, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-05-28 00:47:55 (133) Forced Reboot (81) Firmware Version 05-01-02-0101-05" + }, + { + "at": "2026-05-10T07:19:17", + "sequence": 132, + "reasonName": "Unexpected Reboot", + "reasonCode": 43, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-05-10 07:19:17 (132) Unexpected Reboot (43) Firmware Version 05-01-02-0101-05" + }, + { + "at": "2026-05-10T07:11:23", + "sequence": 131, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-05-10 07:11:23 (131) Power Loss (80) Firmware Version 05-01-02-0101-05" + }, + { + "at": "2026-04-12T09:21:45", + "sequence": 130, + "reasonName": "Power Loss", + "reasonCode": 80, + "firmwareAtBoot": "05-01-02-0101-05", + "raw": "2026-04-12 09:21:45 (130) Power Loss (80) Firmware Version 05-01-02-0101-05" + } + ], + "rtp": { + "total": 4, + "max": -1, + "current": 0, + "currentLocal": 0, + "currentRelay": 0, + "remoteRelay": 0, + "currentRecording": 0, + "timeInMaxRtp": "49710 days 06:28:15 [H:M:S]" + }, + "network": { + "txPackets": 3813285, + "txBlocked": 0, + "txDropped": 0, + "txErrors": 0, + "txBroadcasts": 0, + "rxPackets": 5485843, + "rxBlocked": 0, + "rxDropped": 0, + "rxErrors": 0, + "rxBroadcasts": 4773 + }, + "emergencyNumbers": [ + "911", + "1911", + "933" + ], + "rssi": [], + "devicePresence": [], + "sipIdentityStatus": [], + "perRpnStats": [], + "features": { + "pushToTalk": false + }, + "statisticsHeader": "RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duration[s], Min Latency[ms], Avg Latency[ms], Max Latency[ms], G711U, G711A, G729, G722, G726, OPUS, BV32, Handovers, Failed Handovers, SIP reg failed, Handset Removed, Searching, RcHeapFree Running, New SyncSource, LAN Sync Lost, LAN Primary Lost, Freq[0][0], Freq[0][1], Freq[0][2], Freq[0][3], Freq[0][4], Freq[0][5], Freq[0][6], Freq[0][7], Freq[0][8], Freq[0][9], Freq[0][10], Freq[0][11], Freq[1][0], Freq[1][1], Freq[1][2], Freq[1][3], Freq[1][4], Freq[1][5], Freq[1][6], Freq[1][7], Freq[1][8], Freq[1][9], Freq[1][10], Freq[1][11], Freq[2][0], Freq[2][1], Freq[2][2], Freq[2][3], Freq[2][4], Freq[2][5], Freq[2][6], Freq[2][7], Freq[2][8], Freq[2][9], Freq[2][10], Freq[2][11], Freq[3][0], Freq[3][1], Freq[3][2], Freq[3][3], Freq[3][4], Freq[3][5], Freq[3][6], Freq[3][7], Freq[3][8], Freq[3][9], Freq[3][10], Freq[3][11], Freq[4][0], Freq[4][1], Freq[4][2], Freq[4][3], Freq[4][4], Freq[4][5], Freq[4][6], Freq[4][7], Freq[4][8], Freq[4][9], Freq[4][10], Freq[4][11], Freq[5][0], Freq[5][1], Freq[5][2], Freq[5][3], Freq[5][4], Freq[5][5], Freq[5][6], Freq[5][7], Freq[5][8], Freq[5][9], Freq[5][10], Freq[5][11], Freq[6][0], Freq[6][1], Freq[6][2], Freq[6][3], Freq[6][4], Freq[6][5], Freq[6][6], Freq[6][7], Freq[6][8], Freq[6][9], Freq[6][10], Freq[6][11], Freq[7][0], Freq[7][1], Freq[7][2], Freq[7][3], Freq[7][4], Freq[7][5], Freq[7][6], Freq[7][7], Freq[7][8], Freq[7][9], Freq[7][10], Freq[7][11], Freq[8][0], Freq[8][1], Freq[8][2], Freq[8][3], Freq[8][4], Freq[8][5], Freq[8][6], Freq[8][7], Freq[8][8], Freq[8][9], Freq[8][10], Freq[8][11], Freq[9][0], Freq[9][1], Freq[9][2], Freq[9][3], Freq[9][4], Freq[9][5], Freq[9][6], Freq[9][7], Freq[9][8], Freq[9][9], Freq[9][10], Freq[9][11], R-Idx[0], R-OP[0][s], R-Busy[0], R-Busy Duration[0][s], Max Calls[0], Searching[0], Recovery[0], New SyncSource[0], Wide Band[0], Narrow Band[0], R-Idx[1], R-OP[1][s], R-Busy[1], R-Busy Duration[1][s], Max Calls[1], Searching[1], Recovery[1], New SyncSource[1], Wide Band[1], Narrow Band[1], R-Idx[2], R-OP[2][s], R-Busy[2], R-Busy Duration[2][s], Max Calls[2], Searching[2], Recovery[2], New SyncSource[2], Wide Band[2], Narrow Band[2]", + "_rawStatisticsHeader": "RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duration[s], Min Latency[ms], Avg Latency[ms], Max Latency[ms], G711U, G711A, G729, G722, G726, OPUS, BV32, Handovers, Failed Handovers, SIP reg failed, Handset Removed, Searching, RcHeapFree Running, New SyncSource, LAN Sync Lost, LAN Primary Lost, Freq[0][0], Freq[0][1], Freq[0][2], Freq[0][3], Freq[0][4], Freq[0][5], Freq[0][6], Freq[0][7], Freq[0][8], Freq[0][9], Freq[0][10], Freq[0][11], Freq[1][0], Freq[1][1], Freq[1][2], Freq[1][3], Freq[1][4], Freq[1][5], Freq[1][6], Freq[1][7], Freq[1][8], Freq[1][9], Freq[1][10], Freq[1][11], Freq[2][0], Freq[2][1], Freq[2][2], Freq[2][3], Freq[2][4], Freq[2][5], Freq[2][6], Freq[2][7], Freq[2][8], Freq[2][9], Freq[2][10], Freq[2][11], Freq[3][0], Freq[3][1], Freq[3][2], Freq[3][3], Freq[3][4], Freq[3][5], Freq[3][6], Freq[3][7], Freq[3][8], Freq[3][9], Freq[3][10], Freq[3][11], Freq[4][0], Freq[4][1], Freq[4][2], Freq[4][3], Freq[4][4], Freq[4][5], Freq[4][6], Freq[4][7], Freq[4][8], Freq[4][9], Freq[4][10], Freq[4][11], Freq[5][0], Freq[5][1], Freq[5][2], Freq[5][3], Freq[5][4], Freq[5][5], Freq[5][6], Freq[5][7], Freq[5][8], Freq[5][9], Freq[5][10], Freq[5][11], Freq[6][0], Freq[6][1], Freq[6][2], Freq[6][3], Freq[6][4], Freq[6][5], Freq[6][6], Freq[6][7], Freq[6][8], Freq[6][9], Freq[6][10], Freq[6][11], Freq[7][0], Freq[7][1], Freq[7][2], Freq[7][3], Freq[7][4], Freq[7][5], Freq[7][6], Freq[7][7], Freq[7][8], Freq[7][9], Freq[7][10], Freq[7][11], Freq[8][0], Freq[8][1], Freq[8][2], Freq[8][3], Freq[8][4], Freq[8][5], Freq[8][6], Freq[8][7], Freq[8][8], Freq[8][9], Freq[8][10], Freq[8][11], Freq[9][0], Freq[9][1], Freq[9][2], Freq[9][3], Freq[9][4], Freq[9][5], Freq[9][6], Freq[9][7], Freq[9][8], Freq[9][9], Freq[9][10], Freq[9][11], R-Idx[0], R-OP[0][s], R-Busy[0], R-Busy Duration[0][s], Max Calls[0], Searching[0], Recovery[0], New SyncSource[0], Wide Band[0], Narrow Band[0], R-Idx[1], R-OP[1][s], R-Busy[1], R-Busy Duration[1][s], Max Calls[1], Searching[1], Recovery[1], New SyncSource[1], Wide Band[1], Narrow Band[1], R-Idx[2], R-OP[2][s], R-Busy[2], R-Busy Duration[2][s], Max Calls[2], Searching[2], Recovery[2], New SyncSource[2], Wide Band[2], Narrow Band[2]" + }, + "verdict": { + "healthy": false, + "warnings": [ + "4 recent power-loss reboot(s); most recent at 2026-06-18T11:09:04" + ], + "info": [] + }, + "fetchedAt": "2026-07-27T19:51:57.070Z" +} \ No newline at end of file diff --git a/tests/fixtures/dect/status-933-f63642.xml b/tests/fixtures/dect/status-933-f63642.xml new file mode 100644 index 0000000..a36473d --- /dev/null +++ b/tests/fixtures/dect/status-933-f63642.xml @@ -0,0 +1,92 @@ + + + +Yes +Ready(TXT_STATE_KEEP_ALIVE) Secondary +IPDECT-V2 (DBS-210-3PC) +Generic SIP (RFC 3261) +SME VoIP +Base Idx:1 +US +No Conflict +27-Jul-2026 15:51:57 +39 Days 04:36:13 (H:M:S) +135098C1; RPN:04 +6cab05f63642 +10.52.38.152 +0000 +IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29 + +https://cisco.sipflash.com +dms/dbS210 + + +2026-06-18 11:09:04 (135) Power Loss (80) Firmware Version 05-01-03-0101-09 +2026-05-31 06:44:34 (134) Power Loss (80) Firmware Version 05-01-03-0101-09 +2026-05-28 00:47:55 (133) Forced Reboot (81) Firmware Version 05-01-02-0101-05 +2026-05-10 07:19:17 (132) Unexpected Reboot (43) Firmware Version 05-01-02-0101-05 +2026-05-10 07:11:23 (131) Power Loss (80) Firmware Version 05-01-02-0101-05 +2026-04-12 09:21:45 (130) Power Loss (80) Firmware Version 05-01-02-0101-05 + +Idle + +N/A +Not Installed + + +Unavailable +N/A + + +RSSI for RPN:4 is -49 [dBm] + + +4 +-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! + + + + + + + + +3813285 +0 +0 +0 +0 +5485843 +0 +0 +0 +4773 + +RPN, MAC-Addr, OP[s], DT[s], Call Cnt, Call Drop, Call Failed, Duration[s], Active Calls, Max Calls, Busy, Busy Duration[s], Min Latency[ms], Avg Latency[ms], Max Latency[ms], G711U, G711A, G729, G722, G726, OPUS, BV32, Handovers, Failed Handovers, SIP reg failed, Handset Removed, Searching, RcHeapFree Running, New SyncSource, LAN Sync Lost, LAN Primary Lost, Freq[0][0], Freq[0][1], Freq[0][2], Freq[0][3], Freq[0][4], Freq[0][5], Freq[0][6], Freq[0][7], Freq[0][8], Freq[0][9], Freq[0][10], Freq[0][11], Freq[1][0], Freq[1][1], Freq[1][2], Freq[1][3], Freq[1][4], Freq[1][5], Freq[1][6], Freq[1][7], Freq[1][8], Freq[1][9], Freq[1][10], Freq[1][11], Freq[2][0], Freq[2][1], Freq[2][2], Freq[2][3], Freq[2][4], Freq[2][5], Freq[2][6], Freq[2][7], Freq[2][8], Freq[2][9], Freq[2][10], Freq[2][11], Freq[3][0], Freq[3][1], Freq[3][2], Freq[3][3], Freq[3][4], Freq[3][5], Freq[3][6], Freq[3][7], Freq[3][8], Freq[3][9], Freq[3][10], Freq[3][11], Freq[4][0], Freq[4][1], Freq[4][2], Freq[4][3], Freq[4][4], Freq[4][5], Freq[4][6], Freq[4][7], Freq[4][8], Freq[4][9], Freq[4][10], Freq[4][11], Freq[5][0], Freq[5][1], Freq[5][2], Freq[5][3], Freq[5][4], Freq[5][5], Freq[5][6], Freq[5][7], Freq[5][8], Freq[5][9], Freq[5][10], Freq[5][11], Freq[6][0], Freq[6][1], Freq[6][2], Freq[6][3], Freq[6][4], Freq[6][5], Freq[6][6], Freq[6][7], Freq[6][8], Freq[6][9], Freq[6][10], Freq[6][11], Freq[7][0], Freq[7][1], Freq[7][2], Freq[7][3], Freq[7][4], Freq[7][5], Freq[7][6], Freq[7][7], Freq[7][8], Freq[7][9], Freq[7][10], Freq[7][11], Freq[8][0], Freq[8][1], Freq[8][2], Freq[8][3], Freq[8][4], Freq[8][5], Freq[8][6], Freq[8][7], Freq[8][8], Freq[8][9], Freq[8][10], Freq[8][11], Freq[9][0], Freq[9][1], Freq[9][2], Freq[9][3], Freq[9][4], Freq[9][5], Freq[9][6], Freq[9][7], Freq[9][8], Freq[9][9], Freq[9][10], Freq[9][11], R-Idx[0], R-OP[0][s], R-Busy[0], R-Busy Duration[0][s], Max Calls[0], Searching[0], Recovery[0], New SyncSource[0], Wide Band[0], Narrow Band[0], R-Idx[1], R-OP[1][s], R-Busy[1], R-Busy Duration[1][s], Max Calls[1], Searching[1], Recovery[1], New SyncSource[1], Wide Band[1], Narrow Band[1], R-Idx[2], R-OP[2][s], R-Busy[2], R-Busy Duration[2][s], Max Calls[2], Searching[2], Recovery[2], New SyncSource[2], Wide Band[2], Narrow Band[2] +'00','6CAB05F635DE',3386184,5238180,942,1,8,78353,0,4,0,0,255,0,0,892,0,0,0,35,0,0,285,94,7662,1,1,0,24,0,0,90,109,74,117,75,107,69,112,98,108,80,112,99,142,107,130,90,116,100,119,125,129,107,143,104,136,118,130,106,106,95,141,103,159,97,147,102,139,123,146,123,140,95,166,125,139,127,154,116,164,136,162,113,127,105,164,128,155,128,151,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0 +'00','6CAB05F63642',3386126,5238247,160,0,0,12875,0,2,0,0,1,1,8,146,0,0,0,2,0,0,30,10,167,2,2,2,25,0,0,60,118,72,105,52,118,48,144,57,127,63,122,109,126,88,129,134,129,140,109,127,128,128,142,132,134,85,133,114,117,106,126,97,117,131,133,142,134,82,141,129,137,144,141,124,141,117,122,98,145,78,141,120,146,98,114,90,134,88,123,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0,0,0 + + diff --git a/tests/fixtures/dect/status-with-handsets.xml b/tests/fixtures/dect/status-with-handsets.xml new file mode 100644 index 0000000..910e597 --- /dev/null +++ b/tests/fixtures/dect/status-with-handsets.xml @@ -0,0 +1,86 @@ + + + +Yes +Primary(TXT_STATE_PRIMARY) +IPDECT-V2 (DBS-210-3PC) +Generic SIP (RFC 3261) +SME VoIP +Base Idx:0 +US +No Conflict +21-Jul-2026 14:30:00 +48:15:30 (H:M:S) +13508C9C; RPN:00 +6CAB05F62819 +10.4.11.87 +0000 +IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29 + +https://example.invalid +dms/dbS210 + + +2026-07-15 08:00:00 (200) Normal Reboot (21) Firmware Version 05-01-03-0101-09 + +Idle + +N/A +Not Installed + + +Unavailable +N/A + + +MAC:6CAB05A1B2C3; RSSI:-58 dBm +MAC:6CAB05D4E5F6; RSSI:-71 dBm + + +42 +4 +0 +0 +0 +0 +0 + + +Present +Present + + +Base type:DBS-210-3PC - Required Version:501 Required Branch:309 +Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default + +Off + +911 + + +Registered +Registered +Not Registered +Not Registered + + + + + + +5000 +0 +0 +0 +0 +12000 +0 +0 +0 +100 + +RPN, MAC-Addr, OP[s], DT[s] +00, 6CAB05A1B2C3, 1200, 45 +01, 6CAB05D4E5F6, 980, 62 + + diff --git a/tests/renderers.test.js b/tests/renderers.test.js index f642de1..08ce40a 100644 --- a/tests/renderers.test.js +++ b/tests/renderers.test.js @@ -16,6 +16,7 @@ import { } from '../services/renderers/phoneStatusRenderer.js'; import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js'; import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js'; +import { buildHandsetContext } from '../services/dectStatus/buildHandsetContext.js'; // Timestamp exactly 3 hours in the past — makes `simpleTimeAgo` // deterministic to "3 hours ago" for the duration of this test run. @@ -430,7 +431,18 @@ const fullOkResult = () => ({ { sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80, firmwareAtBoot: '05-01-03-0101-09' }, { sequence: 163, at: '2026-07-02T12:49:50', reasonName: 'Normal Reboot', reasonCode: 21, firmwareAtBoot: '05-01-03-0101-09' }, ], - network: { txPackets: 100, rxPackets: 200, rxDropped: 18, rxErrors: 0, txErrors: 0 }, + network: { + txPackets: 100, + txBlocked: 0, + txDropped: 0, + txErrors: 0, + txBroadcasts: 0, + rxPackets: 200, + rxBlocked: 0, + rxDropped: 18, + rxErrors: 0, + rxBroadcasts: 0, + }, rtp: { total: 2, current: 0, currentLocal: 0, currentRelay: 0 }, security: { customCa: { installed: false }, @@ -440,13 +452,13 @@ const fullOkResult = () => ({ }, verdict: { healthy: false, - warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'], + warnings: ['1 concerning reboot(s) in the last 7 days (latest: Power Loss at 2026-07-02T12:54:12)'], info: ['Rx dropped packets: 18 since last boot'], }, elapsedMs: 812, }); -test('dect status renderer: full dump includes reboot log, emergency numbers, and verdict', () => { +test('dect status renderer: full dump includes reboot log, network stats, and verdict', () => { const md = renderDectStatusMarkdown([fullOkResult()], { storeNum: '782', footer: false, @@ -458,9 +470,83 @@ test('dect status renderer: full dump includes reboot log, emergency numbers, an assert.match(md, /\*\*Reboot log\*\*/); assert.match(md, /⚡ #164/); assert.match(md, /Power Loss/); - assert.match(md, /911, 1911/); - assert.match(md, /healthy: \*\*NO\*\*/); + assert.match(md, /\*\*TX:\*\* 100 pkts · 0 blocked · 0 dropped · 0 errors · 0 bcast/); + assert.match(md, /\*\*RX:\*\* 200 pkts · 0 blocked · 18 dropped · 0 errors · 0 bcast/); + assert.match(md, /\*\*RTP:\*\* 2 total/); + assert.match(md, /multi-cell Primary/); assert.match(md, /Rx dropped packets: 18/); + assert.doesNotMatch(md, /\*\*Security\*\*/); + assert.doesNotMatch(md, /\*\*Emergency numbers\*\*/); + assert.doesNotMatch(md, /Update server/); + assert.doesNotMatch(md, /\*\*Health verdict\*\*/); +}); + +test('dect status renderer: shows registered handsets and RSSI when context provided', () => { + const webexId = 'base-1'; + const handsetCtx = buildHandsetContext({ + dectNetwork: { name: 'Store DECT', handsetsCount: 2 }, + dectBasestations: [{ id: webexId, mac: '6c:ab:05:f6:28:19', linesRegistered: 2 }], + dectHandsets: [ + { + index: 1, + name: 'Handset A', + extension: '50482', + baseStationId: webexId, + mac: '6c:ab:05:a1:b2:c3', + lastRegistrationTime: new Date(Date.now() - 3 * 3600 * 1000).toISOString(), + }, + { + index: 2, + name: 'Handset B', + extension: '50483', + baseStationId: webexId, + mac: '6c:ab:05:d4:e5:f6', + lastRegistrationTime: new Date(Date.now() - 3 * 3600 * 1000).toISOString(), + }, + ], + }); + + const result = fullOkResult(); + result.base.webexId = webexId; + result.data.rssi = [ + { rpn: '00', mac: '6c:ab:05:a1:b2:c3', rssiDbm: -58 }, + { rpn: '01', mac: '6c:ab:05:d4:e5:f6', rssiDbm: -71 }, + ]; + result.data.devicePresence = [{ key: 'RPN_00', present: true, raw: 'Present' }]; + result.data.sipIdentityStatus = [ + { key: 'Line_0', status: 'Registered' }, + { key: 'Line_1', status: 'Registered' }, + ]; + + const md = renderDectStatusMarkdown([result], { + storeNum: '782', + footer: false, + handsetCtx, + }); + + assert.match(md, /\*\*Handsets & RF\*\*/); + assert.match(md, /Handset A.*ext 50482/); + assert.match(md, /Webex lines registered on base: \*\*2\*\*/); + assert.match(md, /-58 dBm/); + assert.match(md, /\*\*SIP\*\* line 0: \*\*Registered\*\*/); + assert.match(md, /\*\*Presence\*\* RPN_00: present/); +}); + +test('dect status renderer: unassigned handsets at store level', () => { + const handsetCtx = buildHandsetContext({ + dectBasestations: [{ id: 'base-1', mac: '6c:ab:05:f6:28:19', linesRegistered: 0 }], + dectHandsets: [ + { name: 'Floater', extension: '9999', baseStationId: null, lastRegistrationTime: null }, + ], + }); + + const md = renderDectStatusMarkdown([fullOkResult()], { + storeNum: '782', + footer: false, + handsetCtx, + }); + assert.match(md, /\*\*Unassigned handsets\*\*/); + assert.match(md, /Floater/); }); test('dect status renderer: empty discovery + offline relay still produces a usable message', () => { diff --git a/tests/statusXml.handsets.test.js b/tests/statusXml.handsets.test.js new file mode 100644 index 0000000..c5dc94e --- /dev/null +++ b/tests/statusXml.handsets.test.js @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { parseStatusXml } from '../integrations/cisco-dect/statusXml.js'; +import { inventoryStatusXml, summarizeInventory } from '../integrations/cisco-dect/statusXmlInventory.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, 'fixtures/dect'); + +const SYNTHETIC = readFileSync(path.join(FIXTURE_DIR, 'status-with-handsets.xml'), 'utf8'); +const STORE933_PRIMARY = readFileSync(path.join(FIXTURE_DIR, 'status-933-f635de.xml'), 'utf8'); +const STORE933_SECONDARY = readFileSync(path.join(FIXTURE_DIR, 'status-933-f63642.xml'), 'utf8'); + +test('parseStatusXml: synthetic fixture parses RSSI rows', () => { + const s = parseStatusXml(SYNTHETIC); + assert.equal(s.rssi.length, 2); + assert.equal(s.rssi[0].mac, '6c:ab:05:a1:b2:c3'); + assert.equal(s.rssi[0].rssiDbm, -58); +}); + +test('parseStatusXml: store 933 primary parses RSSI_Line format', () => { + const s = parseStatusXml(STORE933_PRIMARY); + assert.equal(s.rssi.length, 1); + assert.equal(s.rssi[0].rpn, '4'); + assert.equal(s.rssi[0].rssiDbm, -47); +}); + +test('parseStatusXml: store 933 primary parses device presence and handsets', () => { + const s = parseStatusXml(STORE933_PRIMARY); + assert.equal(s.devicePresence.length, 2); + assert.equal(s.devicePresence[0].extension, '1'); + assert.equal(s.devicePresence[0].deviceType, '6825'); + assert.equal(s.devices.length, 2); + assert.equal(s.devices[0].displayName, '1-50933'); + assert.equal(s.devices[0].rssiDbm, -76); + assert.equal(s.devices[0].batteryPercent, 81); + assert.match(s.devices[1].lockedRpn || '', /RPN:04/); +}); + +test('parseStatusXml: store 933 primary parses SIP identity blocks', () => { + const s = parseStatusXml(STORE933_PRIMARY); + assert.equal(s.sipIdentityStatus.length, 2); + assert.equal(s.sipIdentityStatus[0].status, 'OK'); + assert.match(s.sipIdentityStatus[1].status || '', /Registering/i); +}); + +test('parseStatusXml: store 933 parses Value_Line statistics with MAC', () => { + const s = parseStatusXml(STORE933_PRIMARY); + assert.ok(s.perRpnStats.length >= 2); + assert.equal(s.perRpnStats[0].mac, '6c:ab:05:f6:35:de'); + assert.ok(s.perRpnStats[0].opSeconds > 0); +}); + +test('parseStatusXml: store 933 uptime includes days component', () => { + const s = parseStatusXml(STORE933_PRIMARY); + assert.ok(s.time.operatingTimeSeconds > 86400); +}); + +test('parseStatusXml: store 933 primary parses multi-cell role', () => { + const s = parseStatusXml(STORE933_PRIMARY); + assert.equal(s.multiCell.role, 'primary'); + assert.equal(s.multiCell.state, 'ready'); +}); + +test('parseStatusXml: store 933 secondary has RSSI but no registered handsets', () => { + const s = parseStatusXml(STORE933_SECONDARY); + assert.equal(s.rssi[0].rssiDbm, -49); + assert.equal(s.devices.length, 0); + assert.equal(s.multiCell.role, 'secondary'); + assert.equal(s.multiCell.state, 'ready'); +}); + +test('inventoryStatusXml: store 933 finds handset-related paths', () => { + const inv = inventoryStatusXml(STORE933_PRIMARY); + const summary = summarizeInventory(inv); + assert.ok(summary.handsetRelated > 0); + assert.ok(summary.paths.some((p) => /Device_Information/i.test(p))); +}); diff --git a/tests/statusXml.test.js b/tests/statusXml.test.js index 9ae1997..e11a410 100644 --- a/tests/statusXml.test.js +++ b/tests/statusXml.test.js @@ -213,6 +213,16 @@ test('parseStatusXml: multi-cell role parsed to a normalized token', () => { assert.match(s.multiCell.raw, /TXT_STATE_UNCHAINED/); }); +test('parseStatusXml: ready+primary multi-cell keeps primary as role', () => { + const xml = FIXTURE.replace( + /[^<]+<\/Multi_Cell>/, + 'Ready(TXT_STATE_KEEP_ALIVE) Primary', + ); + const s = parseStatusXml(xml); + assert.equal(s.multiCell.role, 'primary'); + assert.equal(s.multiCell.state, 'ready'); +}); + test('parseStatusXml: reboot log is 6 sorted entries with structured fields', () => { const s = parseStatusXml(FIXTURE); assert.equal(s.rebootLog.length, 6); @@ -260,23 +270,48 @@ test('parseStatusXml: pushToTalk feature flag', () => { // ─── Health verdict ───────────────────────────────────────────────── -test('summarizeBaseHealth: fixture flags power-loss reboot and short uptime', () => { +test('summarizeBaseHealth: fixture rx-dropped surfaces as info (old reboots ignored)', () => { 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. + // Fixture power-loss reboot is from 2026-07-02 — outside the 7-day window. assert.ok( - verdict.warnings.some((w) => /power-loss/i.test(w)), - `expected power-loss warning; got: ${JSON.stringify(verdict.warnings)}`, + !verdict.warnings.some((w) => /concerning reboot/i.test(w)), + `old reboot should not warn; 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: power-loss within 7 days is flagged', () => { + const recent = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 19); + const verdict = summarizeBaseHealth({ + conflictInfo: 'No Conflict', + network: { rxDropped: 0 }, + rebootLog: [ + { at: recent, sequence: 1, reasonName: 'Power Loss', reasonCode: 80 }, + ], + }); + assert.equal(verdict.healthy, false); + assert.ok( + verdict.warnings.some((w) => /concerning reboot/i.test(w)), + `expected concerning reboot warning; got: ${JSON.stringify(verdict.warnings)}`, + ); +}); + +test('summarizeBaseHealth: unexpected reboot within 7 days is flagged', () => { + const recent = new Date(Date.now() - 1 * 86400000).toISOString().slice(0, 19); + const verdict = summarizeBaseHealth({ + conflictInfo: 'No Conflict', + network: { rxDropped: 0 }, + rebootLog: [ + { at: recent, sequence: 1, reasonName: 'Unexpected Reboot', reasonCode: 43 }, + ], + }); + assert.ok(verdict.warnings.some((w) => /Unexpected reboot/i.test(w))); +}); + test('summarizeBaseHealth: RF conflict is flagged as a warning', () => { const s = parseStatusXml(FIXTURE.replace( 'No Conflict',