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