collabSupport/services/phoneDiscovery.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

185 lines
4.7 KiB
JavaScript

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