collabSupport/integrations/cisco-mpp-phone/statusXml.js
jmcqueen f7953b8eb5 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>
2026-07-28 18:01:01 -04:00

265 lines
9 KiB
JavaScript

// 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',
]);
}