// diagnostic-normalizer.js — Normalizes vendor-specific diagnostic data into a unified model // Supports: TP-Link XX230v ($.dm), Raisecom BOA (ASP), Raisecom PHP 'use strict' // TP-Link MAC OUI prefixes (first 3 octets, uppercase with colons) const TPLINK_OUIS = new Set([ '30:DE:4B', '98:25:4A', 'E8:48:B8', '50:C7:BF', 'A8:42:A1', '60:32:B1', 'B0:BE:76', 'C0:06:C3', 'F4:F2:6D', 'D8:07:B6', '6C:5A:B0', '14:EB:B6', 'A8:29:48', '78:20:51', '3C:64:CF', '0C:EF:15', 'E4:FA:C4', 'E8:9C:25', '5C:A6:E6', '54:AF:97', '1C:3B:F3', 'B4:B0:24', 'EC:41:18', '68:FF:7B', 'AC:15:A2', '88:C3:97', '34:60:F9', '98:DA:C4', '40:ED:00', 'CC:32:E5', ]) const MESH_HOSTNAME_PATTERNS = [ { re: /deco\s*m4/i, model: 'Deco M4' }, { re: /deco\s*m5/i, model: 'Deco M5' }, { re: /deco\s*x20/i, model: 'Deco X20' }, { re: /deco\s*x50/i, model: 'Deco X50' }, { re: /deco/i, model: 'Deco' }, { re: /hx220/i, model: 'HX220' }, { re: /hx230/i, model: 'HX230' }, { re: /tp-?link/i, model: 'TP-Link' }, { re: /extender/i, model: 'Extender' }, { re: /repeater/i, model: 'Repeater' }, ] function macOui(mac) { if (!mac) return '' return mac.replace(/[-]/g, ':').toUpperCase().substring(0, 8) } /** * Detect wired mesh repeaters from DHCP leases + WiFi client list. * A host is wired if it's NOT in the wifiClients list (by MAC). */ function detectWiredEquipment(dhcpLeases, wifiClientMacs) { const wifiSet = new Set((wifiClientMacs || []).map(m => m.toUpperCase().replace(/[-]/g, ':'))) const equipment = [] for (const lease of dhcpLeases) { const mac = (lease.mac || '').toUpperCase().replace(/[-]/g, ':') const hostname = (lease.hostname || '').toLowerCase() const oui = macOui(mac) const isWired = !wifiSet.has(mac) lease.isWired = isWired let match = null let matchReason = null // Check hostname patterns (most reliable) for (const p of MESH_HOSTNAME_PATTERNS) { if (p.re.test(hostname) || p.re.test(lease.hostname || '')) { match = { model: p.model, type: 'mesh_repeater' } matchReason = `hostname:${p.re.source}` break } } // Fallback: TP-Link OUI on a wired connection (probable mesh node) if (!match && isWired && TPLINK_OUIS.has(oui)) { match = { model: 'TP-Link device', type: 'possible_mesh_repeater' } matchReason = `oui:${oui}` } if (match) { lease.isMeshRepeater = true equipment.push({ hostname: lease.hostname || '', mac: lease.mac, ip: lease.ip, port: lease.interface || null, model: match.model, type: match.type, matchReason, }) } else { lease.isMeshRepeater = false } } return equipment } // --- Raisecom BOA normalizer --- /** * Parse Raisecom BOA JavaScript data arrays from page HTML. * Raisecom BOA embeds data as: var arr = []; arr.push(new it_nr("idx", new it("key", val), ...)) * Returns an array of objects with key-value pairs. */ function parseBoaArray(html, varName) { const items = [] // Match full push line: varName.push(new it_nr("idx", new it("k1", v1), new it("k2", v2))); // Use line-based matching to avoid nested parenthesis issues const pushRe = new RegExp(varName + '\\.push\\(new it_nr\\((.+?)\\)\\);', 'g') let m while ((m = pushRe.exec(html)) !== null) { const line = m[1] const obj = {} // Extract new it("key", value) pairs from the line const itRe = /new it\("(\w+)",\s*("(?:[^"\\]|\\.)*"|[^,)]+)\)/g let im while ((im = itRe.exec(line)) !== null) { let val = im[2].trim() if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1) else if (!isNaN(val) && val !== '') val = Number(val) obj[im[1]] = val } if (Object.keys(obj).length > 0) items.push(obj) } return items } /** * Parse Raisecom BOA key-value tables from HTML. * Structure: LabelValue */ function parseBoaKvTable(html) { const kv = {} // Collect KV pairs from ALL labelvalue patterns across the page const rowRe = /]*>([\s\S]*?)<\/tr>/gi let rm while ((rm = rowRe.exec(html)) !== null) { const cells = [] const cellRe = /]*>([\s\S]*?)<\/td>/gi let cm while ((cm = cellRe.exec(rm[1])) !== null) { cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim()) } if (cells.length >= 2 && cells[0] && cells[1]) { kv[cells[0]] = cells[1] } } return kv } function normalizeRaisecomBoa(pages) { const result = { modemType: 'raisecom_boa', online: null, wanIPs: [], ethernetPorts: [], dhcpLeases: [], wiredEquipment: [], radios: [], meshNodes: [], // Raisecom has no EasyMesh wifiClients: [], issues: [], device: {}, gpon: {}, } // --- Device basic info --- if (pages.deviceInfo) { const kv = parseBoaKvTable(pages.deviceInfo) result.device = { model: kv['Device Model'] || '', serial: kv['Device Serial Number'] || '', gponSn: kv['GPON SN'] || '', hardware: kv['Hardware Version'] || '', firmware: kv['Software Version'] || '', versionDate: kv['Version Date'] || '', cpu: kv['CPU Usage'] || '', memory: kv['Memory Usage'] || '', uptime: kv['UP Times'] || '', } } // --- GPON optical info --- if (pages.gpon) { const kv = parseBoaKvTable(pages.gpon) result.gpon = { linkState: kv['Link State'] || '', sn: kv['GPONSN'] || '', txPower: kv['Tx Power'] || '', rxPower: kv['Rx Power'] || '', temperature: kv['Temperature'] || '', voltage: kv['Voltage'] || '', biasCurrent: kv['Bias Current'] || '', } // Check link state const linkOk = result.gpon.linkState && result.gpon.linkState.includes('O5') result.online = { ipv4: linkOk, ipv6: false, uptimeV4: 0 } } // --- WAN interfaces --- if (pages.wan) { const links = parseBoaArray(pages.wan, 'links') for (const l of links) { const ip = l.ipAddr || '' const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management' : ip.startsWith('10.') ? 'service' : ip.startsWith('192.168.') ? 'lan' : (!ip.startsWith('169.254.') && ip) ? 'internet' : 'unknown' result.wanIPs.push({ ip, mask: '', type: l.protocol || 'IPoE', role, vlanId: l.vlanId, name: l.servName || '', status: l.strStatus || '', gateway: l.gw || '', dns: l.DNS || '', }) } // Set online based on WAN status const hasUpWan = links.some(l => l.strStatus === 'up' && l.ipAddr && !l.ipAddr.startsWith('10.') && !l.ipAddr.startsWith('172.')) if (result.online) result.online.ipv4 = hasUpWan || result.online.ipv4 else result.online = { ipv4: hasUpWan, ipv6: false, uptimeV4: 0 } } // --- Ethernet ports + connected clients --- if (pages.ethernet) { const ethers = parseBoaArray(pages.ethernet, 'ethers') const clients = parseBoaArray(pages.ethernet, 'clts') for (const e of ethers) { const portNum = parseInt(e.ifname?.replace(/\D/g, '') || '0') const hasTraffic = (e.rx_packets || 0) > 0 || (e.tx_packets || 0) > 0 result.ethernetPorts.push({ port: portNum, label: e.ifname || `Port ${portNum}`, status: hasTraffic ? 'Up' : 'Down', speed: null, duplex: null, stats: { rxPackets: e.rx_packets || 0, rxBytes: e.rx_bytes || 0, rxErrors: e.rx_errors || 0, rxDropped: e.rx_dropped || 0, txPackets: e.tx_packets || 0, txBytes: e.tx_bytes || 0, txErrors: e.tx_errors || 0, txDropped: e.tx_dropped || 0, }, connectedDevice: null, // Will be cross-referenced with DHCP }) } // DHCP clients from Ethernet info page for (const c of clients) { result.dhcpLeases.push({ hostname: c.devname || '', ip: c.ipAddr || '', mac: c.macAddr || '', expiry: c.liveTime ? parseInt(c.liveTime) : null, interface: null, isWired: false, isMeshRepeater: false, }) } } // --- WiFi clients --- if (pages.wlan) { const wlanClients = parseBoaArray(pages.wlan, 'clts') // Same var name // Also check for other var names const wlanClients2 = parseBoaArray(pages.wlan, 'clients') const allWlan = [...wlanClients, ...wlanClients2] for (const c of allWlan) { result.wifiClients.push({ mac: c.macAddr || c.MACAddress || '', hostname: '', ip: '', signal: c.rssi ? parseInt(c.rssi) : null, band: c.bandWidth ? `${c.bandWidth}MHz` : '', standard: '', active: c.linkState === 'Assoc' || c.linkState === 'associated' || true, linkDown: c.currRxRate ? parseInt(c.currRxRate) * 1000 : 0, linkUp: c.currTxRate ? parseInt(c.currTxRate) * 1000 : 0, lossPercent: 0, meshNode: '', }) } // Extract radio info from the wlan page too const radios = parseBoaArray(pages.wlan, 'ethers') // Sometimes reused var name // Fallback: parse KV from the wlan basic config if available if (pages.wlanConfig) { const kv = parseBoaKvTable(pages.wlanConfig) if (kv['Channel'] || kv['Band']) { result.radios.push({ band: kv['Band'] || '2.4GHz', channel: parseInt(kv['Channel']) || 0, bandwidth: kv['Channel Width'] || kv['Bandwidth'] || '', standard: kv['Mode'] || '', txPower: 0, autoChannel: false, status: 'Up', }) } } } // Detect wired equipment (mesh repeaters) const wifiMacs = result.wifiClients.map(c => c.mac) result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, wifiMacs) // Cross-reference Ethernet ports with DHCP clients // (BOA doesn't tell us which port each client is on, but we flag wired devices) return result } // --- Raisecom PHP (WS2) normalizer --- // WS2 has a single dashboard page with HTML tables (not JS arrays like BOA) function parseHtmlTables(html) { const tables = [] const tableRe = /]*>([\s\S]*?)<\/table>/gi let m while ((m = tableRe.exec(html)) !== null) { const rows = [] const rowRe = /]*>([\s\S]*?)<\/tr>/gi let rm while ((rm = rowRe.exec(m[1])) !== null) { const cells = [] const cellRe = /]*>([\s\S]*?)<\/t[dh]>/gi let cm while ((cm = cellRe.exec(rm[1])) !== null) { cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()) } if (cells.length >= 2) rows.push(cells) } if (rows.length > 0) tables.push(rows) } return tables } function normalizeRaisecomPhp(pages) { const result = { modemType: 'raisecom_php', online: null, wanIPs: [], ethernetPorts: [], dhcpLeases: [], wiredEquipment: [], radios: [], meshNodes: [], wifiClients: [], issues: [], device: {}, gpon: {}, } // WS2 returns the same dashboard for all ?parts= params // Use deviceInfo page (or any, they're the same) const html = pages.deviceInfo || pages.wan || pages.ethernet || '' if (!html) return result const tables = parseHtmlTables(html) for (const rows of tables) { // Device info table: 2-column KV pairs (label | value) if (rows.length >= 5 && rows.some(r => r[0] === 'Device Model')) { for (const [k, v] of rows) { if (k === 'Device Model') result.device.model = v if (k === 'Device Serial Number') result.device.serial = v if (k === 'Hardware Version') result.device.hardware = v if (k === 'Software Version') result.device.firmware = v if (k === 'Sub Version') result.device.subVersion = v if (k === 'System Up Time') result.device.uptime = v if (k === 'System Name') result.device.name = v } } // CPU table: "CPU Usage:28% | Time:..." if (rows.length === 1 && rows[0][0]?.includes('CPU Usage')) { const cpuMatch = rows[0][0].match(/CPU Usage:\s*([\d.]+)%/) if (cpuMatch) result.device.cpu = cpuMatch[1] + '%' } // Memory table if (rows.length === 1 && rows[0][0]?.includes('Memory Usage')) { const memMatch = rows[0][0].match(/Memory Usage:\s*([\d.]+)%/) if (memMatch) result.device.memory = memMatch[1] + '%' } // WAN connections table: header row + data rows if (rows.length >= 2 && rows[0].some(h => h === 'Connection Name')) { const headers = rows[0] for (let i = 1; i < rows.length; i++) { const row = rows[i] const obj = {} headers.forEach((h, idx) => { if (row[idx]) obj[h] = row[idx] }) const ip = (obj['IP Address/Net Mask Length'] || '').split('/')[0] || '' const mask = (obj['IP Address/Net Mask Length'] || '').split('/')[1] || '' const status = obj['Link Status'] || '' const name = obj['Connection Name'] || '' if (ip && ip !== '---') { const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management' : ip.startsWith('10.') ? 'service' : ip.startsWith('192.168.') ? 'lan' : !ip.startsWith('169.254.') ? 'internet' : 'unknown' result.wanIPs.push({ ip, mask, type: obj['Address Assign Mode'] || '', role, name, status, gateway: obj['Gate Address'] || '', dns: obj['DNS'] || '', rxRate: obj['Receiving Rate(Kbits/s)'] || '', txRate: obj['Sending Rate(Kbits/s)'] || '', }) } if (status === 'UP') { result.online = result.online || { ipv4: false, ipv6: false, uptimeV4: 0 } if (name.includes('Internet')) result.online.ipv4 = true } } } // WiFi table: "SSID | Service Status | connected count (下挂PC数量)" // Note: WS2 tables sometimes have header in but data rows without opening tags if (rows.length >= 1 && rows[0].some(h => h === 'SSID(Wireless Network Name)' || h === 'SSID')) { for (let i = 1; i < rows.length; i++) { if (rows[i][0] && rows[i][1]) { const ssid = rows[i][0] const statusRaw = rows[i][1] const isOn = /^(Enable|UP|ON)$/i.test(statusRaw) const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz' result.radios.push({ band, channel: 0, bandwidth: '', standard: '', txPower: 0, autoChannel: false, status: isOn ? 'Up' : 'Down', ssid, }) } } } // Ethernet port table: rows of [LANx, UP/DOWN] — either one row with all ports or multiple rows if (rows.length >= 1 && rows.some(r => /^LAN\d$/i.test(r[0]))) { for (const row of rows) { // Each row is either [LANx, UP/DOWN] or a flat row [LAN1, UP, LAN2, DOWN, ...] if (/^LAN\d$/i.test(row[0]) && row.length === 2) { result.ethernetPorts.push({ port: parseInt(row[0].replace(/\D/g, '')), label: row[0], status: row[1] === 'UP' ? 'Up' : 'Down', speed: null, duplex: null, stats: null, connectedDevice: null, }) } else { // Flat format: LAN1 UP LAN2 DOWN ... for (let i = 0; i < row.length - 1; i += 2) { if (/^LAN\d$/i.test(row[i])) { result.ethernetPorts.push({ port: parseInt(row[i].replace(/\D/g, '')), label: row[i], status: row[i + 1] === 'UP' ? 'Up' : 'Down', speed: null, duplex: null, stats: null, connectedDevice: null, }) } } } } } } // --- Fallback: direct HTML regex for WS2 malformed tables --- // WS2 rows often lack opening tags, so parseHtmlTables misses them // WiFi SSIDs: extract from SSID + ON/OFF pairs if (result.radios.length === 0 && html.includes('SSID(Wireless Network Name)')) { const ssidRe = /]*>\s*]*>([^<]+)<\/span>\s*<\/td>\s*]*>\s*]*>(ON|OFF|UP|DOWN|Enable|Disable)<\/span>/gi let sm while ((sm = ssidRe.exec(html)) !== null) { const ssid = sm[1].trim() const isOn = /^(ON|UP|Enable)$/i.test(sm[2]) const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz' result.radios.push({ band, channel: 0, bandwidth: '', standard: '', txPower: 0, autoChannel: false, status: isOn ? 'Up' : 'Down', ssid, }) } } // Ethernet ports: extract LAN1-4 status from LANx...UP/DOWN pairs if (result.ethernetPorts.length === 0 && html.includes('LAN1')) { const lanRe = /]*>(LAN\d)<\/span>[\s\S]*?]*>(UP|DOWN)<\/span>/gi let lm while ((lm = lanRe.exec(html)) !== null) { result.ethernetPorts.push({ port: parseInt(lm[1].replace(/\D/g, '')), label: lm[1], status: lm[2] === 'UP' ? 'Up' : 'Down', speed: null, duplex: null, stats: null, connectedDevice: null, }) } } // If no online status determined, check if any WAN is UP if (!result.online && result.wanIPs.some(w => w.status === 'UP')) { result.online = { ipv4: true, ipv6: false, uptimeV4: 0 } } return result } // --- TP-Link normalizer --- function normalizeTplink(raw) { // Raw data from fullDiagnostic() — keys match the Promise.allSettled order const result = { modemType: 'tplink_xx230v', online: null, wanIPs: [], ethernetPorts: [], dhcpLeases: [], wiredEquipment: [], radios: [], meshNodes: [], wifiClients: [], issues: [], } // Online status if (raw.onlineStatus && !raw.onlineStatus.error) { result.online = { ipv4: raw.onlineStatus.onlineStatusV4 === 'online', ipv6: raw.onlineStatus.onlineStatusV6 === 'online', uptimeV4: parseInt(raw.onlineStatus.onlineTimeV4) || 0, } } // WAN IPs if (Array.isArray(raw.wanIPs)) { for (const w of raw.wanIPs) { if (!w.IPAddress || w.IPAddress === '0.0.0.0') continue if (w.status === 'Disabled') continue const ip = w.IPAddress const role = ip.startsWith('192.168.') ? 'lan' : ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management' : ip.startsWith('10.') ? 'service' : !ip.startsWith('169.254.') ? 'internet' : 'unknown' result.wanIPs.push({ ip, mask: w.subnetMask || '', type: w.addressingType || '', role, }) } } // Radios if (Array.isArray(raw.radios)) { for (const r of raw.radios) { result.radios.push({ band: r.operatingFrequencyBand || '', channel: parseInt(r.channel) || 0, bandwidth: r.currentOperatingChannelBandwidth || '', standard: r.operatingStandards || '', txPower: parseInt(r.transmitPower) || 0, autoChannel: r.autoChannelEnable === '1', status: r.status || '', }) } } // Mesh nodes if (Array.isArray(raw.meshNodes)) { for (const n of raw.meshNodes) { result.meshNodes.push({ hostname: n.X_TP_HostName || 'unknown', model: n.X_TP_ModelName || '', mac: n.MACAddress || '', ip: n.X_TP_IPAddress || '', active: n.X_TP_Active === '1', isController: n.X_TP_IsController === '1', cpu: parseInt(n.X_TP_CPUUsage) || 0, uptime: parseInt(n.X_TP_UpTime) || 0, firmware: n.softwareVersion || '', backhaul: { type: n.backhaulLinkType || 'Ethernet', signal: parseInt(n.backhaulSignalStrength) || 0, utilization: parseInt(n.backhaulLinkUtilization) || 0, linkRate: parseInt(n.X_TP_LinkRate) || 0, }, speedUp: parseInt(n.X_TP_UpSpeed) || 0, speedDown: parseInt(n.X_TP_DownSpeed) || 0, }) } } // WiFi clients const clientMap = new Map() if (Array.isArray(raw.clients)) { for (const c of raw.clients) { clientMap.set(c.MACAddress, { mac: c.MACAddress || '', hostname: c.X_TP_HostName || '', ip: c.X_TP_IPAddress || '', signal: parseInt(c.signalStrength) || 0, band: '', standard: c.operatingStandard || '', active: c.active === '1', linkDown: parseInt(c.lastDataDownlinkRate) || 0, linkUp: parseInt(c.lastDataUplinkRate) || 0, lossPercent: 0, meshNode: '', apMac: c.X_TP_ApDeviceMac || '', radioMac: c.X_TP_RadioMac || '', }) } } if (Array.isArray(raw.clientStats)) { for (const s of raw.clientStats) { const existing = clientMap.get(s.MACAddress) if (existing) { existing.retrans = parseInt(s.retransCount) || 0 existing.packetsSent = parseInt(s.packetsSent) || 0 } } } // Compute loss for (const c of clientMap.values()) { if (c.packetsSent > 100 && c.retrans > 0) { c.lossPercent = Math.round((c.retrans / c.packetsSent) * 1000) / 10 } } result.wifiClients = [...clientMap.values()] // Ethernet ports (from DEV2_ETHERNET_IF if available) if (Array.isArray(raw.ethernetIfs)) { for (let i = 0; i < raw.ethernetIfs.length; i++) { const e = raw.ethernetIfs[i] result.ethernetPorts.push({ port: i + 1, label: e.alias || e.name || `Port ${i + 1}`, status: e.status || 'Down', speed: parseInt(e.maxBitRate || e.currentBitRate) || null, duplex: e.duplexMode || null, stats: null, connectedDevice: null, }) } } // DHCP hosts (from DEV2_HOSTS_HOST if available) if (Array.isArray(raw.hosts)) { const wifiMacs = new Set([...clientMap.keys()].map(m => m.toUpperCase())) for (const h of raw.hosts) { const mac = (h.PhysAddress || h.physAddress || '').toUpperCase() result.dhcpLeases.push({ hostname: h.HostName || h.hostName || '', ip: h.IPAddress || h.ipAddress || '', mac: h.PhysAddress || h.physAddress || '', expiry: h.LeaseTimeRemaining != null ? parseInt(h.LeaseTimeRemaining) : null, interface: h.Layer1Interface || '', isWired: !wifiMacs.has(mac), isMeshRepeater: false, }) } result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, [...clientMap.keys()]) } return result } module.exports = { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink, detectWiredEquipment, parseBoaArray, parseBoaKvTable }