// 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:
]*>([\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 }
| |