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>
265 lines
9 KiB
JavaScript
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',
|
|
]);
|
|
}
|