Add MPP desk phone diagnostics follow-up to /phonestatus via relay.

Wire CP-78xx probe discovery, relay phone-probe commands, and a chat follow-up message so store desk phones get registration, switch, and provisioning detail alongside DECT and WAN diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-28 18:01:01 -04:00
parent 26ae704dff
commit f7953b8eb5
41 changed files with 3696 additions and 10 deletions

View file

@ -499,6 +499,25 @@ DECT_RELAY_AGENT_TOKEN=
# node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/ # node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/
# Uses GET /api/dect/raw-xml/:storeNumber (collect-raw via relay). # Uses GET /api/dect/raw-xml/:storeNumber (collect-raw via relay).
# -----------------------------------------------------------------------------
# Cisco MPP desk phone — LOCAL DEV TEST HARNESS
# Used by scripts/testMppPhone.js when iterating locally against a lab
# CP-78xx on 10.x. NOT read by the bot at runtime — production phone
# access lives in DECT_RELAY_* + dect-relay-agent/ (phone-probe commands).
#
# Prerequisite: enable Web Server on the phone (Settings → Security →
# Web Access). Phones use HTTPS with a self-signed cert. Password is
# optional — /Status.json is usually readable without admin credentials.
# -----------------------------------------------------------------------------
PHONE_TEST_IP=10.0.0.100
# PHONE_TEST_USER=admin
# PHONE_TEST_PASSWORD=
PHONE_TEST_TIMEOUT_MS=15000
# Capture MPP probe data from a live store (bot + relay must be running):
# node scripts/probePhone.js raw 782 --save-dir tests/fixtures/mpp/
# Uses GET /api/phone/probe/:storeNumber (phone-probe-raw via relay).
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Twilio /calltest — outbound voice path testing # Twilio /calltest — outbound voice path testing
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View file

@ -55,11 +55,22 @@ const LONG_HELP = {
}, },
phonestatus: { phonestatus: {
title: '/phonestatus', title: '/phonestatus',
usage: ['/phonestatus <store>', '/phonestatus <store> detailed'], usage: [
examples: ['/phonestatus 782', '/phonestatus 782 detailed'], '/phonestatus <store>',
'/phonestatus <store> detailed',
'/phonestatus <store> verbose',
'/phonestatus <store> detailed verbose',
],
examples: [
'/phonestatus 782',
'/phonestatus 782 detailed',
'/phonestatus 782 verbose',
'/phonestatus 782 debug',
],
notes: [ notes: [
'Shows DECT basestations + IP phones with Meraki links.', 'Shows DECT basestations + IP phones with Meraki links.',
'Detailed mode adds firmware, serial, SIP details and errors.', 'Detailed mode adds firmware, serial, SIP details and errors (main message only).',
'When MPP desk phones are discovered on 10.x, a follow-up **MPP Desk Phone Diagnostics** message arrives via the relay (registration, switch port, provisioning history). Pass `verbose` or `debug` for extension/debug counters.',
'When the store is Prisma SD-WAN managed (site name `CG<store>` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).', 'When the store is Prisma SD-WAN managed (site name `CG<store>` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).',
'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.', 'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.',
'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus <store>`.', 'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus <store>`.',

View file

@ -13,12 +13,15 @@ import {
renderDectDiagnosticsMarkdown, renderDectDiagnosticsMarkdown,
} from '../services/renderers/phoneStatusRenderer.js'; } from '../services/renderers/phoneStatusRenderer.js';
import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js'; import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
import { buildIgmpFixCard } from './igmpFix.js'; import { buildIgmpFixCard } from './igmpFix.js';
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js'; import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
import { extractRequester } from '../utils/requester.js'; import { extractRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { discoverDectBases } from '../services/dectDiscovery.js'; import { discoverDectBases } from '../services/dectDiscovery.js';
import { discoverDeskPhones } from '../services/phoneDiscovery.js';
import { collectAll } from '../services/dectCollectorService.js'; import { collectAll } from '../services/dectCollectorService.js';
import { probeAll } from '../services/phoneCollectorService.js';
import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.js'; import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.js';
import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js'; import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js';
@ -31,9 +34,13 @@ export async function handlePhoneStatus(bot, trigger) {
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
const isDetailed = (args[1]?.toLowerCase() === 'detailed') || const isDetailed = argIncludes(args, 'detailed')
(query.mode === 'detailed') || || (query.mode === 'detailed')
(query.detailed === 'true' || query.detailed === true); || (query.detailed === 'true' || query.detailed === true);
const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug')
|| query.verbose === 'true' || query.verbose === true
|| query.debug === 'true' || query.debug === true;
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
const errorMsg = 'Please provide a 24 digit store number.\n' + const errorMsg = 'Please provide a 24 digit store number.\n' +
@ -72,6 +79,9 @@ export async function handlePhoneStatus(bot, trigger) {
const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled
? discoverDectBases(data) ? discoverDectBases(data)
: { bases: [], warnings: [] }; : { bases: [], warnings: [] };
const { phones: mppPhones, warnings: mppDiscoveryWarnings } = dectFollowUpEnabled
? discoverDeskPhones(data)
: { phones: [], warnings: [] };
if (discoveryWarnings.length > 0) { if (discoveryWarnings.length > 0) {
logger( logger(
'phone:status', 'phone:status',
@ -79,6 +89,22 @@ export async function handlePhoneStatus(bot, trigger) {
'warn', 'warn',
); );
} }
if (mppDiscoveryWarnings.length > 0) {
logger(
'phone:status',
`MPP discovery warnings for store ${storeNum}: ${mppDiscoveryWarnings.map((w) => w.reason).join('; ')}`,
'warn',
);
}
if (dectFollowUpEnabled) {
logger(
'phone:status',
`MPP discovery for store ${storeNum}: ` +
`${mppPhones.length > 0
? `${mppPhones.length} desk phone(s) selected — follow-up scheduled (${mppPhones.map((p) => p.ip).join(', ')})`
: 'no MPP desk phones selected — no follow-up'}`,
);
}
// WAN follow-up discovery. Cheap Prisma-side check: hits the // WAN follow-up discovery. Cheap Prisma-side check: hits the
// 4-hour-cached sites list to confirm this store is Prisma- // 4-hour-cached sites list to confirm this store is Prisma-
@ -116,6 +142,7 @@ export async function handlePhoneStatus(bot, trigger) {
detailed: isDetailed, detailed: isDetailed,
footer: true, footer: true,
dectFollowUpBaseCount: reachableBases.length, dectFollowUpBaseCount: reachableBases.length,
mppFollowUpPhoneCount: mppPhones.length,
wanFollowUpEnabled, wanFollowUpEnabled,
}); });
await bot.say('markdown', reply || 'No data available.'); await bot.say('markdown', reply || 'No data available.');
@ -133,6 +160,12 @@ export async function handlePhoneStatus(bot, trigger) {
}); });
} }
if (dectFollowUpEnabled && mppPhones.length > 0) {
runMppPhoneFollowUp(bot, storeNum, mppPhones, { verbose: isVerbose }).catch((err) => {
logger('phone:status', `MPP phone follow-up failed for store ${storeNum}: ${err.message}`, 'error');
});
}
// WAN follow-up (mirrors the DECT pattern). Only kicked when // WAN follow-up (mirrors the DECT pattern). Only kicked when
// discovery above already confirmed we have a Prisma site for // discovery above already confirmed we have a Prisma site for
// this store. Fire-and-forget with a stable log scope. // this store. Fire-and-forget with a stable log scope.
@ -210,6 +243,13 @@ async function runDectFollowUp(bot, storeNum, bases) {
await bot.say('markdown', md); await bot.say('markdown', md);
} }
async function runMppPhoneFollowUp(bot, storeNum, phones, { verbose = false } = {}) {
const results = await probeAll(phones);
const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose });
if (!md) return;
await bot.say('markdown', md);
}
/** /**
* Prisma SD-WAN follow-up. Runs the enrichment composer (which * Prisma SD-WAN follow-up. Runs the enrichment composer (which
* hydrates site + elements + healthscore + per-path LQM + alarms in * hydrates site + elements + healthscore + per-path LQM + alarms in
@ -242,3 +282,8 @@ function safeSiteName(storeNum) {
return '<invalid store number>'; return '<invalid store number>';
} }
} }
function argIncludes(args, token) {
const needle = String(token).toLowerCase();
return (args || []).some((a) => String(a).toLowerCase() === needle);
}

View file

@ -41,6 +41,18 @@ DECT_ADMIN_PASSWORD=replace-with-dect-serviceability-password
# is aggressive. # is aggressive.
DECT_ADMIN_TIMEOUT_MS=30000 DECT_ADMIN_TIMEOUT_MS=30000
# ─── MPP desk phone admin credentials (CP-78xx, Webex Calling) ───────
#
# Used for phone-probe / phone-probe-raw relay commands. Phones use
# HTTPS on 443 with a self-signed cert (agent accepts untrusted TLS).
# Enable Web Server on the phone (Settings → Security → Web Access).
# PHONE_ADMIN_PASSWORD is OPTIONAL — Webex MPP serves /Status.json
# without credentials when the user web UI is enabled. Set only if
# your site requires auth for admin/legacy paths.
# PHONE_ADMIN_USER=admin
# PHONE_ADMIN_PASSWORD=
PHONE_ADMIN_TIMEOUT_MS=15000
# ─── Optional tuning ───────────────────────────────────────────────── # ─── Optional tuning ─────────────────────────────────────────────────
# #
# How long to wait between reconnect attempts when the bot socket # How long to wait between reconnect attempts when the bot socket

View file

@ -40,7 +40,8 @@
# client.js (at /workspace/integrations/cisco-dect/client.js) would # client.js (at /workspace/integrations/cisco-dect/client.js) would
# never find axios and blow up with ERR_MODULE_NOT_FOUND at runtime. # never find axios and blow up with ERR_MODULE_NOT_FOUND at runtime.
# Placing node_modules one level higher fixes it: both the agent # Placing node_modules one level higher fixes it: both the agent
# AND the shared integrations resolve axios via /workspace/node_modules. # AND the shared integrations (cisco-dect + cisco-mpp-phone) resolve
# axios via /workspace/node_modules.
# The package.json at /workspace/ also declares "type":"module" so # The package.json at /workspace/ also declares "type":"module" so
# every .js file under /workspace/ is treated as ESM without needing # every .js file under /workspace/ is treated as ESM without needing
# its own package.json. # its own package.json.
@ -97,6 +98,7 @@ COPY --chown=node:node dect-relay-agent/index.js ./dect-relay-agent/index.js
# Shared modules the agent imports from the parent workspace. # Shared modules the agent imports from the parent workspace.
COPY --chown=node:node integrations/cisco-dect ./integrations/cisco-dect COPY --chown=node:node integrations/cisco-dect ./integrations/cisco-dect
COPY --chown=node:node integrations/cisco-mpp-phone ./integrations/cisco-mpp-phone
COPY --chown=node:node utils/httpDigestAuth.js ./utils/httpDigestAuth.js COPY --chown=node:node utils/httpDigestAuth.js ./utils/httpDigestAuth.js
# Optional metadata that shows up in `docker inspect` output — useful # Optional metadata that shows up in `docker inspect` output — useful

View file

@ -16,6 +16,7 @@
!dect-relay-agent/package.json !dect-relay-agent/package.json
!dect-relay-agent/index.js !dect-relay-agent/index.js
!integrations/cisco-dect/** !integrations/cisco-dect/**
!integrations/cisco-mpp-phone/**
!utils/httpDigestAuth.js !utils/httpDigestAuth.js
# ─── Never-ship, even inside whitelisted trees ────────────────────── # ─── Never-ship, even inside whitelisted trees ──────────────────────

View file

@ -168,6 +168,40 @@ node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/
The bot terminates the socket if no `pong` arrives within 90s; the agent auto-reconnects with exponential backoff (1s / 2s / 4s / … capped at 30s + 0-1000ms jitter). The bot terminates the socket if no `pong` arrives within 90s; the agent auto-reconnects with exponential backoff (1s / 2s / 4s / … capped at 30s + 0-1000ms jitter).
## MPP desk phones (CP-78xx, Webex Calling)
The same relay agent can probe Cisco MPP desk phones registered directly with Webex. Phones use **HTTPS on 443** with a self-signed certificate (the agent accepts untrusted TLS, same as DBS-210).
### Manual prerequisite (per phone)
Before `phone-probe` can return data:
1. On the phone: **Settings → Security → Web Access** — enable **Web Server** (Admin Access optional for read-only probes).
2. From a host on `10.x`, verify JSON is reachable without credentials (typical for Webex-provisioned MPP):
```bash
curl -k https://<phone_ip>/Status.json
```
3. **No admin password is required** for read-only status on Webex Calling MPP phones — `/Status.json` is served by the user web UI. Set `PHONE_ADMIN_PASSWORD` only if your site locks down admin paths (`/admin/*`) and you have a local password.
Lab store for fixture capture: **782**.
### Relay commands
**Bot → Agent (phone probe):**
```json
{ "id": "cmd_<uuid>", "type": "phone-probe", "targetIp": "10.4.11.50" }
{ "id": "cmd_<uuid>", "type": "phone-probe-raw", "targetIp": "10.4.11.50" }
```
`phone-probe-raw` includes full response bodies for fixture capture. `baseIp` is accepted as an alias for `targetIp`.
**Capture script (from repo root, bot + relay running):**
```bash
node scripts/probePhone.js raw 782 --save-dir tests/fixtures/mpp/
```
Or via HTTP API: `GET /api/phone/probe/782?ip=10.x.x.x`
## Safety guarantees ## Safety guarantees
- DECT admin credentials NEVER leave this agent. The bot only knows the WSS bearer token. - DECT admin credentials NEVER leave this agent. The bot only knows the WSS bearer token.

View file

@ -53,6 +53,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "ERROR: $REPO_ROOT does not look like the collabSupport repo root (no integrations/cisco-dect/)" >&2 echo "ERROR: $REPO_ROOT does not look like the collabSupport repo root (no integrations/cisco-dect/)" >&2
exit 1 exit 1
} }
[[ -d "$REPO_ROOT/integrations/cisco-mpp-phone" ]] || {
echo "ERROR: $REPO_ROOT is missing integrations/cisco-mpp-phone/ (required for MPP phone-probe)" >&2
exit 1
}
# ─── Determine version tag ──────────────────────────────────────── # ─── Determine version tag ────────────────────────────────────────
if [[ -n "$TAG_OVERRIDE" ]]; then if [[ -n "$TAG_OVERRIDE" ]]; then

View file

@ -37,6 +37,10 @@ import {
summarizeBaseHealth, summarizeBaseHealth,
} from '../integrations/cisco-dect/statusXml.js'; } from '../integrations/cisco-dect/statusXml.js';
import { inventoryStatusXml } from '../integrations/cisco-dect/statusXmlInventory.js'; import { inventoryStatusXml } from '../integrations/cisco-dect/statusXmlInventory.js';
import { createMppPhoneClient } from '../integrations/cisco-mpp-phone/client.js';
import { runPhoneProbe } from '../integrations/cisco-mpp-phone/probes.js';
import { parseStatusXml as parsePhoneStatusXml, summarizePhoneHealth } from '../integrations/cisco-mpp-phone/statusXml.js';
import { parseStatusJson, summarizePhoneHealthFromJson } from '../integrations/cisco-mpp-phone/statusJson.js';
// ─── Config ───────────────────────────────────────────────────────── // ─── Config ─────────────────────────────────────────────────────────
@ -47,10 +51,13 @@ const CFG = {
dectUser: process.env.DECT_ADMIN_USER || 'admin', dectUser: process.env.DECT_ADMIN_USER || 'admin',
dectPass: process.env.DECT_ADMIN_PASSWORD, dectPass: process.env.DECT_ADMIN_PASSWORD,
dectTimeout: Number(process.env.DECT_ADMIN_TIMEOUT_MS) || 30_000, dectTimeout: Number(process.env.DECT_ADMIN_TIMEOUT_MS) || 30_000,
phoneUser: process.env.PHONE_ADMIN_USER || 'admin',
phonePass: process.env.PHONE_ADMIN_PASSWORD,
phoneTimeout: Number(process.env.PHONE_ADMIN_TIMEOUT_MS) || 15_000,
reconnectMaxMs: Number(process.env.DECT_RELAY_RECONNECT_MAX_MS) || 30_000, reconnectMaxMs: Number(process.env.DECT_RELAY_RECONNECT_MAX_MS) || 30_000,
}; };
const AGENT_VERSION = '0.2.0'; const AGENT_VERSION = '0.3.0';
const CAPABILITIES = [ const CAPABILITIES = [
'collect', 'collect',
'collect-raw', 'collect-raw',
@ -60,6 +67,8 @@ const CAPABILITIES = [
'force-reboot-chain', 'force-reboot-chain',
'factory-reset', 'factory-reset',
'reconfigure-tree', 'reconfigure-tree',
'phone-probe',
'phone-probe-raw',
]; ];
const HEARTBEAT_INTERVAL_MS = 30_000; const HEARTBEAT_INTERVAL_MS = 30_000;
@ -219,7 +228,15 @@ async function handleMessage(raw) {
replyError(msg.id, 'MALFORMED', 'command frame missing `type`'); replyError(msg.id, 'MALFORMED', 'command frame missing `type`');
return; return;
} }
if (!msg.baseIp) {
const phoneCmd = msg.type === 'phone-probe' || msg.type === 'phone-probe-raw';
const targetIp = msg.targetIp || msg.baseIp;
if (phoneCmd) {
if (!targetIp) {
replyError(msg.id, 'MALFORMED', 'phone command frame missing `targetIp`');
return;
}
} else if (!msg.baseIp) {
replyError(msg.id, 'MALFORMED', 'command frame missing `baseIp`'); replyError(msg.id, 'MALFORMED', 'command frame missing `baseIp`');
return; return;
} }
@ -230,7 +247,8 @@ async function handleMessage(raw) {
replyOk(msg.id, result, Date.now() - started); replyOk(msg.id, result, Date.now() - started);
} catch (err) { } catch (err) {
const code = err?.code || 'AGENT_EXCEPTION'; const code = err?.code || 'AGENT_EXCEPTION';
log('dispatch', `Command ${msg.type} for ${msg.baseIp} failed: ${err.message}`, 'warn'); const label = phoneCmd ? targetIp : msg.baseIp;
log('dispatch', `Command ${msg.type} for ${label} failed: ${err.message}`, 'warn');
replyError(msg.id, code, err.message, { stack: err.stack?.split('\n')[0] }); replyError(msg.id, code, err.message, { stack: err.stack?.split('\n')[0] });
} }
} }
@ -241,6 +259,35 @@ async function handleMessage(raw) {
* field of the reply frame. * field of the reply frame.
*/ */
async function dispatch(cmd) { async function dispatch(cmd) {
if (cmd.type === 'phone-probe' || cmd.type === 'phone-probe-raw') {
const host = cmd.targetIp || cmd.baseIp;
const clientOpts = {
host,
timeoutMs: CFG.phoneTimeout,
};
if (CFG.phonePass) {
clientOpts.user = CFG.phoneUser;
clientOpts.password = CFG.phonePass;
}
const client = createMppPhoneClient(clientOpts);
const includeBodies = cmd.type === 'phone-probe-raw';
const probeResult = await runPhoneProbe(client, { includeBodies });
const parsed = probeResult.statusJson
? parseStatusJson(probeResult.statusJson)
: (probeResult.statusXml ? parsePhoneStatusXml(probeResult.statusXml) : null);
const verdict = parsed
? (probeResult.statusJson ? summarizePhoneHealthFromJson(parsed) : summarizePhoneHealth(parsed))
: null;
return {
...probeResult,
parsed,
verdict,
byteLength: probeResult.statusXml
? Buffer.byteLength(probeResult.statusXml, 'utf8')
: null,
};
}
const client = createDectClient({ const client = createDectClient({
host: cmd.baseIp, host: cmd.baseIp,
user: CFG.dectUser, user: CFG.dectUser,

View file

@ -0,0 +1,281 @@
# MPP Phone Probe — Data Inventory
Reference catalog for Cisco CP-78xx phones on Webex Calling (WxC MPP firmware).
Validated against lab store **782** (`10.43.206.157`, CP-7841-3PCC, MAC `CC98914F6799`).
Use this document to decide what to surface in `/phonestatus`, tickets, and diagnostics.
---
## How data flows
```
Webex Control Hub Phone HTTPS (via DC relay agent)
───────────────── ────────────────────────────────
collectPhoneStatus() → GET /Status.json ← primary (parsed today)
MAC, name, IP GET /Download%20Status.json ← fetched, not parsed
product, webexId GET /ns.json ← fetched, not parsed
GET /basic/System.json ← fetched, not parsed
GET /admin/* ← login HTML (no password)
```
**Auth:** No credentials required for JSON endpoints when **Web Server** is enabled
(Settings → Security → Web Access). Webex does not provide per-phone admin passwords.
**Typical probe time:** ~11s for 8 sequential GETs through the relay (store 782).
**CLI:**
```bash
node scripts/probePhone.js probe 782 --ip 10.43.206.157
node scripts/probePhone.js raw 782 --ip 10.43.206.157 --save-dir tests/fixtures/mpp/
```
---
## Source A — Webex inventory (before probe)
From `collectPhoneStatus()` / `discoverDeskPhones()`. Used to find phones and match MAC ↔ IP.
| Field | Example (782) | Notes |
|-------|---------------|-------|
| `mac` | `CC98914F6799` | Normalized to colon format in probe target |
| `name` | `Store 00782 CP-7841` | Display name in Control Hub |
| `product` | `Cisco 7841` | Used to classify MPP vs room/DECT |
| `ip` | `10.43.206.157` | From Meraki or Webex `ipAddress` |
| `webexId` | device UUID | Cross-reference to Control Hub |
| `source` | `meraki` / `webex` | Where IP came from |
| `capabilities` | `["xapi"]` | Present on WxC phones; not a probe blocker |
**Not available from Webex alone:** live registration state, firmware load name, switch port,
provisioning history, SIP registrar IP.
---
## Source B — `/Status.json` (~12 KB)
Phone web UI: **Info → Status**. Primary source — fully parsed into `parsed` + `verdict`.
### Structured fields extracted today (`parseStatusJson`)
| Group | Fields | Example (782) |
|-------|--------|---------------|
| **Device** | product, mac, firmware, serial, hostname | CP-7841-3PCC, cc:98:91:4f:67:99 |
| **Network** | ipv4, netmask, gateway, dns1, dns2, vlan, connectionType, ipStatus | 10.43.206.157, VLAN 4095, DHCP, OK |
| **Registration** | `registration` (Ext 1), `lines[]` per extension | Registered → 150.253.156.211 |
| **SIP counters** | messagesSent, messagesRecv | 82 / 81 |
| **Reboot history** | last 5 reasons + timestamps | Provisioning, Upgrade, Cloud Triggered |
| **Health verdict** | `healthy`, `warnings[]`, `info[]` | healthy: true |
### All sections in raw `Status.json` (~93 populated fields on 782)
| Section | Useful for | Key fields |
|---------|------------|------------|
| **System Information** | Identity | Host Name, Primary NTP (`ntp.broadcloudpbx.net`) |
| **IPv4 Information** | Network triage | IP Status, DHCP/Static, IP, mask, gateway, DNS |
| **IPv6 Information** | IPv6 rollout | Usually empty/initializing on store LAN |
| **Reboot History** | "Why did it reboot?" | 5 entries: Provisioning / Upgrade / Cloud Triggered + timestamp |
| **VPN Status** | VPN phones | VPN Connected, client address (No on 782) |
| **Product Information** | Inventory match | Product, Serial, VID, MAC, Software/Hardware version, Client Cert, **WxC** auth type |
| **Phone Status** | Uptime & health | Current time, **Elapsed Time**, SIP byte/packet counters, **Operational VLAN**, **SW/PC Port** link, Upgrade Status |
| **Dot1x Authentication** | 802.1X stores | Transaction status (Authenticated), Protocol |
| **LED Status** | Visual state | Line 14, headset, speaker, mute, MWI cadence/color (**Line 1 Green** = registered) |
| **Ext 14 Status** | **Core voice health** | Registration State, Last Registration At/IP, Next Registration In Seconds, MWI, Hoteling, Extended Function |
| **Paging Status** | Overhead paging | Multicast Rx/Tx packets |
| **XML Streaming Status** | Cisco XML apps | Streaming Rx packets |
| **TR-069 Status** | Remote mgmt | Feature disabled on WxC MPP |
| **PRT Status** | Problem report tool | Generation/upload status (usually empty) |
### Per-extension detail (Ext 14)
| Field | Ext 1 (782) | Ext 24 |
|-------|-------------|---------|
| Registration State | **Registered** | Not Registered |
| Last Registration At | timestamp | — |
| Last Registration IP | 150.253.156.211 | — |
| Next Registration In Seconds | ~70 | — |
| Message Waiting | No | No |
| Hoteling State | Disabled | Disabled |
---
## Source C — `/Download%20Status.json` (~3 KB)
Phone web UI: **Info → Download Status**. Fetched in probe; **not parsed** into `parsed` today.
| Section | Fields | Example (782) |
|---------|--------|---------------|
| **Firmware Upgrade Status** | 13 history entries | `[date][https://binaries.webex.com/.../sip78xx....loads]Upgrade Succeeded.` |
| **Transition Authorization Status** | WxC migration | `migration-service-a.wbx2.com` — Authorization Succeeded |
| **Provisioning Status** | Resync history | `[https://cisco.sipflash.com:443/*]Resync Succeeded.` |
| **Custom CA Status** | Custom CA provisioning | Empty |
| **MIC Cert Refresh Status** | Certificate renewal | **MIC Cert Download Failed** — file not found (candidate warning) |
**Good for:** "Did provisioning/firmware succeed recently?" without Control Hub.
---
## Source D — `/ns.json` (~9 KB)
Phone web UI: **Info → Network Statistics**. Fetched in probe; **not parsed** today.
| Section | Useful fields | Example (782) |
|---------|---------------|---------------|
| **Ethernet Information** | Tx/Rx frames, broadcasts, multicasts, unicasts | ~2M frames each direction |
| **Network Port Information** | Error counters, frame size histogram | All zeros (clean link) |
| **LLDP/CDP neighbors** | **Switch hostname, port, mgmt IP** | **SW00782R**, Port 45, 10.229.105.251 |
| **Port speed** | Link speed/duplex | 100M Full |
| **Access Port Information** | PC port stats | All zeros (PC port disabled) |
**Good for:** "Which switch port is this phone on?" — often more useful than Webex for physical troubleshooting.
---
## Source E — `/basic/System.json` (~8 KB)
Phone web UI: **Settings → System** (configuration, not live status). Fetched; **not parsed** today.
Would replace broken `cfgParsed` from `/admin/cfg.xml`.
| Section | Fields | Example (782) |
|---------|--------|---------------|
| **System Configuration** | Enable Web Server, Survivability Test Mode | Web Server: **Yes** |
| **Network Settings** | IP Mode (Dual), IPv4/IPv6 DHCP vs static | DHCP |
| **HTTP Proxy** | Proxy Mode, WPAD, PAC URL, host/port, auth | **Off** |
| **802.1X Authentication** | Certificate Select | Manufacturing installed |
| **Optional Network** | Host Name, Domain (editable) | Empty |
| **VPN Settings** | Server, username, connect on boot | Empty |
**Good for:** Confirming web server enabled, proxy misconfig, 802.1X cert source.
---
## Source F — Legacy XML paths (not useful on WxC MPP)
| Path | What you get | Auth |
|------|--------------|------|
| `/admin/status.xml` | Admin **login HTML** (HTTP 200, not XML) | Admin password |
| `/admin/cfg.xml` | Admin **login HTML** | Admin password |
| `/status.xml` | **403 Forbidden** redirect page | Admin Access required |
`cfgParsed: {}` and `cfg: web=?` in CLI output are expected without admin credentials.
---
## Source G — Explore-only (not in default probe)
| Path | Purpose |
|------|---------|
| `/Debug%20Info.json` | Debug bundle metadata |
| `/basic/init.json` | SPA tab/layout definitions |
| `/admin/advanced` | Legacy admin HTML |
```bash
node scripts/testMppPhone.js --explore --ip 10.x.x.x
```
---
## Probe response shape (today)
| Output key | Content |
|------------|---------|
| `parsed` | Structured data from `/Status.json` |
| `verdict` | Health summary (`healthy`, `warnings`, `info`) |
| `summary` | HTTP probe stats (ok paths, bytes, timing) |
| `statusJson` | Raw JSON string (in raw/API responses) |
| `probes[]` | Per-path status, size, snippet/body (raw mode) |
| `cfgParsed` | Empty unless real `/admin/cfg.xml` XML |
| `downloadStatusJson` | Truncated in summary probe; full in raw mode |
---
## Suggested tiers for product decisions
### Tier 1 — Likely want in `/phonestatus` or tickets
| Signal | Source | Why |
|--------|--------|-----|
| Registration state | Status.json Ext 1 | #1 voice issue |
| Last registration IP | Status.json | Confirms WxC registrar |
| Product + MAC + IP | Status + Webex | Identity |
| Firmware version | Status.json | Upgrade troubleshooting |
| Uptime / elapsed time | Status.json | Recent reboot? |
| Latest reboot reason | Status.json | Provisioning vs power vs cloud |
| Switch + port | **ns.json** (not parsed yet) | Physical layer |
| VLAN | Status.json | Voice VLAN verification |
| Healthy / warnings | `verdict` | Single pass/fail |
### Tier 2 — Useful for deeper triage
| Signal | Source |
|--------|--------|
| Last provisioning resync | Download Status.json |
| Last firmware upgrade URL + result | Download Status.json |
| WxC transition auth history | Download Status.json |
| SIP message/byte counters | Status.json |
| Link speed/duplex | Status.json + ns.json |
| 802.1X status | Status.json |
| Web server / proxy config | System.json |
| LED state (line 1 green) | Status.json |
| MWI / hoteling | Status.json Ext 1 |
| MIC cert refresh failures | Download Status.json |
### Tier 3 — Debug / rarely needed
| Signal | Source |
|--------|--------|
| Full Ethernet error counters | ns.json |
| IPv6 details | Status.json |
| TR-069 / XML streaming / paging pkt counts | Status.json |
| All 4 extension slots | Status.json |
| Raw probe bodies | `probePhone.js raw` |
| Debug Info.json | explore mode |
---
## Gaps — not available without admin password
- SIP proxy, registrar, line DN, display name (in `/admin/cfg.xml` or line config JSON)
- Phone reboot/resync commands (`/admin/reboot`, `/admin/resync` — mutating, intentionally separate)
- Admin-only configuration changes
Webex Control Hub has user/number assignment; phone web UI has line config behind admin login.
---
## Lab snapshot — store 782
| Item | Value |
|------|-------|
| Product | CP-7841-3PCC |
| MAC | CC98914F6799 |
| IP / VLAN | 10.43.206.157 / 4095 |
| Firmware | sip78xx.12-0-7MPP0501-20260317-aa82ce7433.loads |
| Registration | Registered → 150.253.156.211 |
| Uptime | ~22 days |
| Switch | SW00782R Port 45 (10.229.105.251) |
| Last reboot | Provisioning 07/28/2026 15:29 |
| Last resync | cisco.sipflash.com 07/28/2026 15:30 |
| MIC cert | Download failed (not renewed) |
---
## Implementation notes
**Highest-value unparsed additions:**
1. `/Download%20Status.json` — provisioning/firmware history, MIC cert warnings
2. `/ns.json` — switch port / LLDP neighbor
3. `/basic/System.json` — web server / proxy / 802.1X config (replaces cfg.xml path)
**Code references:**
- Probe paths: `integrations/cisco-mpp-phone/probes.js`
- Status.json parser: `integrations/cisco-mpp-phone/statusJson.js`
- Discovery: `services/phoneDiscovery.js`
- Capture API: `services/phoneStatus/capturePhoneProbe.js`
- Fixture: `tests/fixtures/mpp/status-782-live.json`
---
*Last validated: 2026-07-28 against live probe of 10.43.206.157 via relay.*

View file

@ -193,6 +193,50 @@ app.get('/api/dect/raw-xml/:storeNumber', dataAuthGate, async (req, res) => {
} }
}); });
import { capturePhoneProbe, capturePhoneProbeDirect } from './services/phoneStatus/capturePhoneProbe.js';
app.get('/api/phone/probe/:storeNumber', dataAuthGate, async (req, res) => {
const storeNumber = (req.params.storeNumber || '').trim();
const phoneFilter = (req.query.phone || req.query.ip || req.query.mac || '').trim();
try {
const result = await capturePhoneProbe(storeNumber, { phoneFilter: phoneFilter || undefined });
res.json(result);
} catch (err) {
const code = err?.code || 'CAPTURE_FAILED';
const status = code === 'INVALID_STORE' ? 400
: code === 'PHONE_NOT_FOUND' ? 404
: code === 'RELAY_NOT_CONFIGURED' ? 503
: 500;
logger('phone:capture', `probe failed for store ${storeNumber}: ${err.message}`, 'error');
res.status(status).json({
success: false,
code,
message: err.message,
knownPhones: err.knownPhones || undefined,
});
}
});
app.get('/api/phone/probe-direct/:targetIp', dataAuthGate, async (req, res) => {
const targetIp = (req.params.targetIp || '').trim();
try {
const result = await capturePhoneProbeDirect(targetIp);
res.json(result);
} catch (err) {
const code = err?.code || 'CAPTURE_FAILED';
const status = code === 'INVALID_IP' ? 400
: code === 'RELAY_NOT_CONFIGURED' ? 503
: 500;
logger('phone:capture', `probe-direct failed for ${targetIp}: ${err.message}`, 'error');
res.status(status).json({
success: false,
code,
message: err.message,
});
}
});
app.get('/api/av/device/:storeNumber/:identifier', dataAuthGate, async (req, res) => { app.get('/api/av/device/:storeNumber/:identifier', dataAuthGate, async (req, res) => {
const result = await getShapedDeviceData(req.params.storeNumber, req.params.identifier); const result = await getShapedDeviceData(req.params.storeNumber, req.params.identifier);
res.json(result); res.json(result);

View file

@ -0,0 +1,87 @@
// integrations/cisco-mpp-phone/aggregateProbe.js
//
// Combine MPP probe JSON payloads into a single view for rendering.
import { parseStatusJson, summarizePhoneHealthFromJson } from './statusJson.js';
import { parseDownloadStatusJson, downloadStatusWarnings } from './downloadStatusJson.js';
import { parseNsJson } from './nsJson.js';
import { parseSystemJson } from './systemJson.js';
import { parseStatusXml, summarizePhoneHealth } from './statusXml.js';
/**
* @param {object} input
* @param {string} [input.statusJson]
* @param {string} [input.statusXml]
* @param {string} [input.downloadStatusJson]
* @param {string} [input.nsJson]
* @param {string} [input.systemJson]
* @param {object[]} [input.probes]
* @returns {object}
*/
export function buildMppProbeView({
statusJson,
statusXml,
downloadStatusJson,
nsJson,
systemJson,
probes,
} = {}) {
let parsed = null;
let baseVerdict = { healthy: false, warnings: ['no status payload'], info: [] };
if (statusJson) {
parsed = parseStatusJson(statusJson);
baseVerdict = summarizePhoneHealthFromJson(parsed);
} else if (statusXml) {
parsed = parseStatusXml(statusXml);
baseVerdict = summarizePhoneHealth(parsed);
}
const download = downloadStatusJson ? parseDownloadStatusJson(downloadStatusJson) : null;
const networkNeighbor = nsJson ? parseNsJson(nsJson) : null;
const system = systemJson ? parseSystemJson(systemJson) : null;
const extraWarnings = [
...downloadStatusWarnings(download),
];
const warnings = [...(baseVerdict.warnings || [])];
for (const w of extraWarnings) {
if (!warnings.includes(w)) warnings.push(w);
}
const info = [...(baseVerdict.info || [])];
if (networkNeighbor?.switchDevice) {
info.push(`switch: ${networkNeighbor.switchDevice} ${networkNeighbor.switchPort || ''}`.trim());
}
if (download?.latestProvisioning?.url) {
const host = shortenUrl(download.latestProvisioning.url);
info.push(`last resync: ${host}`);
}
const verdict = {
healthy: warnings.length === 0,
warnings,
info,
};
return {
parsed,
download,
networkNeighbor,
system,
verdict,
probes: Array.isArray(probes) ? probes : [],
};
}
function shortenUrl(url) {
if (!url || typeof url !== 'string') return '?';
try {
const normalized = url.startsWith('http') ? url : `https://${url}`;
const u = new URL(normalized);
return u.hostname;
} catch {
return url.length > 40 ? `${url.slice(0, 40)}` : url;
}
}

View file

@ -0,0 +1,123 @@
// integrations/cisco-mpp-phone/client.js
//
// Axios wrapper for Cisco MPP desk phones (CP-78xx, Webex Calling).
// HTTPS on 443 with a self-signed cert; Basic auth is typical, with
// Digest retry on 401 (same interceptor pattern as DECT).
import axios from 'axios';
import https from 'node:https';
import {
parseDigestChallenge,
buildDigestAuthHeader,
} from '../../utils/httpDigestAuth.js';
/**
* @param {object} opts
* @param {string} opts.host
* @param {string} [opts.user] default `admin` when password is set
* @param {string} [opts.password] omit for unauthenticated probes (Webex MPP JSON)
* @param {number} [opts.timeoutMs]
* @returns {import('axios').AxiosInstance}
*/
export function createMppPhoneClient({ host, user, password, timeoutMs = 15_000 }) {
if (!host) throw new Error('createMppPhoneClient: host is required');
const useAuth = !!(password && String(password).length > 0);
const authUser = user || 'admin';
const client = axios.create({
baseURL: `https://${host}`,
timeout: timeoutMs,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
validateStatus: () => true,
responseType: 'text',
transformResponse: [(data) => data],
...(useAuth ? { auth: { username: authUser, password } } : {}),
headers: { 'User-Agent': 'collabSupport-mpp-phone/0.1' },
});
if (useAuth) {
client.defaults.__mppAuth = { user: authUser, password };
}
client.interceptors.response.use(async (response) => {
if (response.status !== 401) return response;
if (!client.defaults.__mppAuth) return response;
const originalConfig = response.config;
if (originalConfig.__digestRetried) return response;
const wwwAuth = response.headers?.['www-authenticate'];
const challenge = parseDigestChallenge(wwwAuth);
if (!challenge) return response;
const { user: username, password: pass } = client.defaults.__mppAuth;
const method = (originalConfig.method || 'get').toUpperCase();
const uri = originalConfig.url || '/';
const authHeader = buildDigestAuthHeader({
username, password: pass, method, uri, challenge,
});
return client.request({
...originalConfig,
auth: undefined,
headers: { ...(originalConfig.headers || {}), Authorization: authHeader },
__digestRetried: true,
});
});
return client;
}
/**
* @typedef {object} ProbeResult
* @property {string} path
* @property {string} method
* @property {number|null} status
* @property {string|null} contentType
* @property {number} sizeBytes
* @property {string|null} snippet
* @property {string|null} error
* @property {number} elapsedMs
*/
export async function tryRequest(client, { method = 'GET', path, data, headers } = {}) {
const start = Date.now();
try {
const res = await client.request({ method, url: path, data, headers });
return normalize({
path, method,
status: res.status,
contentType: res.headers?.['content-type'] || null,
body: res.data,
elapsedMs: Date.now() - start,
});
} catch (err) {
return {
path,
method,
status: null,
contentType: null,
sizeBytes: 0,
snippet: null,
error: err.code ? `${err.code}: ${err.message}` : err.message,
elapsedMs: Date.now() - start,
};
}
}
function normalize({ path, method, status, contentType, body, elapsedMs, error = null }) {
const bodyStr = typeof body === 'string' ? body : (body == null ? '' : String(body));
const flat = bodyStr.replace(/\s+/g, ' ').trim();
return {
path,
method,
status,
contentType,
sizeBytes: Buffer.byteLength(bodyStr, 'utf8'),
snippet: flat ? flat.slice(0, 200) : null,
error,
elapsedMs,
};
}

View file

@ -0,0 +1,90 @@
// integrations/cisco-mpp-phone/downloadStatusJson.js
//
// Parser for /Download%20Status.json (provisioning + firmware history).
import { coerceRows, fieldsFromRows, extractIndexed } from './jsonRows.js';
/**
* @param {string|Array} raw
* @returns {object}
*/
export function parseDownloadStatusJson(raw) {
const rows = coerceRows(raw);
const fields = fieldsFromRows(rows);
const firmwareUpgrades = extractIndexed(fields, /^Firmware Upgrade Status \d+$/i);
const provisioning = extractIndexed(fields, /^Provisioning Status \d+$/i);
const transitionAuth = extractIndexed(fields, /^Transition Authorization Status \d+$/i);
const micCertStatus = fields['MIC Cert Provisioning Status'] || null;
const micCertInfo = fields['MIC Cert Info'] || null;
return {
fields,
latestFirmwareUpgrade: parseHistoryEntry(firmwareUpgrades[0]),
latestProvisioning: parseHistoryEntry(provisioning[0]),
latestTransitionAuth: parseHistoryEntry(transitionAuth[0]),
firmwareUpgrades: firmwareUpgrades.map(parseHistoryEntry).filter(Boolean),
provisioningHistory: provisioning.map(parseHistoryEntry).filter(Boolean),
micCert: {
provisioningStatus: micCertStatus,
info: micCertInfo,
failed: !!(micCertStatus && /fail/i.test(micCertStatus)),
},
};
}
/**
* Parse "[timestamp][url]Result." or "[seq][timestamp][url]Result." entries.
* @param {string|null} raw
*/
export function parseHistoryEntry(raw) {
if (!raw || typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const triple = trimmed.match(/^\[([^\]]*)\]\[([^\]]*)\]\[([^\]]*)\](.+)$/);
if (triple && looksLikeUrl(triple[3])) {
return buildEntry(trimmed, triple[2], triple[3], triple[4]);
}
const double = trimmed.match(/^\[([^\]]*)\]\[([^\]]*)\](.+)$/);
if (double) {
if (looksLikeUrl(double[2])) {
return buildEntry(trimmed, double[1], double[2], double[3]);
}
return buildEntry(trimmed, null, double[1], double[2]);
}
return buildEntry(trimmed, null, null, trimmed);
}
function looksLikeUrl(s) {
return typeof s === 'string' && (/^https?:\/\//i.test(s) || /\.[a-z]{2,}/i.test(s));
}
function buildEntry(raw, timestamp, url, resultPart) {
const result = (resultPart || '').trim();
return {
raw,
timestamp: timestamp || null,
url: url || null,
result: result || null,
succeeded: /succeed/i.test(result),
failed: /fail/i.test(result),
};
}
export function downloadStatusWarnings(download) {
const warnings = [];
if (!download) return warnings;
if (download.micCert?.failed) {
warnings.push(`MIC cert: ${download.micCert.provisioningStatus}`);
}
if (download.latestProvisioning?.failed) {
warnings.push(`provisioning: ${download.latestProvisioning.result}`);
}
if (download.latestFirmwareUpgrade?.failed) {
warnings.push(`firmware upgrade: ${download.latestFirmwareUpgrade.result}`);
}
return warnings;
}

View file

@ -0,0 +1,35 @@
// integrations/cisco-mpp-phone/jsonRows.js
//
// Shared helpers for MPP SPA JSON endpoints (array-of-rows format).
export function coerceRows(raw) {
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
/** @param {Array} rows */
export function fieldsFromRows(rows) {
const fields = {};
for (const row of rows) {
const name = typeof row?.name === 'string' ? row.name.trim() : '';
if (!name) continue;
const value = row?.value == null ? '' : String(row.value).trim();
if (value) fields[name] = value;
}
return fields;
}
export function extractIndexed(fields, re) {
return Object.entries(fields)
.filter(([k]) => re.test(k))
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
.map(([, v]) => v);
}

View file

@ -0,0 +1,54 @@
// integrations/cisco-mpp-phone/nsJson.js
//
// Parser for /ns.json (network statistics + LLDP/CDP neighbors).
import { coerceRows, fieldsFromRows } from './jsonRows.js';
const ERROR_COUNTER_FIELDS = [
'RxDropPkts', 'RxUndersizePkts', 'RxOversizePkts', 'RxFragments', 'RxJabbers',
'RxAlignErrs', 'RxFCSErrs', 'RxSymbolErrs', 'TxDropPkts', 'TxCollisions',
'TxLateCollisions',
];
/**
* @param {string|Array} raw
* @returns {object}
*/
export function parseNsJson(raw) {
const rows = coerceRows(raw);
const fields = fieldsFromRows(rows);
const neighbor = {
lldpDevice: fields['LLDPNeighborDeviceId'] || null,
lldpPort: fields['LLDPNeighborPort'] || null,
lldpIp: fields['LLDPNeighborIP'] || null,
cdpDevice: fields['CDPNeighborDeviceId'] || null,
cdpPort: fields['CDPNeighborPort'] || null,
cdpIp: fields['CDPNeighborIP'] || null,
};
const switchDevice = neighbor.lldpDevice || neighbor.cdpDevice || null;
const switchPort = neighbor.lldpPort || neighbor.cdpPort || null;
const switchIp = neighbor.lldpIp || neighbor.cdpIp || null;
const portSpeed = (fields['PortSpeed'] || '').trim() || null;
const errorCounters = {};
for (const key of ERROR_COUNTER_FIELDS) {
const val = fields[key];
if (val != null && val !== '' && val !== '0') {
errorCounters[key] = val;
}
}
return {
fields,
neighbor,
switchDevice,
switchPort,
switchIp,
portSpeed,
errorCounters,
txFrames: fields['Tx Frames'] || fields['TxtotalGoodPkt'] || null,
rxFrames: fields['Rx Frames'] || fields['RxtotalPkt'] || null,
};
}

View file

@ -0,0 +1,183 @@
// integrations/cisco-mpp-phone/probes.js
//
// Read-only probes against Cisco MPP desk phones (Webex Calling).
// Current-release Webex MPP firmware uses JSON endpoints on the SPA
// web UI (not legacy /admin/status.xml).
import { tryRequest } from './client.js';
import { parseCfgXml } from './statusXml.js';
export const READ_PROBE_PATHS = [
{ path: '/', purpose: 'user web landing (SPA shell)' },
{ path: '/Status.json', purpose: 'primary status JSON (Info > Status tab)' },
{ path: '/Download%20Status.json', purpose: 'provisioning + firmware upgrade history' },
{ path: '/ns.json', purpose: 'network statistics JSON' },
{ path: '/basic/System.json', purpose: 'system configuration JSON (admin fields)' },
// Legacy fallbacks (older MPP loads / CUCM-style)
{ path: '/admin/status.xml', purpose: 'legacy admin status XML' },
{ path: '/status.xml', purpose: 'legacy user status XML' },
{ path: '/admin/cfg.xml', purpose: 'legacy admin config XML' },
];
export const EXPLORE_PROBE_PATHS = [
{ path: '/Debug%20Info.json', purpose: 'debug info JSON' },
{ path: '/basic/init.json', purpose: 'UI layout metadata' },
{ path: '/admin/advanced', purpose: 'legacy admin HTML landing' },
];
export const MUTATING_ACTION_PATHS = Object.freeze({
REBOOT: '/admin/reboot',
RESYNC: '/admin/resync',
UPGRADE: '/admin/upgrade',
});
export async function runProbeList(client, pathList) {
const results = [];
for (const { path, purpose } of pathList) {
const r = await tryRequest(client, { method: 'GET', path });
results.push({ ...r, purpose });
}
return results;
}
export async function runReadProbes(client) {
return runProbeList(client, READ_PROBE_PATHS);
}
export async function runExploreProbes(client) {
return runProbeList(client, [...READ_PROBE_PATHS, ...EXPLORE_PROBE_PATHS]);
}
export async function runPhoneProbe(client, { includeBodies = false } = {}) {
const started = Date.now();
const probes = await runReadProbes(client);
const statusJson = await fetchBodyIfOk(probes, client, '/Status.json');
const statusXml = !statusJson
? (await fetchBodyIfOk(probes, client, '/admin/status.xml')
|| await fetchBodyIfOk(probes, client, '/status.xml'))
: null;
const downloadStatusJson = await fetchBodyIfOk(probes, client, '/Download%20Status.json');
const nsJson = await fetchBodyIfOk(probes, client, '/ns.json');
const systemJson = await fetchBodyIfOk(probes, client, '/basic/System.json');
const cfgXml = await fetchBodyIfOk(probes, client, '/admin/cfg.xml');
const cfgParsed = cfgXml ? parseCfgXml(cfgXml) : null;
const summary = summarizeProbes(probes, { statusJson, statusXml });
return {
probes: includeBodies ? await attachBodies(client, probes) : probes,
summary,
statusJson,
downloadStatusJson: downloadStatusJson || null,
nsJson: nsJson || null,
systemJson: systemJson || null,
statusXml,
cfgXml: includeBodies ? cfgXml : (cfgXml ? truncate(cfgXml, 4000) : null),
cfgParsed: cfgParsed?.highlights || null,
byteLength: statusJson
? Buffer.byteLength(statusJson, 'utf8')
: (statusXml ? Buffer.byteLength(statusXml, 'utf8') : null),
elapsedMs: Date.now() - started,
};
}
export async function fetchStatusJson(client) {
const r = await tryRequest(client, { method: 'GET', path: '/Status.json' });
if (r.status === 200 && r.sizeBytes > 0) {
const body = await fetchBody(client, '/Status.json');
return {
rawJson: body,
rawXml: null,
byteLength: Buffer.byteLength(body || '', 'utf8'),
probe: r,
path: '/Status.json',
};
}
const xml = await fetchStatusXml(client);
return {
rawJson: null,
rawXml: xml.rawXml,
byteLength: xml.byteLength,
probe: xml.probe,
path: xml.path,
};
}
export async function fetchStatusXml(client) {
for (const path of ['/admin/status.xml', '/status.xml']) {
const r = await tryRequest(client, { method: 'GET', path });
if (r.status === 200 && r.sizeBytes > 0) {
const body = await fetchBody(client, path);
return { rawXml: body, byteLength: Buffer.byteLength(body || '', 'utf8'), probe: r, path };
}
}
const err = new Error('phone returned no Status.json or status.xml');
err.code = 'PHONE_BAD_STATUS';
throw err;
}
async function fetchBodyIfOk(probes, client, path) {
const p = probes.find((row) => row.path === path);
if (p?.status === 200 && p.sizeBytes > 0) {
return fetchBody(client, path);
}
return null;
}
async function fetchBody(client, path) {
try {
const res = await client.get(path);
return typeof res.data === 'string' ? res.data : JSON.stringify(res.data ?? '');
} catch {
return null;
}
}
async function attachBodies(client, probes) {
const out = [];
for (const p of probes) {
if (p.status === 200 && p.sizeBytes > 0) {
const body = await fetchBody(client, p.path);
out.push({ ...p, body: body ?? null });
} else {
out.push({ ...p, body: null });
}
}
return out;
}
function truncate(str, max) {
if (!str || str.length <= max) return str;
return `${str.slice(0, max)}\n<!-- truncated ${str.length - max} bytes -->`;
}
export function summarizeProbes(probes, { statusJson = null, statusXml = null } = {}) {
const okCount = probes.filter((p) => p.status === 200).length;
const authOk = probes.some((p) => p.status === 200);
const statusJsonFound = probes.some(
(p) => p.path === '/Status.json' && p.status === 200 && p.sizeBytes > 0,
) || !!statusJson;
const statusXmlFound = probes.some(
(p) => (p.path === '/admin/status.xml' || p.path === '/status.xml')
&& p.status === 200 && p.sizeBytes > 0,
) || !!statusXml;
return {
okCount,
total: probes.length,
authOk,
statusJsonFound,
statusXmlFound,
statusJsonBytes: statusJson ? Buffer.byteLength(statusJson, 'utf8') : 0,
statusXmlBytes: statusXml ? Buffer.byteLength(statusXml, 'utf8') : 0,
forbidden: probes.filter((p) => p.status === 403).map((p) => p.path),
unauthorized: probes.filter((p) => p.status === 401).map((p) => p.path),
notFound: probes.filter((p) => p.status === 404).map((p) => p.path),
okPaths: probes.filter((p) => p.status === 200).map((p) => p.path),
};
}
export async function getPath(client, path) {
return tryRequest(client, { method: 'GET', path });
}

View file

@ -0,0 +1,184 @@
// integrations/cisco-mpp-phone/statusJson.js
//
// Parser for MPP Web UI JSON endpoints (Status.json).
import { coerceRows, extractIndexed } from './jsonRows.js';
/**
* @param {string|object|Array} raw
* @returns {object}
*/
export function parseStatusJson(raw) {
const rows = coerceRows(raw);
const fields = {};
const lines = [];
let currentExt = null;
for (const row of rows) {
const name = typeof row?.name === 'string' ? row.name.trim() : '';
if (!name) continue;
const value = row?.value == null ? '' : String(row.value).trim();
const extMatch = name.match(/^Ext (\d+) Status$/i);
if (extMatch) {
currentExt = Number(extMatch[1]);
lines.push({ index: currentExt });
continue;
}
if (currentExt && isLineField(name)) {
const line = lines.find((l) => l.index === currentExt);
if (line) assignLineField(line, name, value);
fields[`Ext ${currentExt} ${name}`] = value;
continue;
}
if (value) fields[name] = value;
}
const device = {
product: fields['Product Name'] || null,
mac: normalizeMac(fields['MAC Address']),
firmware: fields['Software Version'] || fields['Firmware Version'] || null,
serial: fields['Serial Number'] || null,
hostname: fields['Host Name'] || null,
};
const network = {
ipv4: fields['Current IP'] || null,
netmask: fields['Current Netmask'] || null,
gateway: fields['Current Gateway'] || null,
dns1: fields['Primary DNS'] || null,
dns2: fields['Secondary DNS'] || fields['Secondary DNS'] || null,
vlan: fields['Operational VLAN ID'] || fields['VLAN ID'] || null,
connectionType: fields['Connection Type'] || null,
ipStatus: fields['IP Status'] || null,
};
const rebootHistory = extractIndexed(fields, /^Reboot Reason \d+$/i);
const normalizedLines = lines
.filter((l) => l.registrationState || l.lastRegistrationAt || l.lastRegistrationIp)
.map((l) => ({
index: l.index,
registrationState: l.registrationState || null,
lastRegistrationAt: l.lastRegistrationAt || null,
lastRegistrationIp: l.lastRegistrationIp || null,
nextRegistrationInSeconds: l.nextRegistrationInSeconds || null,
messageWaiting: l.messageWaiting || null,
hotelingState: l.hotelingState || null,
}));
const primary = normalizedLines.find((l) => l.index === 1) || normalizedLines[0] || null;
const phoneStatus = {
elapsedTime: fields['Elapsed Time'] || null,
currentTime: fields['Current Time'] || null,
linkSpeed: fields['SW Port'] || null,
linkConfig: fields['SW Port Config'] || null,
upgradeStatus: fields['Upgrade Status'] || null,
externalIp: fields['External IP'] || null,
dot1xStatus: fields['Transaction status'] || null,
dot1xProtocol: fields['Protocol'] || null,
ledLine1Color: fields['Line 1 LED Color'] || null,
ledLine1Cadence: fields['Line 1 LED Cadence'] || null,
messageWaiting: fields['Ext 1 Message Waiting'] || fields['Message Waiting'] || null,
hotelingState: fields['Ext 1 Hoteling State'] || fields['Hoteling State'] || null,
sipBytesSent: fields['SIP Bytes Sent'] || null,
sipBytesRecv: fields['SIP Bytes Recv'] || null,
vpnConnected: fields['VPN Connected'] || null,
ipv6Status: fields['IP Status_IPV6'] || null,
ipv6Address: fields['Current IP_IPV6'] || null,
tr069Feature: fields['TR-069 Feature'] || null,
multicastRx: fields['Multicast Rx Pkts'] || null,
multicastTx: fields['Multicast Tx Pkts'] || null,
streamingRx: fields['Streaming Rx Pkts'] || null,
};
return {
fields,
device,
network,
lines: normalizedLines,
rebootHistory,
phoneStatus,
provisioning: {
latest: fields['Provisioning Status 1'] || null,
firmwareUpgrade: fields['Firmware Upgrade Status 1'] || null,
},
sip: {
messagesSent: fields['SIP Messages Sent'] || null,
messagesRecv: fields['SIP Messages Recv'] || null,
},
macAddress: device.mac,
firmware: device.firmware,
ipAddress: network.ipv4,
registration: primary?.registrationState || null,
};
}
export function summarizePhoneHealthFromJson(parsed) {
const warnings = [];
const info = [];
if (!parsed?.fields || Object.keys(parsed.fields).length === 0) {
return { healthy: false, warnings: ['Status.json parsed empty or missing'], info };
}
info.push(`${Object.keys(parsed.fields).length} status field(s) from Status.json`);
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
if (line1?.registrationState) {
const reg = line1.registrationState.toLowerCase();
if (reg.includes('not registered') || reg.includes('fail')) {
warnings.push(`Ext ${line1.index} registration: ${line1.registrationState}`);
} else {
info.push(`Ext ${line1.index} registration: ${line1.registrationState}`);
if (line1.lastRegistrationAt) info.push(`last reg: ${line1.lastRegistrationAt}`);
if (line1.lastRegistrationIp) info.push(`registrar IP: ${line1.lastRegistrationIp}`);
}
} else if (parsed.registration) {
info.push(`registration: ${parsed.registration}`);
} else {
warnings.push('no extension registration state in Status.json');
}
if (parsed.device?.firmware) info.push(`firmware: ${parsed.device.firmware}`);
if (parsed.device?.product) info.push(`product: ${parsed.device.product}`);
if (parsed.network?.ipv4) info.push(`ip: ${parsed.network.ipv4}`);
if (parsed.network?.vlan) info.push(`vlan: ${parsed.network.vlan}`);
if (parsed.rebootHistory?.length) info.push(`latest reboot: ${parsed.rebootHistory[0]}`);
return { healthy: warnings.length === 0, warnings, info };
}
function isLineField(name) {
return [
'Registration State',
'Last Registration At',
'Last Registration IP',
'Next Registration In Seconds',
'Mapped SIP Port',
'Extended Function Status',
'Message Waiting',
'Hoteling State',
].includes(name);
}
function assignLineField(line, name, value) {
switch (name) {
case 'Registration State': line.registrationState = value; break;
case 'Last Registration At': line.lastRegistrationAt = value; break;
case 'Last Registration IP': line.lastRegistrationIp = value; break;
case 'Next Registration In Seconds': line.nextRegistrationInSeconds = value; break;
case 'Message Waiting': line.messageWaiting = value; break;
case 'Hoteling State': line.hotelingState = value; break;
default: break;
}
}
function normalizeMac(raw) {
if (!raw || typeof raw !== 'string') return null;
const hex = raw.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
if (hex.length !== 12) return null;
return hex.match(/../g).join(':');
}

View file

@ -0,0 +1,265 @@
// integrations/cisco-mpp-phone/statusXml.js
//
// Parser for MPP /admin/status.xml and /admin/cfg.xml snapshots.
// Uses the same nested-xml reader as DECT (phones use a similar dump shape).
import { xmlToObject } from '../cisco-dect/statusXml.js';
const MAC_RE = /^[0-9a-f]{12}$/i;
/**
* @param {string} rawXml
* @returns {object}
*/
export function parseStatusXml(rawXml) {
if (!rawXml || typeof rawXml !== 'string') {
return { fields: {}, device: {}, lines: [], rebootHistory: [], network: {}, provisioning: {} };
}
let tree;
try {
tree = xmlToObject(rawXml);
} catch {
return { fields: {}, device: {}, lines: [], rebootHistory: [], network: {}, provisioning: {}, parseError: 'xml parse failed' };
}
const root = pickRoot(tree);
const fields = flattenLeaves(root);
return {
fields,
device: extractDevice(fields, root),
lines: extractLines(fields, root),
rebootHistory: extractRebootHistory(fields, root),
network: extractNetwork(fields),
provisioning: extractProvisioning(fields),
sip: extractSip(fields),
// Back-compat aliases used by summarizePhoneHealth + collector
macAddress: extractDevice(fields, root).mac || null,
firmware: extractDevice(fields, root).firmware || null,
ipAddress: extractDevice(fields, root).ip || extractNetwork(fields).ipv4 || null,
registration: extractPrimaryRegistration(fields, root),
};
}
/**
* Parse cfg.xml passwords are redacted by the phone; useful for SIP/provision context.
* @param {string} rawXml
* @returns {object}
*/
export function parseCfgXml(rawXml) {
if (!rawXml || typeof rawXml !== 'string') {
return { fields: {}, highlights: {} };
}
let tree;
try {
tree = xmlToObject(rawXml);
} catch {
return { fields: {}, highlights: {}, parseError: 'xml parse failed' };
}
const root = pickRoot(tree);
const fields = flattenLeaves(root);
return {
fields,
highlights: {
webServer: pickField(fields, ['Enable_Web_Server', 'System.Enable_Web_Server']),
webAdminAccess: pickField(fields, ['Enable_Web_Admin_Access', 'System.Enable_Web_Admin_Access']),
sipProxy: pickField(fields, ['Proxy', 'SIP_Proxy', 'Line_1.Proxy', 'Line_1.SIP_Proxy']),
registrar: pickField(fields, ['Registrar', 'Line_1.Registrar']),
userId: pickField(fields, ['User_ID', 'Line_1.User_ID', 'Line_1.Auth_User_ID']),
displayName: pickField(fields, ['Display_Name', 'Line_1.Display_Name']),
upgradeRule: pickField(fields, ['Upgrade_Rule', 'Upgrade_Enable']),
profileRule: pickField(fields, ['Profile_Rule']),
},
};
}
export function summarizePhoneHealth(parsed) {
const warnings = [];
const info = [];
if (!parsed?.fields || Object.keys(parsed.fields).length === 0) {
return { healthy: false, warnings: ['status.xml parsed empty or missing'], info };
}
const fieldCount = Object.keys(parsed.fields).length;
info.push(`${fieldCount} status field(s) extracted`);
const reg = parsed.registration || parsed.lines?.[0]?.state || parsed.lines?.[0]?.registration;
if (reg) {
const regStr = String(reg).toLowerCase();
if (regStr.includes('unreg') || regStr.includes('fail') || regStr === '0' || regStr.includes('not registered')) {
warnings.push(`registration: ${reg}`);
} else {
info.push(`registration: ${reg}`);
}
} else {
warnings.push('no line registration state found in status.xml');
}
if (parsed.device?.firmware) info.push(`firmware: ${parsed.device.firmware}`);
if (parsed.device?.mac) info.push(`mac: ${parsed.device.mac}`);
if (parsed.network?.ipv4 || parsed.ipAddress) {
info.push(`ip: ${parsed.network?.ipv4 || parsed.ipAddress}`);
}
if (parsed.network?.vlan) info.push(`vlan: ${parsed.network.vlan}`);
const reboots = parsed.rebootHistory || [];
if (reboots.length > 0) {
info.push(`reboot history: ${reboots.length} entr${reboots.length === 1 ? 'y' : 'ies'} (latest: ${reboots[0]})`);
}
return { healthy: warnings.length === 0, warnings, info };
}
function pickRoot(tree) {
if (!tree || typeof tree !== 'object') return {};
return tree.Status
|| tree.status
|| tree['flat-profile']
|| tree['flat_profile']
|| tree.device
|| tree.Device
|| tree;
}
export function flattenLeaves(node, prefix = '', out = {}) {
if (node == null) return out;
if (typeof node === 'string') {
const val = node.trim();
if (val && prefix) out[prefix] = val;
return out;
}
if (typeof node !== 'object') return out;
for (const [k, v] of Object.entries(node)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === 'string') {
const val = v.trim();
if (val) out[key] = val;
} else if (v && typeof v === 'object') {
flattenLeaves(v, key, out);
}
}
return out;
}
function pickField(fields, candidates) {
for (const c of candidates) {
if (fields[c]) return fields[c];
}
const lower = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k.toLowerCase(), v]));
for (const c of candidates) {
const hit = lower[c.toLowerCase()];
if (hit) return hit;
}
return null;
}
function normalizeMac(raw) {
if (!raw || typeof raw !== 'string') return null;
const hex = raw.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
if (hex.length !== 12) return null;
return hex.match(/../g).join(':');
}
function extractDevice(fields, root) {
const mac = normalizeMac(pickField(fields, [
'MAC_Address', 'MACAddress', 'Mac_Address', 'Network.MAC_Address', 'Product_information.MAC_Address',
]));
return {
mac,
ip: pickField(fields, ['IP_Address', 'IPAddress', 'Network.IP_Address', 'IPv4_Address', 'Network.IPv4_Address']),
firmware: pickField(fields, [
'Firmware_Version', 'Software_Version', 'SoftwareVersion', 'Product_information.Software_Version',
]),
serial: pickField(fields, ['Serial_Number', 'SerialNumber', 'Product_information.Serial_Number']),
product: pickField(fields, ['Product_Name', 'Phone_Type', 'Product_information.Product_Name']),
uptime: pickField(fields, ['Operating_Time', 'Elapsed_Time', 'Up_Time']),
};
}
function extractNetwork(fields) {
return {
ipv4: pickField(fields, ['IP_Address', 'IPAddress', 'Network.IP_Address', 'IPv4_Address', 'Network.IPv4_Address', 'Current_IP']),
ipv6: pickField(fields, ['IPv6_Address', 'Network.IPv6_Address']),
vlan: pickField(fields, ['VLAN_ID', 'Vlan_ID', 'Network.VLAN_ID']),
hostname: pickField(fields, ['Host_Name', 'HostName', 'Network.Host_Name']),
switchPort: pickField(fields, ['Switch_Port_Config', 'Switch_Port_Link', 'Network.Switch_Port_Config']),
dns: pickField(fields, ['DNS_1', 'DNS1', 'Network.DNS_1']),
gateway: pickField(fields, ['Default_Router', 'Default_Gateway', 'Network.Default_Router']),
};
}
function extractProvisioning(fields) {
return {
state: pickField(fields, [
'Customization', 'Customization_State', 'Provisioning_Status', 'Provision_Status',
]),
profile: pickField(fields, ['Profile_Rule', 'Provisioning_Profile']),
lastResync: pickField(fields, ['Last_Resync', 'Last_Resync_Time']),
};
}
function extractSip(fields) {
return {
proxy: pickField(fields, ['Proxy', 'SIP_Proxy', 'Line_1.Proxy']),
registrar: pickField(fields, ['Registrar', 'Line_1.Registrar']),
outboundProxy: pickField(fields, ['Outbound_Proxy', 'Line_1.Outbound_Proxy']),
};
}
function extractRebootHistory(fields, root) {
const out = [];
const history = root?.Reboot_History || root?.RebootHistory;
if (history && typeof history === 'object') {
for (let i = 1; i <= 5; i += 1) {
const v = history[`Reboot_Reason_${i}`] || history[`RebootReason${i}`];
if (v && String(v).trim()) out.push(String(v).trim());
}
}
for (let i = 1; i <= 5; i += 1) {
const v = fields[`Reboot_Reason_${i}`] || fields[`Reboot_History.Reboot_Reason_${i}`];
if (v && !out.includes(v)) out.push(v);
}
return out;
}
function extractLines(fields, root) {
const lines = [];
for (let i = 1; i <= 8; i += 1) {
const prefix = `Line_${i}`;
const state = pickField(fields, [
`${prefix}.Registration`, `${prefix}.Line_State`, `${prefix}.State`,
`Registration_${i}`, `Line_State_${i}`,
]);
const user = pickField(fields, [`${prefix}.User_ID`, `${prefix}.Auth_User_ID`, `${prefix}.Display_Name`]);
const active = pickField(fields, [`${prefix}.Active`, `${prefix}.Call_State`]);
if (state || user || active) {
lines.push({
index: i,
userId: user,
state: state || active,
registration: state,
});
}
}
// Fallback: scan for any registration-ish keys
if (lines.length === 0) {
for (const [k, v] of Object.entries(fields)) {
if (/line.*regist|registration/i.test(k) && v) {
lines.push({ index: lines.length + 1, state: v, registration: v, key: k });
}
}
}
return lines;
}
function extractPrimaryRegistration(fields, root) {
const line = extractLines(fields, root)[0];
if (line?.registration || line?.state) return line.registration || line.state;
return pickField(fields, [
'Registration', 'Line_State', 'SIP_Registration', 'Phone_State',
]);
}

View file

@ -0,0 +1,24 @@
// integrations/cisco-mpp-phone/systemJson.js
//
// Parser for /basic/System.json (web/proxy/network config).
import { coerceRows, fieldsFromRows } from './jsonRows.js';
/**
* @param {string|Array} raw
* @returns {object}
*/
export function parseSystemJson(raw) {
const rows = coerceRows(raw);
const fields = fieldsFromRows(rows);
return {
fields,
webServer: fields['Enable Web Server'] || null,
proxyMode: fields['Proxy Mode'] || null,
ipMode: fields['IP Mode'] || null,
connectionType: fields['Connection Type'] || fields['Connection Type_IPV6'] || null,
dot1xCertSelect: fields['Certificate Select'] || null,
autoConfig: fields['Auto Config'] || null,
};
}

View file

@ -0,0 +1,75 @@
// scripts/lib/botPhoneApi.js
//
// Route phone probes through the running bot's HTTP API when the CLI
// process has no attached relay WebSocket (normal for local scripts).
export function botApiBase() {
return (process.env.PROBE_PHONE_API_BASE
|| process.env.BOT_API_BASE
|| `http://localhost:${process.env.SERVER_PORT || 1800}`).replace(/\/$/, '');
}
export function localRelayConnected(getDectRelayHub) {
try {
return getDectRelayHub().isConnected();
} catch {
return false;
}
}
export async function botApiFetch(path) {
const token = process.env.HTTP_API_TOKEN;
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const url = `${botApiBase()}${path}`;
let res;
try {
res = await fetch(url, { headers });
} catch (err) {
const hint = 'Start the bot (node index.js) so the relay WebSocket is attached, or set PROBE_PHONE_API_BASE to a running instance.';
const e = new Error(`Cannot reach bot API at ${url}${err.message}. ${hint}`);
e.code = 'BOT_API_UNREACHABLE';
e.cause = err;
throw e;
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(body?.message || `bot API ${res.status} ${path}`);
err.code = body?.code || 'BOT_API_ERROR';
err.body = body;
throw err;
}
return body;
}
export function mapApiPhoneToProbeResult(target, phoneRow) {
return {
phone: target,
ok: !!phoneRow?.ok,
probes: phoneRow?.probes || null,
statusJson: phoneRow?.statusJson || null,
statusXml: phoneRow?.statusXml || null,
parsed: phoneRow?.parsed || null,
verdict: phoneRow?.verdict || null,
summary: phoneRow?.summary || null,
cfgParsed: phoneRow?.cfgParsed || null,
byteLength: phoneRow?.byteLength || null,
elapsedMs: phoneRow?.elapsedMs || null,
error: phoneRow?.error || null,
};
}
export async function probeTargetsViaBotApi(targets) {
const results = [];
let relay = { connected: false };
for (const t of targets) {
const data = await botApiFetch(`/api/phone/probe-direct/${encodeURIComponent(t.ip)}`);
relay = data.relay || relay;
results.push(mapApiPhoneToProbeResult(t, data.phones?.[0]));
}
return { results, relay };
}
export async function captureStoreViaBotApi(store, { ip } = {}) {
const qs = ip ? `?ip=${encodeURIComponent(ip)}` : '';
return botApiFetch(`/api/phone/probe/${store}${qs}`);
}

View file

@ -380,6 +380,16 @@ export class DectRelayHub {
return this.rpc({ type: 'collect-raw', baseIp }, opts); return this.rpc({ type: 'collect-raw', baseIp }, opts);
} }
/** Read-only probe of an MPP desk phone (summary probes, no raw bodies). */
phoneProbe(targetIp, opts) {
return this.rpc({ type: 'phone-probe', targetIp }, opts);
}
/** Full probe including response bodies for fixture capture. */
phoneProbeRaw(targetIp, opts) {
return this.rpc({ type: 'phone-probe-raw', targetIp }, opts);
}
/** /**
* Convenience: execute one of the mutating actions the agent * Convenience: execute one of the mutating actions the agent
* exposes (reboot / force-reboot / reboot-chain / force-reboot-chain * exposes (reboot / force-reboot / reboot-chain / force-reboot-chain

View file

@ -0,0 +1,180 @@
// services/phoneCollectorService.js
//
// Fan-out MPP phone probes over the DECT relay hub.
import { getDectRelayHub, RelayErrorCodes } from './dectRelayHub.js';
import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js';
import { logger } from '../utils/logger.js';
const LOG_SCOPE = 'phone:collector';
const DEFAULT_TIMEOUT_MS = Number(process.env.PHONE_COLLECT_TIMEOUT_MS) || 15_000;
/**
* @param {object[]} phones from discoverDeskPhones()
* @param {object} [opts]
* @returns {Promise<object[]>}
*/
export async function probeAll(phones, opts = {}) {
const list = Array.isArray(phones) ? phones : [];
if (list.length === 0) return [];
const hub = opts.hub || getDectRelayHub();
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
logger(LOG_SCOPE, `Fanning out phone-probe() to ${list.length} phone(s)`, 'debug');
const results = await Promise.all(list.map((phone) => probeOne(hub, phone, timeoutMs)));
const okCount = results.filter((r) => r.ok).length;
logger(LOG_SCOPE, `Phone probe finished: ${okCount}/${list.length} succeeded`, 'debug');
return results;
}
/**
* @param {object[]} phones
* @param {object} [opts]
* @returns {Promise<object[]>}
*/
export async function probeRawAll(phones, opts = {}) {
const list = Array.isArray(phones) ? phones : [];
if (list.length === 0) return [];
const hub = opts.hub || getDectRelayHub();
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
logger(LOG_SCOPE, `Fanning out phone-probe-raw() to ${list.length} phone(s)`, 'debug');
const results = await Promise.all(list.map((phone) => probeRawOne(hub, phone, timeoutMs)));
const okCount = results.filter((r) => r.ok).length;
logger(LOG_SCOPE, `Phone probe-raw finished: ${okCount}/${list.length} succeeded`, 'debug');
return results;
}
export async function probeOne(hub, phone, timeoutMs = DEFAULT_TIMEOUT_MS) {
if (!phone?.ip) {
return {
phone, ok: false, probes: null, statusJson: null, statusXml: null, parsed: null, verdict: null,
elapsedMs: null,
error: { code: 'NO_IP', message: 'phone has no IP address' },
};
}
const started = Date.now();
try {
const { result, elapsedMs } = await hub.phoneProbe(phone.ip, { timeoutMs });
const mpp = buildMppProbeView({
statusJson: result?.statusJson,
statusXml: result?.statusXml,
downloadStatusJson: result?.downloadStatusJson,
nsJson: result?.nsJson,
systemJson: result?.systemJson,
probes: result?.probes,
});
const summary = result?.summary || null;
return {
phone,
ok: !!(summary?.statusJsonFound || summary?.statusXmlFound || mpp.parsed),
probes: result?.probes || null,
statusJson: result?.statusJson || null,
statusXml: result?.statusXml || null,
downloadStatusJson: result?.downloadStatusJson || null,
nsJson: result?.nsJson || null,
systemJson: result?.systemJson || null,
cfgParsed: result?.cfgParsed || null,
parsed: mpp.parsed,
verdict: mpp.verdict,
mpp,
summary,
elapsedMs: elapsedMs ?? (Date.now() - started),
error: null,
};
} catch (err) {
return {
phone,
ok: false,
probes: null,
statusJson: null,
statusXml: null,
parsed: null,
verdict: null,
elapsedMs: Date.now() - started,
error: {
code: err?.code || 'UNKNOWN',
message: err?.message || String(err),
hint: hintFor(err?.code),
},
};
}
}
export async function probeRawOne(hub, phone, timeoutMs = DEFAULT_TIMEOUT_MS) {
if (!phone?.ip) {
return {
phone, ok: false, probes: null, statusJson: null, statusXml: null, parsed: null, verdict: null,
byteLength: null, elapsedMs: null,
error: { code: 'NO_IP', message: 'phone has no IP address' },
};
}
const started = Date.now();
try {
const { result, elapsedMs } = await hub.phoneProbeRaw(phone.ip, { timeoutMs });
const mpp = buildMppProbeView({
statusJson: result?.statusJson,
statusXml: result?.statusXml,
downloadStatusJson: result?.downloadStatusJson,
nsJson: result?.nsJson,
systemJson: result?.systemJson,
probes: result?.probes,
});
const summary = result?.summary || null;
return {
phone,
ok: !!(summary?.statusJsonFound || summary?.statusXmlFound || result?.statusJson || result?.statusXml),
probes: result?.probes || null,
statusJson: result?.statusJson || null,
statusXml: result?.statusXml || null,
downloadStatusJson: result?.downloadStatusJson || null,
nsJson: result?.nsJson || null,
systemJson: result?.systemJson || null,
byteLength: result?.byteLength ?? null,
cfgParsed: result?.cfgParsed || null,
parsed: mpp.parsed,
verdict: mpp.verdict,
mpp,
summary,
elapsedMs: elapsedMs ?? (Date.now() - started),
error: null,
};
} catch (err) {
return {
phone,
ok: false,
probes: null,
statusJson: null,
statusXml: null,
parsed: null,
verdict: null,
byteLength: null,
elapsedMs: Date.now() - started,
error: {
code: err?.code || 'UNKNOWN',
message: err?.message || String(err),
hint: hintFor(err?.code),
},
};
}
}
function hintFor(code) {
switch (code) {
case RelayErrorCodes.NOT_CONNECTED:
return 'DECT relay agent is not connected. Check that dect-relay-agent is running in the data center.';
case RelayErrorCodes.TIMEOUT:
return 'Relay accepted the request but the phone did not respond in time.';
case RelayErrorCodes.DISCONNECTED:
return 'Relay agent disconnected while this command was in flight. Try again.';
case 'AGENT_CONFIG':
return 'Relay agent configuration is incomplete — check dect-relay-agent/.env and restart.';
default:
return null;
}
}

185
services/phoneDiscovery.js Normal file
View file

@ -0,0 +1,185 @@
// services/phoneDiscovery.js
//
// Turn collectPhoneStatus() desk phones into relay targets on 10.x.
// Pure — no I/O.
import { isTenDotIp, normalizeMac } from './dectDiscovery.js';
// Webex /devices product strings vary: "Cisco CP-7841", "Cisco 7841", etc.
const MPP_PRODUCT_PATTERNS = [
/(?:^|\s)CP-\d+/i,
/^Cisco\s+CP-/i,
/^Cisco\s+78\d{2}\b/i,
/^Cisco\s+88\d{2}\b/i,
/^Cisco\s+68\d{2}\b/i,
/\bCP-78\d{2}\b/i,
/\bCP-88\d{2}\b/i,
/\bCP-68\d{2}\b/i,
];
// Room / collaboration devices — never probe via MPP phone relay.
const ROOM_PRODUCT_PATTERNS = [
/room kit/i,
/room bar/i,
/room 55/i,
/room 70/i,
/room 90/i,
/board pro/i,
/desk pro/i,
/webex desk pro/i,
/\bcodec\b/i,
/webex room/i,
/dbs-210/i,
/dect/i,
];
/**
* @param {object} phoneStatus collectPhoneStatus() output
* @returns {{ phones: object[], warnings: object[], inventory: object }}
*/
export function discoverDeskPhones(phoneStatus) {
const phones = [];
const warnings = [];
const seenIps = new Set();
const seenMacs = new Set();
const skipped = [];
const raw = extractPhoneList(phoneStatus);
for (const phone of raw) {
if (!isMppDeskPhone(phone)) {
skipped.push({
mac: phone.mac || null,
product: phone.product || phone.model || null,
name: phone.name || phone.displayName || null,
reason: 'not classified as MPP desk phone',
});
continue;
}
const ip = pickIp(phone);
const mac = pickMac(phone);
const name = phone.name || phone.displayName || `Phone ${mac || '?'}`;
if (!mac) {
warnings.push({ mac: null, ip, name, reason: 'phone has no MAC address in inventory' });
continue;
}
if (!ip) {
warnings.push({ mac, ip: null, name, reason: 'no IP address available (phone may be unreachable)' });
continue;
}
if (!isTenDotIp(ip)) {
warnings.push({
mac,
ip,
name,
reason: `phone IP ${ip} is not on the corporate 10.0.0.0/8 network; skipping`,
});
continue;
}
if (seenIps.has(ip)) {
warnings.push({ mac, ip, name, reason: `duplicate IP ${ip} — keeping first entry` });
continue;
}
if (seenMacs.has(mac)) {
warnings.push({ mac, ip, name, reason: `duplicate MAC ${mac} — keeping first entry` });
continue;
}
seenIps.add(ip);
seenMacs.add(mac);
phones.push({
mac,
ip,
name,
webexId: phone.id || null,
product: phone.product || phone.model || null,
source: phone.meraki?.ip ? 'meraki' : 'webex',
});
}
if (raw.length > 0 && phones.length === 0 && skipped.length > 0) {
warnings.push({
mac: null,
ip: null,
name: null,
reason: `${raw.length} Webex device(s) in inventory but none selected for MPP probe`,
skipped,
});
}
return {
phones,
warnings,
inventory: {
webexDeviceCount: raw.length,
mppSelected: phones.length,
skipped,
},
};
}
export function extractPhoneList(phoneStatus) {
if (Array.isArray(phoneStatus?.phones)) return phoneStatus.phones;
if (Array.isArray(phoneStatus?.phones?.data)) return phoneStatus.phones.data;
return [];
}
export function isMppDeskPhone(phone) {
if (!phone || typeof phone !== 'object') return false;
const product = String(phone.product || phone.model || '').trim();
const name = String(phone.name || phone.displayName || '').trim();
const haystack = `${product} ${name}`.trim();
for (const re of ROOM_PRODUCT_PATTERNS) {
if (re.test(haystack)) return false;
}
for (const re of MPP_PRODUCT_PATTERNS) {
if (re.test(haystack)) return true;
}
return false;
}
function pickIp(phone) {
const merakiIp = phone?.meraki?.ip;
if (typeof merakiIp === 'string' && merakiIp.trim()) return merakiIp.trim();
const webexIp = phone?.ipAddress;
if (typeof webexIp === 'string' && webexIp.trim() && webexIp !== '—') return webexIp.trim();
return null;
}
/** Webex sometimes omits MAC; Meraki client match is a reliable fallback. */
function pickMac(phone) {
const candidates = [
phone?.mac,
phone?.meraki?.mac,
phone?.meraki?.client?.mac,
];
for (const raw of candidates) {
if (!raw || raw === '—') continue;
const normalized = normalizeMac(raw);
if (normalized) return normalized;
}
return null;
}
/**
* Build a manual probe target (bypasses Webex discovery).
* @param {string} ip
* @param {object} [attrs]
*/
export function manualPhoneTarget(ip, attrs = {}) {
const trimmed = String(ip || '').trim();
if (!trimmed) return null;
return {
mac: attrs.mac || null,
ip: trimmed,
name: attrs.name || `manual ${trimmed}`,
webexId: null,
product: attrs.product || null,
source: 'manual',
};
}

View file

@ -0,0 +1,137 @@
// services/phoneStatus/capturePhoneProbe.js
//
// Fetch MPP phone probe data from store desk phones via the on-prem relay.
import { collectPhoneStatus } from '../phoneService.js';
import { discoverDeskPhones, manualPhoneTarget } from '../phoneDiscovery.js';
import { probeRawAll, probeRawOne } from '../phoneCollectorService.js';
import { getDectRelayHub } from '../dectRelayHub.js';
import { logger } from '../../utils/logger.js';
const LOG_SCOPE = 'phone:capture';
function relayStatus() {
try {
return getDectRelayHub().status();
} catch (err) {
logger(LOG_SCOPE, `Relay status unavailable: ${err.message}`, 'warn');
return { connected: false };
}
}
function mapProbeResult(r) {
return {
mac: r.phone?.mac || null,
ip: r.phone?.ip || null,
webexId: r.phone?.webexId || null,
name: r.phone?.name || null,
product: r.phone?.product || null,
ok: r.ok,
byteLength: r.byteLength,
statusJson: r.statusJson || null,
statusXml: r.statusXml || null,
downloadStatusJson: r.downloadStatusJson || null,
nsJson: r.nsJson || null,
systemJson: r.systemJson || null,
cfgParsed: r.cfgParsed || null,
probes: r.probes,
parsed: r.parsed,
verdict: r.verdict,
mpp: r.mpp || null,
summary: r.summary,
elapsedMs: r.elapsedMs,
error: r.error,
};
}
/**
* Probe a single phone IP via the connected relay (bot process only).
* @param {string} targetIp
*/
export async function capturePhoneProbeDirect(targetIp) {
const ip = String(targetIp || '').trim();
if (!ip) {
const err = new Error('targetIp is required');
err.code = 'INVALID_IP';
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;
}
const relay = relayStatus();
const phone = manualPhoneTarget(ip);
const result = await probeRawOne(getDectRelayHub(), phone);
return {
relay,
phones: [mapProbeResult(result)],
};
}
/**
* @param {string} storeNum
* @param {object} [opts]
* @param {string} [opts.phoneFilter]
* @returns {Promise<object>}
*/
export async function capturePhoneProbe(storeNum, opts = {}) {
const store = String(storeNum || '').trim();
if (!store || !/^\d{2,4}$/.test(store)) {
const err = new Error('storeNum must be a 24 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;
}
const relay = relayStatus();
const phoneData = await collectPhoneStatus(store);
const { phones, warnings: discoveryWarnings, inventory } = discoverDeskPhones(phoneData || {});
let targets = phones;
const phoneFilter = (opts.phoneFilter || '').trim();
if (phoneFilter) {
targets = filterPhones(phones, phoneFilter);
if (targets.length === 0) {
const err = new Error(`No discovered phone matched "${phoneFilter}" for store ${store}`);
err.code = 'PHONE_NOT_FOUND';
err.knownPhones = phones.map((p) => ({ ip: p.ip, mac: p.mac, name: p.name, product: p.product }));
throw err;
}
}
const results = await probeRawAll(targets);
return {
storeNum: store,
relay,
discoveryWarnings,
inventory,
phones: results.map(mapProbeResult),
};
}
function filterPhones(phones, raw) {
const needle = String(raw).trim().toLowerCase();
const needleMac = needle.replace(/[^0-9a-f]/g, '');
return phones.filter((p) => {
if (p.ip && p.ip.toLowerCase() === needle) return true;
if (p.mac) {
const macHex = p.mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
if (macHex === needleMac && needleMac.length === 12) return true;
if (p.mac.toLowerCase() === needle) return true;
}
if (p.name && p.name.toLowerCase().includes(needle)) return true;
if (p.product && p.product.toLowerCase().includes(needle)) return true;
return false;
});
}

View file

@ -0,0 +1,204 @@
// services/renderers/mppPhoneDiagnosticsRenderer.js
//
// MPP desk phone diagnostics follow-up for /phonestatus (via relay).
import { formatDisplayTime } from '../../utils/time.js';
/**
* @param {Array} results probeAll() output
* @param {object} opts
* @param {string} opts.storeNum
* @param {boolean} [opts.verbose=false] Tier 3 fields
* @param {boolean} [opts.footer=true]
* @returns {string}
*/
export function renderMppPhoneDiagnosticsMarkdown(results, opts = {}) {
const { storeNum, verbose = false, footer = true } = opts;
const list = Array.isArray(results) ? results : [];
if (list.length === 0) return '';
let out = `**MPP Desk Phone Diagnostics — Store ${storeNum}**\n\n`;
for (const r of list) {
out += renderOnePhone(r, { verbose });
out += '\n';
}
if (footer) {
out += `\n*Phone diagnostics pulled at ${formatDisplayTime()} via the DECT relay.*`;
if (!verbose) {
out += ' Pass `verbose` or `debug` for extension/debug counters.';
}
out += ' Raw capture: `node scripts/probePhone.js raw <store>`';
}
return out.trim();
}
function renderOnePhone(r, { verbose }) {
const label = r.phone?.name || r.phone?.product || `Phone ${r.phone?.mac || '?'}`;
const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?';
if (!r.ok) {
return `⚠️ **${label}** (${ip}) — probe failed: ${r.error?.message || 'unknown error'}` +
(r.error?.hint ? `\n _${r.error.hint}_` : '');
}
const mpp = r.mpp || {};
const parsed = mpp.parsed || r.parsed || {};
const verdict = mpp.verdict || r.verdict || {};
const device = parsed.device || {};
const network = parsed.network || {};
const phoneStatus = parsed.phoneStatus || {};
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
const neighbor = mpp.networkNeighbor || {};
const download = mpp.download || {};
const system = mpp.system || {};
const icon = verdict.healthy ? '✅' : '⚠️';
const fwShort = shortenFirmware(device.firmware);
let out = `${icon} **${label}** (${ip})\n`;
out += ` ${device.product || '?'} · ${device.mac || '?'} · ${fwShort}\n`;
if (line1?.registrationState) {
out += ` Ext ${line1.index}: ${line1.registrationState}`;
if (line1.lastRegistrationIp) out += `${line1.lastRegistrationIp}`;
if (line1.lastRegistrationAt) out += ` (last ${line1.lastRegistrationAt})`;
out += '\n';
}
const uptime = phoneStatus.elapsedTime || '?';
const vlan = network.vlan ? `VLAN ${network.vlan}` : null;
const link = phoneStatus.linkSpeed || neighbor.portSpeed || null;
const netBits = [uptime !== '?' ? `uptime ${uptime}` : null, vlan, link].filter(Boolean);
if (netBits.length) out += ` ${netBits.join(' · ')}\n`;
if (neighbor.switchDevice) {
const swIp = neighbor.switchIp ? ` (${neighbor.switchIp})` : '';
out += ` Switch: ${neighbor.switchDevice} ${neighbor.switchPort || ''}${swIp}\n`;
}
if (parsed.rebootHistory?.length) {
out += ` Last reboot: ${formatReboot(parsed.rebootHistory[0])}\n`;
}
if (download.latestProvisioning) {
const prov = download.latestProvisioning;
const host = prov.url ? hostFromUrl(prov.url) : '?';
const result = prov.succeeded ? 'OK' : (prov.failed ? 'FAILED' : (prov.result || '?'));
out += ` Last resync: ${host} · ${result}\n`;
}
if (download.latestFirmwareUpgrade) {
const fw = download.latestFirmwareUpgrade;
if (fw.succeeded) {
out += ` Firmware upgrade: OK\n`;
} else if (fw.failed) {
out += ` Firmware upgrade: ${fw.result}\n`;
}
}
if (phoneStatus.dot1xStatus) {
out += ` 802.1X: ${phoneStatus.dot1xStatus}${phoneStatus.dot1xProtocol ? ` (${phoneStatus.dot1xProtocol})` : ''}\n`;
}
if (phoneStatus.ledLine1Color) {
out += ` Line 1 LED: ${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}\n`;
}
if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) {
out += ` SIP msgs: ${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`;
if (phoneStatus.sipBytesSent) out += ` (${phoneStatus.sipBytesSent}B / ${phoneStatus.sipBytesRecv || 0}B)`;
out += '\n';
}
if (system.webServer) {
out += ` Web server: ${system.webServer}`;
if (system.proxyMode) out += ` · proxy ${system.proxyMode}`;
out += '\n';
}
if (line1?.messageWaiting && line1.messageWaiting !== 'No') {
out += ` Message waiting: ${line1.messageWaiting}\n`;
}
if (line1?.hotelingState && line1.hotelingState !== 'Disabled') {
out += ` Hoteling: ${line1.hotelingState}\n`;
}
if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) {
for (const w of verdict.warnings) {
out += ` ⚠️ ${w}\n`;
}
}
if (verbose) {
out += renderVerboseTier(r, parsed, neighbor, mpp.probes);
}
return out;
}
function renderVerboseTier(r, parsed, neighbor, probes) {
let out = '';
const otherLines = (parsed.lines || []).filter((l) => l.index !== 1);
for (const line of otherLines) {
out += ` Ext ${line.index}: ${line.registrationState || '?'}\n`;
}
const ps = parsed.phoneStatus || {};
if (ps.ipv6Status || ps.ipv6Address) {
out += ` IPv6: ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n`.trimEnd() + '\n';
}
if (ps.tr069Feature) out += ` TR-069: ${ps.tr069Feature}\n`;
if (ps.multicastRx != null || ps.multicastTx != null) {
out += ` Paging multicast: rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n`;
}
if (ps.streamingRx != null) out += ` XML streaming rx: ${ps.streamingRx}\n`;
const counters = neighbor.errorCounters || {};
const counterKeys = Object.keys(counters);
if (counterKeys.length > 0) {
out += ' Ethernet errors:\n';
for (const k of counterKeys.slice(0, 8)) {
out += ` ${k}: ${counters[k]}\n`;
}
}
const probeList = Array.isArray(probes) ? probes : [];
if (probeList.length > 0) {
out += ' Probe paths:\n';
out += ' ```\n';
out += ' STATUS BYTES PATH\n';
for (const p of probeList) {
const status = p.status == null ? 'ERR' : String(p.status);
out += ` ${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`;
}
out += ' ```\n';
}
return out;
}
function shortenFirmware(fw) {
if (!fw || typeof fw !== 'string') return '?';
const m = fw.match(/(sip78xx[^\s]+)/i);
return m ? m[1] : (fw.length > 36 ? `${fw.slice(0, 36)}` : fw);
}
function formatReboot(raw) {
if (!raw) return '?';
const m = raw.match(/^(\w+)\(([^)]+)\)/);
return m ? `${m[1]} ${m[2]}` : raw;
}
function hostFromUrl(url) {
if (!url || typeof url !== 'string') return '?';
try {
const normalized = url.startsWith('http') ? url : `https://${url}`;
const u = new URL(normalized);
return u.hostname;
} catch {
return url.length > 30 ? `${url.slice(0, 30)}` : url;
}
}

View file

@ -35,6 +35,8 @@ import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time.
* that a follow-up message with base-station diagnostics is on the * that a follow-up message with base-station diagnostics is on the
* way. Chat handler passes this after the base count comes back * way. Chat handler passes this after the base count comes back
* from discoverDectBases(); poller and HTTP callers pass 0. * from discoverDectBases(); poller and HTTP callers pass 0.
* @param {number} [opts.mppFollowUpPhoneCount=0]
* When > 0, emits MPP desk-phone relay diagnostics loading hint.
* @param {boolean} [opts.wanFollowUpEnabled=false] * @param {boolean} [opts.wanFollowUpEnabled=false]
* When true, emits a "⏳ WAN metrics loading…" line just above the * When true, emits a "⏳ WAN metrics loading…" line just above the
* footer. Same rationale as `dectFollowUpBaseCount` chat handler * footer. Same rationale as `dectFollowUpBaseCount` chat handler
@ -49,6 +51,7 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
detailed = false, detailed = false,
footer = true, footer = true,
dectFollowUpBaseCount = 0, dectFollowUpBaseCount = 0,
mppFollowUpPhoneCount = 0,
wanFollowUpEnabled = false, wanFollowUpEnabled = false,
} = opts; } = opts;
@ -138,6 +141,11 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
} }
reply += '\n'; reply += '\n';
}); });
if (mppFollowUpPhoneCount > 0) {
const n = mppFollowUpPhoneCount;
reply += `_⏳ MPP phone diagnostics loading for ${n} desk phone${n === 1 ? '' : 's'} via relay — a follow-up message will arrive shortly._\n\n`;
}
} }
// DECT Basestations // DECT Basestations

View file

@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDownloadStatusJson, downloadStatusWarnings } from '../integrations/cisco-mpp-phone/downloadStatusJson.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/download-status-782.json'), 'utf8');
test('parseDownloadStatusJson: extracts provisioning and firmware history', () => {
const parsed = parseDownloadStatusJson(FIXTURE);
assert.equal(parsed.latestProvisioning.succeeded, true);
assert.match(parsed.latestProvisioning.url, /cisco\.sipflash\.com/);
assert.equal(parsed.latestFirmwareUpgrade.succeeded, true);
assert.match(parsed.latestFirmwareUpgrade.url, /binaries\.webex\.com/);
});
test('downloadStatusWarnings: flags MIC cert failure', () => {
const parsed = parseDownloadStatusJson(FIXTURE);
const warnings = downloadStatusWarnings(parsed);
assert.ok(warnings.some((w) => /MIC cert/i.test(w)));
assert.equal(parsed.micCert.failed, true);
});

View file

@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseNsJson } from '../integrations/cisco-mpp-phone/nsJson.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/ns-782.json'), 'utf8');
test('parseNsJson: extracts LLDP neighbor and port speed', () => {
const parsed = parseNsJson(FIXTURE);
assert.equal(parsed.switchDevice, 'SW00782R');
assert.equal(parsed.switchPort, 'Port 45');
assert.equal(parsed.switchIp, '10.229.105.251');
assert.match(parsed.portSpeed, /100/);
});
test('parseNsJson: omits zero error counters', () => {
const parsed = parseNsJson(FIXTURE);
assert.equal(Object.keys(parsed.errorCounters).length, 0);
});

View file

@ -0,0 +1,31 @@
// Parser tests for MPP Status.json (live store 782 fixture from HAR)
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseStatusJson, summarizePhoneHealthFromJson } from '../integrations/cisco-mpp-phone/statusJson.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/status-782-live.json'), 'utf8');
test('parseStatusJson: live 782 fixture extracts registration + product', () => {
const parsed = parseStatusJson(LIVE);
assert.equal(parsed.device.product, 'CP-7841-3PCC');
assert.equal(parsed.device.mac, 'cc:98:91:4f:67:99');
assert.match(parsed.device.firmware, /sip78xx/);
assert.equal(parsed.network.ipv4, '10.43.206.157');
assert.equal(parsed.lines[0].registrationState, 'Registered');
assert.equal(parsed.registration, 'Registered');
assert.ok(parsed.rebootHistory.length >= 1);
assert.ok(Object.keys(parsed.fields).length > 20);
});
test('summarizePhoneHealthFromJson: registered phone is healthy', () => {
const parsed = parseStatusJson(LIVE);
const verdict = summarizePhoneHealthFromJson(parsed);
assert.equal(verdict.healthy, true);
assert.equal(verdict.warnings.length, 0);
});

View file

@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseSystemJson } from '../integrations/cisco-mpp-phone/systemJson.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/system-782.json'), 'utf8');
test('parseSystemJson: extracts web server and proxy', () => {
const parsed = parseSystemJson(FIXTURE);
assert.equal(parsed.webServer, 'Yes');
assert.equal(parsed.proxyMode, 'Off');
assert.equal(parsed.dot1xCertSelect, 'Manufacturing installed');
});

165
tests/ciscoMppPhone.test.js Normal file
View file

@ -0,0 +1,165 @@
// Unit tests for integrations/cisco-mpp-phone/
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createMppPhoneClient, tryRequest } from '../integrations/cisco-mpp-phone/client.js';
import { runReadProbes, fetchStatusJson, summarizeProbes } from '../integrations/cisco-mpp-phone/probes.js';
import { parseStatusJson, summarizePhoneHealthFromJson } from '../integrations/cisco-mpp-phone/statusJson.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'mpp');
const STATUS_XML = fs.readFileSync(path.join(FIXTURE_DIR, 'status-782-sample.xml'), 'utf8');
const STATUS_JSON = fs.readFileSync(path.join(FIXTURE_DIR, 'status-782-live.json'), 'utf8');
const KEY = fs.readFileSync(path.join(FIXTURE_DIR, 'test-key.pem'), 'utf8');
const CERT = fs.readFileSync(path.join(FIXTURE_DIR, 'test-cert.pem'), 'utf8');
const TEST_USER = 'admin';
const TEST_PASS = 'test-phone-pass';
function startMockPhoneServer({ openJson = false } = {}) {
return new Promise((resolve) => {
const server = https.createServer({ key: KEY, cert: CERT }, (req, res) => {
const openPaths = openJson && (
req.url === '/Status.json'
|| req.url === '/Download%20Status.json'
|| req.url === '/ns.json'
|| req.url === '/basic/System.json'
|| req.url === '/'
);
if (!openPaths) {
const auth = req.headers.authorization || '';
const expected = `Basic ${Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64')}`;
if (auth !== expected) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="phone"' });
res.end('unauthorized');
return;
}
}
if (req.url === '/Status.json' || req.url === '/admin/status.xml' || req.url === '/status.xml') {
res.writeHead(200, { 'Content-Type': req.url.endsWith('.json') ? 'application/json' : 'application/xml' });
res.end(req.url.endsWith('.json') ? STATUS_JSON : STATUS_XML);
return;
}
if (req.url === '/admin/cfg.xml') {
res.writeHead(200, { 'Content-Type': 'application/xml' });
res.end('<flat-profile><Enable_Web_Server>Yes</Enable_Web_Server><Line_1><Proxy>sip.webex.com</Proxy></Line_1></flat-profile>');
return;
}
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body>MPP phone</body></html>');
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({ server, port, close: () => new Promise((r) => server.close(() => r())) });
});
});
}
test('parseStatusJson: extracts key fields from fixture', () => {
const parsed = parseStatusJson(STATUS_JSON);
assert.equal(parsed.device.mac, 'cc:98:91:4f:67:99');
assert.equal(parsed.registration, 'Registered');
});
test('summarizePhoneHealthFromJson: registered phone is healthy', () => {
const parsed = parseStatusJson(STATUS_JSON);
const verdict = summarizePhoneHealthFromJson(parsed);
assert.equal(verdict.healthy, true);
});
test('createMppPhoneClient: fetches status.xml over self-signed HTTPS', async () => {
const mock = await startMockPhoneServer();
try {
const client = createMppPhoneClient({
host: `127.0.0.1:${mock.port}`,
user: TEST_USER,
password: TEST_PASS,
timeoutMs: 5000,
});
const r = await tryRequest(client, { path: '/admin/status.xml' });
assert.equal(r.status, 200);
assert.ok(r.sizeBytes > 0);
assert.match(r.snippet, /MAC_Address|Product_Name|Registered/);
} finally {
await mock.close();
}
});
test('runReadProbes: returns structured rows against mock phone', async () => {
const mock = await startMockPhoneServer();
try {
const client = createMppPhoneClient({
host: `127.0.0.1:${mock.port}`,
user: TEST_USER,
password: TEST_PASS,
timeoutMs: 5000,
});
const probes = await runReadProbes(client);
const summary = summarizeProbes(probes);
assert.ok(probes.length >= 2);
assert.equal(summary.statusJsonFound, true);
assert.equal(summary.authOk, true);
} finally {
await mock.close();
}
});
test('fetchStatusJson: returns raw JSON body', async () => {
const mock = await startMockPhoneServer();
try {
const client = createMppPhoneClient({
host: `127.0.0.1:${mock.port}`,
user: TEST_USER,
password: TEST_PASS,
timeoutMs: 5000,
});
const { rawJson, byteLength } = await fetchStatusJson(client);
assert.ok(rawJson.includes('Product Name'));
assert.ok(byteLength > 0);
} finally {
await mock.close();
}
});
test('createMppPhoneClient: fetches Status.json without auth (Webex MPP web UI)', async () => {
const mock = await startMockPhoneServer({ openJson: true });
try {
const client = createMppPhoneClient({
host: `127.0.0.1:${mock.port}`,
timeoutMs: 5000,
});
const r = await tryRequest(client, { path: '/Status.json' });
assert.equal(r.status, 200);
assert.ok(r.sizeBytes > 0);
const { rawJson } = await fetchStatusJson(client);
assert.ok(rawJson.includes('Product Name'));
} finally {
await mock.close();
}
});
test('createMppPhoneClient: rejects wrong password', async () => {
const mock = await startMockPhoneServer();
try {
const client = createMppPhoneClient({
host: `127.0.0.1:${mock.port}`,
user: TEST_USER,
password: 'wrong',
timeoutMs: 5000,
});
const r = await tryRequest(client, { path: '/admin/status.xml' });
assert.equal(r.status, 401);
} finally {
await mock.close();
}
});

View file

@ -349,3 +349,27 @@ test('hub: execAction routes action name into type field', async () => {
await closeAll(); await closeAll();
} }
}); });
test('hub: phoneProbe RPC uses targetIp and returns probe summary', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
attachAutoAgent(ws, async (msg) => {
assert.equal(msg.type, 'phone-probe');
assert.equal(msg.targetIp, '10.4.11.50');
return {
probes: [{ path: '/admin/status.xml', status: 200, sizeBytes: 100 }],
summary: { okCount: 1, total: 4, authOk: true, statusXmlFound: true },
statusXml: '<Status/>',
parsed: { fields: {} },
verdict: { healthy: true, warnings: [], info: [] },
};
});
const { result } = await hub.phoneProbe('10.4.11.50');
assert.equal(result.summary.statusXmlFound, true);
assert.equal(result.probes[0].path, '/admin/status.xml');
} finally {
await closeAll();
}
});

View file

@ -0,0 +1,34 @@
[{
"line": "one",
"type": 9,
"name": "Firmware Upgrade Status"
}, {
"line": "one",
"type": 12,
"value": "[05/19/2026 03:08:43][https://binaries.webex.com:443/cisco-mpp-78xx-stable/20260430130452/sip78xx.12-0-7MPP0501-20260317-aa82ce7433.loads]Upgrade Succeeded.",
"name": "Firmware Upgrade Status 1",
"index": 420,
"tab": 27
}, {
"line": "one",
"type": 9,
"name": "Provisioning Status"
}, {
"line": "one",
"type": 12,
"value": "[1718][07/28/2026 15:30:59][https://cisco.sipflash.com:443/*]Resync Succeeded.",
"name": "Provisioning Status 1",
"index": 488,
"tab": 27
}, {
"line": "one",
"type": 9,
"name": "MIC Cert Refresh Status"
}, {
"line": "one",
"type": 12,
"value": "[07/28/2026 16:30:50][http://sudirenewal.cisco.com:80/test.cer]MIC Cert Download Failed. Reason: Error - File not found",
"name": "MIC Cert Provisioning Status",
"index": 506,
"tab": 27
}]

35
tests/fixtures/mpp/ns-782.json vendored Normal file
View file

@ -0,0 +1,35 @@
[{
"line": "one",
"type": 9,
"name": "Network Port Information"
}, {
"type": 1,
"value": "0",
"name": "RxDropPkts",
"index": "sc-13",
"tab": -1
}, {
"type": 1,
"value": "SW00782R",
"name": "LLDPNeighborDeviceId",
"index": "sc-49",
"tab": -1
}, {
"type": 1,
"value": "10.229.105.251",
"name": "LLDPNeighborIP",
"index": "sc-50",
"tab": -1
}, {
"type": 1,
"value": "Port 45",
"name": "LLDPNeighborPort",
"index": "sc-51",
"tab": -1
}, {
"type": 1,
"value": "100F ",
"name": "PortSpeed",
"index": "sc-52",
"tab": -1
}]

745
tests/fixtures/mpp/status-782-live.json vendored Normal file
View file

@ -0,0 +1,745 @@
[{
"line": "one",
"type": 9,
"name": "System Information"
}, {
"type": 12,
"value": "SEPCC98914F6799",
"name": "Host Name",
"index": -15767,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Domain",
"index": -15766,
"tab": 1
}, {
"type": 12,
"value": "ntp.broadcloudpbx.net",
"name": "Primary NTP Server",
"index": -15735,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Secondary NTP Server",
"index": -15734,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "IPv4 Information"
}, {
"type": 12,
"value": "OK",
"name": "IP Status",
"index": -15764,
"tab": 1
}, {
"type": 12,
"value": "DHCP",
"name": "Connection Type",
"index": -15759,
"tab": 1
}, {
"type": 12,
"value": "10.43.206.157",
"name": "Current IP",
"index": -15765,
"tab": 1
}, {
"type": 12,
"value": "255.255.255.240",
"name": "Current Netmask",
"index": -15763,
"tab": 1
}, {
"type": 12,
"value": "10.43.206.145",
"name": "Current Gateway",
"index": -15762,
"tab": 1
}, {
"type": 12,
"value": "10.96.65.21",
"name": "Primary DNS",
"index": -15761,
"tab": 1
}, {
"line": "one",
"type": 12,
"value": "10.97.227.252 ",
"name": "Secondary DNS",
"index": -15760,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "IPv6 Information"
}, {
"type": 12,
"value": "Initializing",
"name": "IP Status_IPV6",
"index": -15752,
"tab": 1
}, {
"type": 12,
"value": "DHCP",
"name": "Connection Type_IPV6",
"index": -15753,
"tab": 1
}, {
"type": 12,
"value": "::",
"name": "Current IP_IPV6",
"index": -15756,
"tab": 1
}, {
"type": 12,
"value": "0",
"name": "Prefix Length_IPV6",
"index": -15755,
"tab": 1
}, {
"type": 12,
"value": "::",
"name": "Current Gateway_IPV6",
"index": -15754,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Primary DNS_IPV6",
"index": -15751,
"tab": 1
}, {
"line": "one",
"type": 12,
"value": "",
"name": "Secondary DNS_IPV6",
"index": -15750,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Reboot History"
}, {
"type": 12,
"value": "Provisioning(07/28/2026 15:29:46)",
"name": "Reboot Reason 1",
"index": -15706,
"tab": 1
}, {
"type": 12,
"value": "Provisioning(05/19/2026 03:08:44)",
"name": "Reboot Reason 2",
"index": -15705,
"tab": 1
}, {
"type": 12,
"value": "Upgrade(04/23/2026 03:11:46)",
"name": "Reboot Reason 3",
"index": -15704,
"tab": 1
}, {
"type": 12,
"value": "Upgrade(03/05/2026 00:02:43)",
"name": "Reboot Reason 4",
"index": -15703,
"tab": 1
}, {
"type": 12,
"value": "Cloud Triggered(02/26/2026 14:59:51)",
"name": "Reboot Reason 5",
"index": -15702,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "VPN Status"
}, {
"type": 12,
"value": "No",
"name": "VPN Connected",
"index": -15719,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Client Address",
"index": -15718,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Client Netmask",
"index": -15717,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Bytes Sent",
"index": -15711,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Bytes Recv",
"index": -15712,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Product Information"
}, {
"type": 12,
"value": "CP-7841-3PCC",
"name": "Product Name",
"index": 8,
"tab": 1
}, {
"type": 12,
"value": "WZP21281JHO",
"name": "Serial Number",
"index": 7,
"tab": 1
}, {
"type": 12,
"value": "V05",
"name": "VID",
"index": 16,
"tab": 1
}, {
"type": 12,
"value": "CC98914F6799",
"name": "MAC Address",
"index": 6,
"tab": 1
}, {
"type": 12,
"value": "sip78xx.12-0-7MPP0501-20260317-aa82ce7433.loads",
"name": "Software Version",
"index": 10,
"tab": 1
}, {
"type": 12,
"value": "33",
"name": "Hardware Version",
"index": 9,
"tab": 1
}, {
"type": 12,
"value": "Installed",
"name": "Client Certificate",
"index": 13,
"tab": 1
}, {
"type": 12,
"value": "WxC",
"name": "Transition Authorization Type",
"index": 430,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Phone Status"
}, {
"type": 12,
"value": "7/28/2026 04:20:26 PM",
"name": "Current Time",
"index": -65520,
"tab": 1
}, {
"type": 12,
"value": "22 days and 07:20:35",
"name": "Elapsed Time",
"index": -65519,
"tab": 1
}, {
"type": 12,
"value": "53",
"name": "SIP Messages Sent",
"index": -65514,
"tab": 1
}, {
"type": 12,
"value": "47687",
"name": "SIP Bytes Sent",
"index": -65512,
"tab": 1
}, {
"type": 12,
"value": "52",
"name": "SIP Messages Recv",
"index": -65513,
"tab": 1
}, {
"type": 12,
"value": "37743",
"name": "SIP Bytes Recv",
"index": -65511,
"tab": 1
}, {
"type": 12,
"value": "2006647",
"name": "Network Packets Sent",
"index": -65460,
"tab": 1
}, {
"type": 12,
"value": "2545022",
"name": "Network Packets Recv",
"index": -65459,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "External IP",
"index": -65500,
"tab": 1
}, {
"type": 12,
"value": "4095",
"name": "Operational VLAN ID",
"index": -65466,
"tab": 1
}, {
"type": 12,
"value": "100M Full",
"name": "SW Port",
"index": -65465,
"tab": 1
}, {
"type": 12,
"value": "Disabled",
"name": "PC Port",
"index": -65464,
"tab": 1
}, {
"type": 12,
"value": "Same image",
"name": "Upgrade Status",
"index": -65458,
"tab": 1
}, {
"type": 12,
"value": "AUTO",
"name": "SW Port Config",
"index": -65463,
"tab": 1
}, {
"type": 12,
"value": "Disabled",
"name": "PC Port Config",
"index": -65462,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Dot1x Authentication"
}, {
"type": 12,
"value": "Authenticated",
"name": "Transaction status",
"index": -15396,
"tab": 1
}, {
"type": 12,
"value": "None",
"name": "Protocol",
"index": -15395,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "LED Status"
}, {
"type": 12,
"value": "Steady",
"name": "Line 1 LED Cadence",
"index": -16112,
"tab": 1
}, {
"type": 12,
"value": "Green",
"name": "Line 1 LED Color",
"index": -16111,
"tab": 1
}, {
"type": 12,
"value": "Steady",
"name": "Line 2 LED Cadence",
"index": -16110,
"tab": 1
}, {
"type": 12,
"value": "Off",
"name": "Line 2 LED Color",
"index": -16109,
"tab": 1
}, {
"type": 12,
"value": "Steady",
"name": "Line 3 LED Cadence",
"index": -16108,
"tab": 1
}, {
"type": 12,
"value": "Off",
"name": "Line 3 LED Color",
"index": -16107,
"tab": 1
}, {
"type": 12,
"value": "Steady",
"name": "Line 4 LED Cadence",
"index": -16106,
"tab": 1
}, {
"type": 12,
"value": "Off",
"name": "Line 4 LED Color",
"index": -16105,
"tab": 1
}, {
"type": 12,
"value": "Steady",
"name": "Headset LED Cadence",
"index": -16080,
"tab": 1
}, {
"type": 12,
"value": "Off",
"name": "Headset LED Color",
"index": -16079,
"tab": 1
}, {
"type": 12,
"value": "Steady",
"name": "Speaker LED Cadence",
"index": -16078,
"tab": 1
}, {
"type": 12,
"value": "Off",
"name": "Speaker LED Color",
"index": -16077,
"tab": 1
}, {
"type": 12,
"value": "Steady",
"name": "Mute LED Cadence",
"index": -16076,
"tab": 1
}, {
"type": 12,
"value": "Off",
"name": "Mute LED Color",
"index": -16075,
"tab": 1
}, {
"type": 12,
"value": "Steady",
"name": "Mwi LED Cadence",
"index": -16074,
"tab": 1
}, {
"type": 12,
"value": "Off",
"name": "Mwi LED Color",
"index": -16073,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Ext 1 Status"
}, {
"type": 12,
"value": "Registered",
"name": "Registration State",
"index": -65263,
"tab": 1
}, {
"type": 12,
"value": "7/28/2026 16:20:13",
"name": "Last Registration At",
"index": -65262,
"tab": 1
}, {
"type": 12,
"value": "150.253.156.211",
"name": "Last Registration IP",
"index": -65261,
"tab": 1
}, {
"type": 12,
"value": "80",
"name": "Next Registration In Seconds",
"index": -65260,
"tab": 1
}, {
"type": 12,
"value": "No",
"name": "Message Waiting",
"index": -65259,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Mapped SIP Port",
"index": -65255,
"tab": 1
}, {
"type": 12,
"value": "Disabled",
"name": "Hoteling State",
"index": -65249,
"tab": 1
}, {
"type": 12,
"value": "None",
"name": "Extended Function Status",
"index": -65254,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Ext 2 Status"
}, {
"type": 12,
"value": "Not Registered",
"name": "Registration State",
"index": -65007,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Last Registration At",
"index": -65006,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Last Registration IP",
"index": -65005,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Next Registration In Seconds",
"index": -65004,
"tab": 1
}, {
"type": 12,
"value": "No",
"name": "Message Waiting",
"index": -65003,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Mapped SIP Port",
"index": -64999,
"tab": 1
}, {
"type": 12,
"value": "Disabled",
"name": "Hoteling State",
"index": -64993,
"tab": 1
}, {
"type": 12,
"value": "None",
"name": "Extended Function Status",
"index": -64998,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Ext 3 Status"
}, {
"type": 12,
"value": "Not Registered",
"name": "Registration State",
"index": -64751,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Last Registration At",
"index": -64750,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Last Registration IP",
"index": -64749,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Next Registration In Seconds",
"index": -64748,
"tab": 1
}, {
"type": 12,
"value": "No",
"name": "Message Waiting",
"index": -64747,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Mapped SIP Port",
"index": -64743,
"tab": 1
}, {
"type": 12,
"value": "Disabled",
"name": "Hoteling State",
"index": -64737,
"tab": 1
}, {
"type": 12,
"value": "None",
"name": "Extended Function Status",
"index": -64742,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Ext 4 Status"
}, {
"type": 12,
"value": "Not Registered",
"name": "Registration State",
"index": -64495,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Last Registration At",
"index": -64494,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Last Registration IP",
"index": -64493,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Next Registration In Seconds",
"index": -64492,
"tab": 1
}, {
"type": 12,
"value": "No",
"name": "Message Waiting",
"index": -64491,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Mapped SIP Port",
"index": -64487,
"tab": 1
}, {
"type": 12,
"value": "Disabled",
"name": "Hoteling State",
"index": -64481,
"tab": 1
}, {
"type": 12,
"value": "None",
"name": "Extended Function Status",
"index": -64486,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "Paging Status"
}, {
"type": 12,
"value": "0",
"name": "Multicast Rx Pkts",
"index": -15393,
"tab": 1
}, {
"type": 12,
"value": "0",
"name": "Multicast Tx Pkts",
"index": -15392,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "XML Streaming Status"
}, {
"type": 12,
"value": "0",
"name": "Streaming Rx Pkts",
"index": -15391,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "TR-069 Status"
}, {
"type": 12,
"value": "Disabled",
"name": "TR-069 Feature",
"index": -15477,
"tab": 1
}, {
"type": 12,
"value": "20 s",
"name": "Periodic Inform Time",
"index": -15476,
"tab": 1
}, {
"type": 12,
"value": "0/0/0 00:00:00",
"name": "Last Inform Time",
"index": -15475,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "Last Transaction Status",
"index": -15474,
"tab": 1
}, {
"type": 12,
"value": "0/0/0 00:00:00 - 0/0/0 00:00:00",
"name": "Last Session",
"index": -15473,
"tab": 1
}, {
"type": 12,
"value": "",
"name": "ParameterKey",
"index": -15472,
"tab": 1
}, {
"line": "one",
"type": 9,
"name": "PRT Status"
}, {
"line": "one",
"type": 12,
"value": "",
"name": "PRT Generation Status",
"index": 475,
"tab": 1
}, {
"line": "one",
"type": 12,
"value": "",
"name": "PRT Upload Status",
"index": 476,
"tab": 1
}]

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<Status>
<Product_information>
<Product_Name>Cisco CP-7841</Product_Name>
<Serial_Number>FCH2345L0AB</Serial_Number>
<MAC_Address>f0b2e56789ab</MAC_Address>
<Software_Version>sip78xx.14-0-1-0201-1</Software_Version>
</Product_information>
<Network>
<IPv4_Address>10.4.11.50</IPv4_Address>
<VLAN_ID>120</VLAN_ID>
<Host_Name>CP-7841-782</Host_Name>
<Default_Router>10.4.11.1</Default_Router>
</Network>
<Line_1>
<User_ID>50782</User_ID>
<Display_Name>Store 782</Display_Name>
<Registration>Registered</Registration>
<Line_State>Idle</Line_State>
</Line_1>
<Reboot_History>
<Reboot_Reason_1>[07/28/26 08:12:38] User Triggered</Reboot_Reason_1>
<Reboot_Reason_2>[07/27/26 14:30:10] Provisioning</Reboot_Reason_2>
</Reboot_History>
<Provisioning_Status>Complete</Provisioning_Status>
</Status>

29
tests/fixtures/mpp/system-782.json vendored Normal file
View file

@ -0,0 +1,29 @@
[{
"line": "one",
"type": 9,
"name": "System Configuration"
}, {
"type": 12,
"value": "Yes",
"name": "Enable Web Server",
"index": 45,
"tab": 2
}, {
"type": 4,
"options": ["Auto", "Manual", "Off"],
"value": "Off",
"name": "Proxy Mode",
"index": 329,
"tab": 2
}, {
"line": "one",
"type": 9,
"name": "802.1X Authentication"
}, {
"type": 4,
"options": ["Manufacturing installed", "Custom installed"],
"value": "Manufacturing installed",
"name": "Certificate Select",
"index": 292,
"tab": 2
}]

View file

@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js';
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATUS = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/status-782-live.json'), 'utf8');
const DOWNLOAD = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/download-status-782.json'), 'utf8');
const NS = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/ns-782.json'), 'utf8');
const SYSTEM = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/system-782.json'), 'utf8');
function okResult() {
const mpp = buildMppProbeView({
statusJson: STATUS,
downloadStatusJson: DOWNLOAD,
nsJson: NS,
systemJson: SYSTEM,
probes: [
{ path: '/Status.json', status: 200, sizeBytes: 12564 },
{ path: '/ns.json', status: 200, sizeBytes: 9474 },
],
});
return {
ok: true,
phone: { name: 'Store 00782 CP-7841', ip: '10.43.206.157', mac: 'CC98914F6799' },
parsed: mpp.parsed,
verdict: mpp.verdict,
mpp,
probes: mpp.probes,
};
}
test('renderMppPhoneDiagnosticsMarkdown: tier 1+2 default output', () => {
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
assert.match(md, /\*\*MPP Desk Phone Diagnostics — Store 782\*\*/);
assert.match(md, /Registered/);
assert.match(md, /SW00782R/);
assert.match(md, /Port 45/);
assert.match(md, /cisco\.sipflash\.com/);
assert.match(md, /MIC cert/i);
assert.doesNotMatch(md, /Probe paths:/);
});
test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table', () => {
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', verbose: true, footer: false });
assert.match(md, /Probe paths:/);
assert.match(md, /\/Status\.json/);
assert.match(md, /Ext 2:/);
});
test('renderMppPhoneDiagnosticsMarkdown: relay failure line', () => {
const md = renderMppPhoneDiagnosticsMarkdown([{
ok: false,
phone: { name: 'Phone', ip: '10.1.1.1' },
error: { message: 'relay offline', hint: 'check agent' },
}], { storeNum: '782', footer: false });
assert.match(md, /probe failed: relay offline/);
assert.match(md, /check agent/);
});
test('buildMppProbeView: merges MIC warning into verdict', () => {
const mpp = buildMppProbeView({
statusJson: STATUS,
downloadStatusJson: DOWNLOAD,
nsJson: NS,
});
assert.equal(mpp.verdict.healthy, false);
assert.ok(mpp.verdict.warnings.some((w) => /MIC cert/i.test(w)));
});

View file

@ -0,0 +1,103 @@
// Unit tests for services/phoneDiscovery.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
discoverDeskPhones,
isMppDeskPhone,
} from '../services/phoneDiscovery.js';
const phone = (attrs = {}) => ({
mac: 'f0:b2:e5:67:89:ab',
name: 'Register 1',
product: 'Cisco CP-7841',
ipAddress: '10.4.11.50',
meraki: {},
...attrs,
});
test('isMppDeskPhone: accepts CP-78xx products', () => {
assert.equal(isMppDeskPhone({ product: 'Cisco CP-7841' }), true);
assert.equal(isMppDeskPhone({ product: 'Cisco 7841', capabilities: ['xapi'] }), true);
assert.equal(isMppDeskPhone({ model: 'CP-8851' }), true);
assert.equal(isMppDeskPhone({ name: 'Store 00782 CP-7841', product: 'Cisco 7841' }), true);
});
test('isMppDeskPhone: rejects non-desk devices', () => {
assert.equal(isMppDeskPhone({ product: 'Cisco Webex Room Kit' }), false);
assert.equal(isMppDeskPhone({ product: 'DBS-210-3PC' }), false);
});
test('discoverDeskPhones: empty input', () => {
assert.deepEqual(discoverDeskPhones({}), {
phones: [],
warnings: [],
inventory: { webexDeviceCount: 0, mppSelected: 0, skipped: [] },
});
});
test('discoverDeskPhones: happy path with nested phones.data', () => {
const { phones, warnings } = discoverDeskPhones({
phones: {
status: 'success',
data: [phone({ product: 'Cisco 7841', meraki: { ip: '10.4.11.50' } })],
},
});
assert.equal(warnings.length, 0);
assert.equal(phones.length, 1);
assert.equal(phones[0].ip, '10.4.11.50');
assert.equal(phones[0].source, 'meraki');
assert.equal(phones[0].product, 'Cisco 7841');
});
test('discoverDeskPhones: prefers Meraki IP over Webex', () => {
const { phones } = discoverDeskPhones({
phones: { data: [phone({ ipAddress: '10.9.9.9', meraki: { ip: '10.4.11.50' } })] },
});
assert.equal(phones[0].ip, '10.4.11.50');
});
test('discoverDeskPhones: skips non-10.x', () => {
const { phones, warnings } = discoverDeskPhones({
phones: { data: [phone({ ipAddress: '192.168.1.10', meraki: {} })] },
});
assert.equal(phones.length, 0);
assert.match(warnings[0].reason, /10\.0\.0\.0\/8/);
});
test('discoverDeskPhones: skips non-MPP inventory', () => {
const { phones } = discoverDeskPhones({
phones: {
data: [phone({ product: 'Cisco Webex Desk Pro' })],
},
});
assert.equal(phones.length, 0);
});
test('discoverDeskPhones: uses Meraki MAC when Webex MAC is placeholder', () => {
const { phones, warnings } = discoverDeskPhones({
phones: {
data: [phone({
mac: '—',
meraki: { ip: '10.4.11.50', mac: 'f0:b2:e5:67:89:ab' },
})],
},
});
assert.equal(warnings.length, 0);
assert.equal(phones.length, 1);
assert.equal(phones[0].mac, 'f0:b2:e5:67:89:ab');
});
test('discoverDeskPhones: dedupes by IP', () => {
const { phones, warnings } = discoverDeskPhones({
phones: {
data: [
phone({ mac: 'f0:b2:e5:67:89:ab', meraki: { ip: '10.4.11.50' } }),
phone({ mac: 'aa:bb:cc:dd:ee:ff', meraki: { ip: '10.4.11.50' } }),
],
},
});
assert.equal(phones.length, 1);
assert.match(warnings[0].reason, /duplicate IP/i);
});

View file

@ -292,6 +292,18 @@ test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default
assert.doesNotMatch(md, /diagnostics loading/); assert.doesNotMatch(md, /diagnostics loading/);
}); });
test('phone renderer: mppFollowUpPhoneCount > 0 emits MPP loading hint', () => {
const data = {
phones: { data: [{ name: 'Store CP-7841', status: 'connected', lastSeen: new Date().toISOString() }] },
dectBasestations: [],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, {
storeNum: '782', footer: false, mppFollowUpPhoneCount: 1,
});
assert.match(md, /MPP phone diagnostics loading for 1 desk phone/);
});
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// DECT follow-up message (renderDectDiagnosticsMarkdown) // DECT follow-up message (renderDectDiagnosticsMarkdown)
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────