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:
parent
26ae704dff
commit
f7953b8eb5
41 changed files with 3696 additions and 10 deletions
19
.env.example
19
.env.example
|
|
@ -499,6 +499,25 @@ DECT_RELAY_AGENT_TOKEN=
|
|||
# node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -55,11 +55,22 @@ const LONG_HELP = {
|
|||
},
|
||||
phonestatus: {
|
||||
title: '/phonestatus',
|
||||
usage: ['/phonestatus <store>', '/phonestatus <store> detailed'],
|
||||
examples: ['/phonestatus 782', '/phonestatus 782 detailed'],
|
||||
usage: [
|
||||
'/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: [
|
||||
'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).',
|
||||
'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>`.',
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ import {
|
|||
renderDectDiagnosticsMarkdown,
|
||||
} from '../services/renderers/phoneStatusRenderer.js';
|
||||
import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
|
||||
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
|
||||
import { buildIgmpFixCard } from './igmpFix.js';
|
||||
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
|
||||
import { extractRequester } from '../utils/requester.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { discoverDectBases } from '../services/dectDiscovery.js';
|
||||
import { discoverDeskPhones } from '../services/phoneDiscovery.js';
|
||||
import { collectAll } from '../services/dectCollectorService.js';
|
||||
import { probeAll } from '../services/phoneCollectorService.js';
|
||||
import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.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;
|
||||
|
||||
const isDetailed = (args[1]?.toLowerCase() === 'detailed') ||
|
||||
(query.mode === 'detailed') ||
|
||||
(query.detailed === 'true' || query.detailed === true);
|
||||
const isDetailed = argIncludes(args, 'detailed')
|
||||
|| (query.mode === 'detailed')
|
||||
|| (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)) {
|
||||
const errorMsg = 'Please provide a 2–4 digit store number.\n' +
|
||||
|
|
@ -72,6 +79,9 @@ export async function handlePhoneStatus(bot, trigger) {
|
|||
const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled
|
||||
? discoverDectBases(data)
|
||||
: { bases: [], warnings: [] };
|
||||
const { phones: mppPhones, warnings: mppDiscoveryWarnings } = dectFollowUpEnabled
|
||||
? discoverDeskPhones(data)
|
||||
: { phones: [], warnings: [] };
|
||||
if (discoveryWarnings.length > 0) {
|
||||
logger(
|
||||
'phone:status',
|
||||
|
|
@ -79,6 +89,22 @@ export async function handlePhoneStatus(bot, trigger) {
|
|||
'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
|
||||
// 4-hour-cached sites list to confirm this store is Prisma-
|
||||
|
|
@ -116,6 +142,7 @@ export async function handlePhoneStatus(bot, trigger) {
|
|||
detailed: isDetailed,
|
||||
footer: true,
|
||||
dectFollowUpBaseCount: reachableBases.length,
|
||||
mppFollowUpPhoneCount: mppPhones.length,
|
||||
wanFollowUpEnabled,
|
||||
});
|
||||
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
|
||||
// discovery above already confirmed we have a Prisma site for
|
||||
// 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);
|
||||
}
|
||||
|
||||
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
|
||||
* hydrates site + elements + healthscore + per-path LQM + alarms in
|
||||
|
|
@ -242,3 +282,8 @@ function safeSiteName(storeNum) {
|
|||
return '<invalid store number>';
|
||||
}
|
||||
}
|
||||
|
||||
function argIncludes(args, token) {
|
||||
const needle = String(token).toLowerCase();
|
||||
return (args || []).some((a) => String(a).toLowerCase() === needle);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@ DECT_ADMIN_PASSWORD=replace-with-dect-serviceability-password
|
|||
# is aggressive.
|
||||
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 ─────────────────────────────────────────────────
|
||||
#
|
||||
# How long to wait between reconnect attempts when the bot socket
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
# client.js (at /workspace/integrations/cisco-dect/client.js) would
|
||||
# never find axios and blow up with ERR_MODULE_NOT_FOUND at runtime.
|
||||
# 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
|
||||
# every .js file under /workspace/ is treated as ESM without needing
|
||||
# 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.
|
||||
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
|
||||
|
||||
# Optional metadata that shows up in `docker inspect` output — useful
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
!dect-relay-agent/package.json
|
||||
!dect-relay-agent/index.js
|
||||
!integrations/cisco-dect/**
|
||||
!integrations/cisco-mpp-phone/**
|
||||
!utils/httpDigestAuth.js
|
||||
|
||||
# ─── Never-ship, even inside whitelisted trees ──────────────────────
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
- DECT admin credentials NEVER leave this agent. The bot only knows the WSS bearer token.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 ────────────────────────────────────────
|
||||
if [[ -n "$TAG_OVERRIDE" ]]; then
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ import {
|
|||
summarizeBaseHealth,
|
||||
} from '../integrations/cisco-dect/statusXml.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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -47,10 +51,13 @@ const CFG = {
|
|||
dectUser: process.env.DECT_ADMIN_USER || 'admin',
|
||||
dectPass: process.env.DECT_ADMIN_PASSWORD,
|
||||
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,
|
||||
};
|
||||
|
||||
const AGENT_VERSION = '0.2.0';
|
||||
const AGENT_VERSION = '0.3.0';
|
||||
const CAPABILITIES = [
|
||||
'collect',
|
||||
'collect-raw',
|
||||
|
|
@ -60,6 +67,8 @@ const CAPABILITIES = [
|
|||
'force-reboot-chain',
|
||||
'factory-reset',
|
||||
'reconfigure-tree',
|
||||
'phone-probe',
|
||||
'phone-probe-raw',
|
||||
];
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
|
@ -219,7 +228,15 @@ async function handleMessage(raw) {
|
|||
replyError(msg.id, 'MALFORMED', 'command frame missing `type`');
|
||||
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`');
|
||||
return;
|
||||
}
|
||||
|
|
@ -230,7 +247,8 @@ async function handleMessage(raw) {
|
|||
replyOk(msg.id, result, Date.now() - started);
|
||||
} catch (err) {
|
||||
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] });
|
||||
}
|
||||
}
|
||||
|
|
@ -241,6 +259,35 @@ async function handleMessage(raw) {
|
|||
* field of the reply frame.
|
||||
*/
|
||||
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({
|
||||
host: cmd.baseIp,
|
||||
user: CFG.dectUser,
|
||||
|
|
|
|||
281
docs/mpp-phone-data-inventory.md
Normal file
281
docs/mpp-phone-data-inventory.md
Normal 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 1–4, headset, speaker, mute, MWI cadence/color (**Line 1 Green** = registered) |
|
||||
| **Ext 1–4 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 1–4)
|
||||
|
||||
| Field | Ext 1 (782) | Ext 2–4 |
|
||||
|-------|-------------|---------|
|
||||
| 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** | 1–3 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.*
|
||||
44
index.js
44
index.js
|
|
@ -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) => {
|
||||
const result = await getShapedDeviceData(req.params.storeNumber, req.params.identifier);
|
||||
res.json(result);
|
||||
|
|
|
|||
87
integrations/cisco-mpp-phone/aggregateProbe.js
Normal file
87
integrations/cisco-mpp-phone/aggregateProbe.js
Normal 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;
|
||||
}
|
||||
}
|
||||
123
integrations/cisco-mpp-phone/client.js
Normal file
123
integrations/cisco-mpp-phone/client.js
Normal 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,
|
||||
};
|
||||
}
|
||||
90
integrations/cisco-mpp-phone/downloadStatusJson.js
Normal file
90
integrations/cisco-mpp-phone/downloadStatusJson.js
Normal 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;
|
||||
}
|
||||
35
integrations/cisco-mpp-phone/jsonRows.js
Normal file
35
integrations/cisco-mpp-phone/jsonRows.js
Normal 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);
|
||||
}
|
||||
54
integrations/cisco-mpp-phone/nsJson.js
Normal file
54
integrations/cisco-mpp-phone/nsJson.js
Normal 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,
|
||||
};
|
||||
}
|
||||
183
integrations/cisco-mpp-phone/probes.js
Normal file
183
integrations/cisco-mpp-phone/probes.js
Normal 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 });
|
||||
}
|
||||
184
integrations/cisco-mpp-phone/statusJson.js
Normal file
184
integrations/cisco-mpp-phone/statusJson.js
Normal 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(':');
|
||||
}
|
||||
265
integrations/cisco-mpp-phone/statusXml.js
Normal file
265
integrations/cisco-mpp-phone/statusXml.js
Normal 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',
|
||||
]);
|
||||
}
|
||||
24
integrations/cisco-mpp-phone/systemJson.js
Normal file
24
integrations/cisco-mpp-phone/systemJson.js
Normal 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,
|
||||
};
|
||||
}
|
||||
75
scripts/lib/botPhoneApi.js
Normal file
75
scripts/lib/botPhoneApi.js
Normal 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}`);
|
||||
}
|
||||
|
|
@ -380,6 +380,16 @@ export class DectRelayHub {
|
|||
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
|
||||
* exposes (reboot / force-reboot / reboot-chain / force-reboot-chain
|
||||
|
|
|
|||
180
services/phoneCollectorService.js
Normal file
180
services/phoneCollectorService.js
Normal 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
185
services/phoneDiscovery.js
Normal 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',
|
||||
};
|
||||
}
|
||||
137
services/phoneStatus/capturePhoneProbe.js
Normal file
137
services/phoneStatus/capturePhoneProbe.js
Normal 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 2–4 digit store number');
|
||||
err.code = 'INVALID_STORE';
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||
const err = new Error('DECT_RELAY_AGENT_TOKEN is not configured on this bot');
|
||||
err.code = 'RELAY_NOT_CONFIGURED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
204
services/renderers/mppPhoneDiagnosticsRenderer.js
Normal file
204
services/renderers/mppPhoneDiagnosticsRenderer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time.
|
|||
* that a follow-up message with base-station diagnostics is on the
|
||||
* way. Chat handler passes this after the base count comes back
|
||||
* 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]
|
||||
* When true, emits a "⏳ WAN metrics loading…" line just above the
|
||||
* footer. Same rationale as `dectFollowUpBaseCount` — chat handler
|
||||
|
|
@ -49,6 +51,7 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
|||
detailed = false,
|
||||
footer = true,
|
||||
dectFollowUpBaseCount = 0,
|
||||
mppFollowUpPhoneCount = 0,
|
||||
wanFollowUpEnabled = false,
|
||||
} = opts;
|
||||
|
||||
|
|
@ -138,6 +141,11 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
|||
}
|
||||
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
|
||||
|
|
|
|||
25
tests/ciscoMppPhone.downloadStatus.test.js
Normal file
25
tests/ciscoMppPhone.downloadStatus.test.js
Normal 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);
|
||||
});
|
||||
23
tests/ciscoMppPhone.nsJson.test.js
Normal file
23
tests/ciscoMppPhone.nsJson.test.js
Normal 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);
|
||||
});
|
||||
31
tests/ciscoMppPhone.statusJson.test.js
Normal file
31
tests/ciscoMppPhone.statusJson.test.js
Normal 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);
|
||||
});
|
||||
17
tests/ciscoMppPhone.systemJson.test.js
Normal file
17
tests/ciscoMppPhone.systemJson.test.js
Normal 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
165
tests/ciscoMppPhone.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -349,3 +349,27 @@ test('hub: execAction routes action name into type field', async () => {
|
|||
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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
34
tests/fixtures/mpp/download-status-782.json
vendored
Normal file
34
tests/fixtures/mpp/download-status-782.json
vendored
Normal 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
35
tests/fixtures/mpp/ns-782.json
vendored
Normal 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
745
tests/fixtures/mpp/status-782-live.json
vendored
Normal 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
|
||||
}]
|
||||
26
tests/fixtures/mpp/status-782-sample.xml
vendored
Normal file
26
tests/fixtures/mpp/status-782-sample.xml
vendored
Normal 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
29
tests/fixtures/mpp/system-782.json
vendored
Normal 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
|
||||
}]
|
||||
73
tests/mppPhoneDiagnosticsRenderer.test.js
Normal file
73
tests/mppPhoneDiagnosticsRenderer.test.js
Normal 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)));
|
||||
});
|
||||
103
tests/phoneDiscovery.test.js
Normal file
103
tests/phoneDiscovery.test.js
Normal 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);
|
||||
});
|
||||
|
|
@ -292,6 +292,18 @@ test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default
|
|||
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)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue