// src/integrations/cisco-dect/statusXml.js // // Parser + normalizer for the Cisco DBS-210 DECT base station's // `/admin/status.xml` endpoint. Pure — takes a raw XML string, // returns a structured object. No axios, no I/O, no side-effects. // // Why hand-roll instead of pulling in fast-xml-parser? The DBS-210's // status XML schema is trivially flat: // - No attributes anywhere // - No CDATA, no comments, no mixed content // - Two levels of nesting at most (Status → Section → leaves; // Section → Sub-object → leaves) // - No repeated same-name siblings (Reboot_Line_1..6 are distinct // tags, not arrays) // A specialized 60-line reader is safer than dragging in a generic // XML parser we'd then need to keep pinned on the eventual store-side // relay agent (which we want to stay tiny). // // The output shape is deliberately camelCased and grouped for // consumers — it's NOT a faithful reflection of the XML tag names. // That's on purpose: the parser is the moment where we absorb the // Cisco tag naming quirks so nothing else in the codebase has to. // ─── Low-level: XML → plain JS object ─────────────────────────────── /** * Parse a very-simple XML string (single root, no attributes, no * CDATA, no comments, no mixed content) into a plain JS object. * Leaf elements become string values; parent elements become nested * objects keyed by tag name. * * Whitespace between tags is discarded. Whitespace inside leaf text * is preserved as-is (the DBS-210 uses meaningful spacing in some * fields, e.g. `RFPI_Address` = "13508C9C; RPN:00"). * * Throws on malformed input rather than silently coercing — callers * catch and log so a firmware change that breaks the schema shows up * loudly instead of producing a mysteriously-empty object. */ export function xmlToObject(xml) { if (typeof xml !== 'string') { throw new TypeError('xmlToObject: expected a string'); } // Strip XML declaration if present. const cleaned = xml.replace(/^\uFEFF/, '').replace(/<\?xml[^?]*\?>/, '').trim(); if (!cleaned) throw new Error('xmlToObject: empty input'); let pos = 0; function skipWs() { while (pos < cleaned.length && /\s/.test(cleaned[pos])) pos++; } function readOpenTag() { // Assumes cleaned[pos] === '<'. if (cleaned[pos] !== '<') { throw new Error(`xmlToObject: expected '<' at ${pos}`); } const end = cleaned.indexOf('>', pos); if (end < 0) throw new Error(`xmlToObject: unterminated tag at ${pos}`); const raw = cleaned.slice(pos + 1, end); pos = end + 1; const selfClosing = raw.endsWith('/'); const inner = selfClosing ? raw.slice(0, -1).trim() : raw.trim(); // Tag name = up to first whitespace. Attributes (if any) are // ignored — DBS-210 XML doesn't use them and swallowing them // silently keeps us robust to future additions. const nameMatch = inner.match(/^([A-Za-z_][\w.-]*)/); if (!nameMatch) throw new Error(`xmlToObject: bad tag name near ${pos}: ${inner}`); return { name: nameMatch[1], selfClosing }; } function parseElement() { const { name, selfClosing } = readOpenTag(); if (selfClosing) return { name, value: '' }; // Look ahead: if the next non-whitespace char is '<' AND it's not // an immediate close of THIS tag, treat body as child elements. // Otherwise treat body as leaf text up to . const savedPos = pos; skipWs(); const closeTag = ``; const nextOpen = cleaned.indexOf('<', pos); if (nextOpen === pos && !cleaned.startsWith(closeTag, pos)) { // Container element with child elements. const children = {}; while (true) { skipWs(); if (cleaned.startsWith(closeTag, pos)) { pos += closeTag.length; return { name, value: children }; } if (pos >= cleaned.length) { throw new Error(`xmlToObject: EOF while reading children of <${name}>`); } const child = parseElement(); children[child.name] = child.value; } } // Leaf text (may be empty or whitespace-only). pos = savedPos; const closeIdx = cleaned.indexOf(closeTag, pos); if (closeIdx < 0) throw new Error(`xmlToObject: missing `); const rawText = cleaned.slice(pos, closeIdx); pos = closeIdx + closeTag.length; return { name, value: decodeEntities(rawText.trim()) }; } const root = parseElement(); return { [root.name]: root.value }; } function decodeEntities(s) { return s .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) .replace(/&/g, '&'); // must run last } // ─── High-level: raw parsed tree → structured status object ───────── /** * Parse the two known-shape reboot line formats: * "2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09" * "2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09" * * Returns null for unrecognized shapes so callers can pass the raw * string through instead of dropping the entry. */ export function parseRebootLine(line) { if (typeof line !== 'string' || !line.trim()) return null; const m = line.match( /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+\((\d+)\)\s+(.+?)\s+\((\d+)\)\s+Firmware Version\s+(\S+)\s*$/ ); if (!m) return { raw: line, unrecognized: true }; const [, date, time, seq, reasonName, reasonCode, firmware] = m; return { at: `${date}T${time}`, // ISO-ish local time (device doesn't include a TZ) sequence: Number(seq), reasonName: reasonName.trim(), reasonCode: Number(reasonCode), firmwareAtBoot: firmware, raw: line, }; } /** * Take raw /admin/status.xml text and return a normalized, grouped * status object. Missing sections come back as `null` (or empty * collections where an array/object shape is expected) rather than * throwing — the DBS-210 sometimes omits sections depending on * multi-cell role, and callers should be able to reason about * partial data. * * @param {string} xml * @returns {object} structured status */ export function parseStatusXml(xml) { const tree = xmlToObject(xml); const root = tree.Status || {}; const sys = (typeof root === 'object' && root.System_Information) || {}; const stats = (typeof root === 'object' && root.Statistics) || {}; const rebootLog = collectRebootLog(sys.Reboot_Log); const rtp = sys.RTP_Usage || {}; const netStats = stats.Network_Statistics || {}; const emergency = extractEmergencyNumbers(sys.Emergency_Calls); const deviceFwu = extractDeviceFwu(sys.Device_FWU_Info); const rssi = extractRssiList(sys.RSSI_List); const devicePresence = extractDevicePresence(sys.Device_Presence); const sipIdentityStatus = extractSipIdentityStatus(sys.SIP_Identity_Status); const devices = extractDeviceInformation(root); const perRpnStats = extractPerRpnStatistics(stats); return { device: { model: str(sys.Phone_Type), // "IPDECT-V2 (DBS-210-3PC)" systemType: str(sys.System_Type), // "Generic SIP (RFC 3261)" unitName: str(sys.Unit_Name), // "SME VoIP" unitIndex: str(sys.Unit_Index), // "Base Idx:0" rfBand: str(sys.RF_Band), // "US" productConfiguration: str(sys.Product_Configuration), macAddress: normalizeMac(str(sys.MAC_Address)), ipAddress: str(sys.IP_Address), rfpiAddress: str(sys.RFPI_Address), // "13508C9C; RPN:00" releasedBuild: yesNo(sys.Released_Build), }, firmware: { version: str(sys.Firmware_Version), // "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29" updateServer: str(sys.Firmware_URL?.Update_Server_Address), updatePath: str(sys.Firmware_URL?.Path), requiredFor: deviceFwu, }, time: { currentLocalTime: str(sys.Current_Local_Time), operatingTime: str(sys.Operating_Time), operatingTimeSeconds: parseOperatingSeconds(sys.Operating_Time), }, multiCell: parseMultiCell(sys.Multi_Cell), baseStatus: (str(sys.Base_Station_Status) || '').toLowerCase() || null, conflictInfo: str(sys.Conflict_Info), security: { customCa: { provisioningStatus: str(sys.Custom_CA_Status?.Custom_CA_Provisioning_Status), info: str(sys.Custom_CA_Status?.Custom_CA_Info), installed: (str(sys.Custom_CA_Status?.Custom_CA_Info) || '').toLowerCase() !== 'not installed', }, dot1x: { transactionStatus: str(sys.Dot1x_Authentication?.Transaction_status), protocol: str(sys.Dot1x_Authentication?.Protocol), }, }, rebootLog, rtp: { total: numOrNull(rtp.Total_RTP), max: numOrNull(rtp.Max_RTP), current: numOrNull(rtp.Current_RTP), currentLocal: numOrNull(rtp.Current_Local_RTP), currentRelay: numOrNull(rtp.Current_Relay_RTP), remoteRelay: numOrNull(rtp.Remote_Relay_RTP), currentRecording: numOrNull(rtp.Current_Recording), timeInMaxRtp: str(rtp.Time_In_Max_RTP), }, network: { txPackets: numOrNull(netStats.Tx_Packets), txBlocked: numOrNull(netStats.Tx_Blocked), txDropped: numOrNull(netStats.Tx_Dropped), txErrors: numOrNull(netStats.Tx_Errors), txBroadcasts: numOrNull(netStats.Tx_Broadcasts), rxPackets: numOrNull(netStats.Rx_Packets), rxBlocked: numOrNull(netStats.Rx_Blocked), rxDropped: numOrNull(netStats.Rx_Dropped), rxErrors: numOrNull(netStats.Rx_Errors), rxBroadcasts: numOrNull(netStats.Rx_Broadcasts), }, emergencyNumbers: emergency, rssi, devicePresence, sipIdentityStatus, devices, perRpnStats, features: { pushToTalk: (str(sys.Push_To_Talk) || '').toLowerCase() === 'on', }, // The Statistics/Header_Line_Idx tag is a CSV schema descriptor // for companion per-RPN statistics rows (see perRpnStats). statisticsHeader: str(stats.Header_Line_Idx), _rawStatisticsHeader: str(stats.Header_Line_Idx), }; } // ─── Health verdict ───────────────────────────────────────────────── // Cisco reboot reason codes. Not documented publicly — collected // empirically from the DBS-210 sample and from Cisco community posts. // We only classify the ones we've actually observed so a new code // shows up as "unknown" instead of being silently reclassified. const KNOWN_REBOOT_CODES = new Map([ [21, { key: 'normal', severity: 'info', label: 'Normal reboot (admin- or firmware-initiated)' }], [43, { key: 'unexpected', severity: 'warn', label: 'Unexpected reboot' }], [80, { key: 'power-loss', severity: 'warn', label: 'Power loss — mains interruption or PoE glitch' }], ]); /** Reboot reason codes that affect health when seen within the warn window. */ const REBOOT_HEALTH_WARN_CODES = new Set([43, 80]); const REBOOT_HEALTH_WARN_DAYS = 7; function rebootEntryAgeMs(entry) { if (!entry?.at) return null; const t = Date.parse(entry.at); return Number.isFinite(t) ? Date.now() - t : null; } function isRecentRebootConcern(entry, maxAgeMs = REBOOT_HEALTH_WARN_DAYS * 86400000) { if (!REBOOT_HEALTH_WARN_CODES.has(entry?.reasonCode)) return false; const ageMs = rebootEntryAgeMs(entry); if (ageMs == null) return false; return ageMs >= 0 && ageMs <= maxAgeMs; } /** * Compute a pure-function health verdict from a parsed status object. * No I/O. Returns { healthy, warnings, info } where warnings is an * array of user-facing strings. Consumers decide how to render. */ export function summarizeBaseHealth(parsed) { const warnings = []; const info = []; if (!parsed || typeof parsed !== 'object') { return { healthy: false, warnings: ['No status data parsed'], info: [] }; } // Uptime — under 10 minutes = very recent reboot, worth flagging. const uptimeSec = parsed.time?.operatingTimeSeconds; if (Number.isFinite(uptimeSec) && uptimeSec < 600) { warnings.push(`Base rebooted very recently (uptime ${Math.round(uptimeSec / 60)} min)`); } // Reboot log — power-loss or unexpected reboot within the last 7 days. const badReboots = (parsed.rebootLog || []).filter((entry) => isRecentRebootConcern(entry)); if (badReboots.length > 0) { const latest = badReboots[0]; const label = latest.reasonName || `code ${latest.reasonCode}`; warnings.push( `${badReboots.length} concerning reboot(s) in the last ${REBOOT_HEALTH_WARN_DAYS} days ` + `(latest: ${label} at ${latest.at})`, ); } // RF conflict — non-"No Conflict" means DECT interference detected. const conflict = parsed.conflictInfo || ''; if (conflict && conflict.toLowerCase() !== 'no conflict') { warnings.push(`DECT RF conflict reported: ${conflict}`); } // Network drops. Non-zero rx_dropped is the classic "your switch // port is misconfigured / the base is overwhelmed" signal. const rxDropped = parsed.network?.rxDropped; if (Number.isFinite(rxDropped) && rxDropped > 0) { info.push(`Rx dropped packets: ${rxDropped} since last boot`); } const rxErrors = parsed.network?.rxErrors; if (Number.isFinite(rxErrors) && rxErrors > 0) { warnings.push(`Rx errors: ${rxErrors} since last boot`); } const txErrors = parsed.network?.txErrors; if (Number.isFinite(txErrors) && txErrors > 0) { warnings.push(`Tx errors: ${txErrors} since last boot`); } // 802.1X — if enabled (protocol != 'N/A') and status isn't 'Authenticated' // we flag it. The DBS-210 emits 'Unavailable' when 802.1X is off, so // we specifically ignore that state. const dot1xStatus = (parsed.security?.dot1x?.transactionStatus || '').toLowerCase(); const dot1xProto = (parsed.security?.dot1x?.protocol || '').toLowerCase(); if (dot1xProto && dot1xProto !== 'n/a' && dot1xStatus && dot1xStatus !== 'authenticated' && dot1xStatus !== 'unavailable') { warnings.push(`802.1X in state "${parsed.security.dot1x.transactionStatus}" (protocol ${parsed.security.dot1x.protocol})`); } // Custom CA — informational only; some fleets never install one. if (parsed.security?.customCa?.installed) { info.push(`Custom CA installed: ${parsed.security.customCa.info}`); } return { healthy: warnings.length === 0, warnings, info, }; } // ─── Helpers ──────────────────────────────────────────────────────── function str(v) { if (v == null) return null; if (typeof v === 'string') return v; return null; // an object where a string was expected → treat as absent } function numOrNull(v) { if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } function yesNo(v) { if (v == null) return null; return String(v).trim().toLowerCase() === 'yes'; } function normalizeMac(mac) { if (!mac) return null; const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); if (hex.length !== 12) return mac; // not the expected 12-hex form, pass through return hex.match(/../g).join(':'); } /** * Multi_Cell field is e.g.: * "Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary" * "Primary(TXT_STATE_PRIMARY) ..." * "Secondary(TXT_STATE_SECONDARY) ..." * We normalize to a role token + keep the flavor text. */ function parseMultiCell(raw) { if (!raw) return { role: null, state: null, raw: null }; const s = String(raw).trim(); const leadRole = s.match(/^(Primary|Secondary|Unchained)\b/i); if (leadRole) { return { role: leadRole[1].toLowerCase(), state: null, raw: s }; } const leadState = s.match(/^([A-Za-z]+)/); const state = leadState ? leadState[1].toLowerCase() : null; const trailRole = s.match(/\)\s+(Primary|Secondary)\b/i); const role = trailRole ? trailRole[1].toLowerCase() : null; return { role, state: role && state && role !== state ? state : null, raw: s, }; } /** * Operating_Time is formatted as "H:M:S" (e.g. "00:10:20 (H:M:S)"). * Convert to seconds. Returns null if unrecognized. */ function parseOperatingSeconds(v) { if (!v) return null; const s = String(v); const daysMatch = s.match(/(\d+)\s+Days?\s+(\d+):(\d+):(\d+)/i); if (daysMatch) { const [, d, h, mi, sec] = daysMatch; return Number(d) * 86400 + Number(h) * 3600 + Number(mi) * 60 + Number(sec); } const m = s.match(/(\d+):(\d+):(\d+)/); if (!m) return null; const [, h, mi, sec] = m; return Number(h) * 3600 + Number(mi) * 60 + Number(sec); } function collectRebootLog(rebootLogNode) { if (!rebootLogNode || typeof rebootLogNode !== 'object') return []; // Reboot_Line_1..N — sort by their numeric suffix and drop empties. const entries = Object.entries(rebootLogNode) .filter(([k]) => /^Reboot_Line_\d+$/.test(k)) .sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0])) .map(([, v]) => v); return entries .filter((v) => v && typeof v === 'string' && v.trim()) .map(parseRebootLine) .filter(Boolean); } function extractEmergencyNumbers(node) { if (!node || typeof node !== 'object') return []; return Object.entries(node) .filter(([k]) => /^Emergency_Number_\d+$/.test(k)) .sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0])) .map(([, v]) => (typeof v === 'string' ? v.trim() : '')) .filter((v) => v && !/^no number set/i.test(v)); } function extractDeviceFwu(node) { if (!node || typeof node !== 'object') return {}; const out = {}; for (const [k, v] of Object.entries(node)) { if (typeof v !== 'string') continue; // "Base type:DBS-210-3PC - Required Version:501 Required Branch:309" // "Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default" const typeMatch = v.match(/(?:Base type|Device type):([^\s]+)/); const versionMatch = v.match(/Required Version:(\S+)/); const branchMatch = v.match(/Required Branch:(\S+)/); const langMatch = v.match(/Language Pack:(\S+)/); if (typeMatch) { out[typeMatch[1]] = { requiredVersion: versionMatch ? versionMatch[1] : null, requiredBranch: branchMatch ? branchMatch[1] : null, languagePack: langMatch ? langMatch[1] : null, _sourceKey: k, }; } } return out; } function extractRssiList(node) { if (!node || typeof node !== 'object') return []; const out = []; for (const entry of extractKeyedEntries(node, /^RPN_/)) { out.push({ ...parseRssiEntry(entry.key, entry.value), raw: entry.value }); } for (const entry of extractKeyedEntries(node, /^RSSI_Line_/)) { const parsed = parseRssiLineText(entry.value); if (parsed) out.push({ ...parsed, raw: entry.value }); } return out; } /** * Parse RSSI_List child text. Shapes seen in the field: * "MAC:6CAB05A1B2C3; RSSI:-58 dBm" (lab / synthetic) * "RSSI for RPN:4 is -49 [dBm]" (store 933 live capture) */ function parseRssiEntry(key, value) { const rpnMatch = key.match(/^RPN_(\d+)$/i); const rpn = rpnMatch ? rpnMatch[1] : null; const text = String(value || ''); const macMatch = text.match(/MAC:\s*([0-9A-Fa-f:]+)/i); const rssiMatch = text.match(/RSSI:\s*(-?\d+)/i) || text.match(/(-?\d+)\s*dBm/i); return { rpn, mac: macMatch ? normalizeMac(macMatch[1]) : null, rssiDbm: rssiMatch ? Number(rssiMatch[1]) : null, }; } function parseRssiLineText(text) { const m = String(text || '').match(/RPN:(\d+)\s+is\s+(-?\d+)/i); if (!m) return null; return { rpn: m[1], rssiDbm: Number(m[2]), mac: null }; } function extractDevicePresence(node) { if (!node || typeof node !== 'object') return []; const rpnEntries = extractKeyedEntries(node, /^RPN_/).map((e) => ({ key: e.key, present: /present/i.test(e.value), raw: e.value, })); if (rpnEntries.length > 0) return rpnEntries; return extractKeyedEntries(node, /^Device_Line_/).map((e) => { const m = e.value.match( /Device:([^\s]+)\s+(present|absent)\s+at\s+Extension\s+(\d+)/i, ); return { key: e.key, deviceType: m?.[1] || null, present: m ? /present/i.test(m[2]) : null, extension: m?.[3] || null, raw: e.value, }; }); } function extractSipIdentityStatus(node) { if (!node || typeof node !== 'object') return []; const uaEntries = Object.entries(node) .filter(([k]) => /^SIP_UA_Id_/i.test(k)) .sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0])); if (uaEntries.length > 0) { return uaEntries.map(([key, val]) => { if (typeof val !== 'object' || val == null) { return { key, raw: String(val) }; } return { key, sipIdx: str(val.SIP_Idx), serverName: str(val.Server_Name), sipUri: str(val.SIP_URI), status: str(val.Status), }; }); } return extractKeyedEntries(node, /^Line_/).map((e) => ({ key: e.key, status: e.value, raw: e.value, })); } function extractDeviceInformation(root) { const di = (typeof root === 'object' && root.Device_Information) || {}; if (!di || typeof di !== 'object') return []; return Object.entries(di) .filter(([k]) => /^Device_Idx\d+$/i.test(k)) .sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0])) .map(([key, dev]) => parseDeviceIdxEntry(key, dev)) .filter(Boolean); } function parseDeviceIdxEntry(key, dev) { if (!dev || typeof dev !== 'object') return null; const indexMatch = key.match(/(\d+)$/); const index = indexMatch ? Number(indexMatch[1]) : null; const battery = dev.Battery_RSSI_Info || {}; const location = dev.Location_Info || {}; const dect = dev.DECT_Info || {}; const sipAccount = findSipAccountDisplay(dev.SIP_Account); const rssiRaw = str(battery.RSSI); const rssiMatch = rssiRaw?.match(/(-?\d+)/); const batteryRaw = str(battery.Battery_level); const batteryMatch = batteryRaw?.match(/(\d+)/); return { index, deviceType: str(dev.Device_Type), ipei: str(dev.Ipei), firmware: str(dev.SW_Version), displayName: sipAccount?.displayName || null, sipNumber: sipAccount?.number || null, sipState: sipAccount?.state || null, rssiDbm: rssiMatch ? Number(rssiMatch[1]) : null, batteryPercent: batteryMatch ? Number(batteryMatch[1]) : null, registeredRpn: str(location.RPN), lockedRpn: str(location.Locked_RPN), dectState: str(dect.DECT_State), fwuProgress: str(dev.FWU_Progress), }; } function findSipAccountDisplay(sipAccountNode) { if (!sipAccountNode || typeof sipAccountNode !== 'object') return null; for (const val of Object.values(sipAccountNode)) { if (!val || typeof val !== 'object') continue; const displayName = str(val.Display_Name); const number = str(val.Number); const state = str(val.State); if (displayName || number) { return { displayName, number, state }; } } return null; } function extractKeyedEntries(node, keyPattern) { if (!node || typeof node !== 'object') return []; return Object.entries(node) .filter(([k]) => keyPattern.test(k)) .sort(([a], [b]) => { const na = Number((a.match(/\d+$/) || [0])[0]); const nb = Number((b.match(/\d+$/) || [0])[0]); return na - nb; }) .map(([key, value]) => ({ key, value: typeof value === 'string' ? value.trim() : String(value ?? ''), })) .filter((e) => e.value); } function extractPerRpnStatistics(statsNode) { if (!statsNode || typeof statsNode !== 'object') return []; const header = str(statsNode.Header_Line_Idx) || ''; const columns = header.split(',').map((c) => c.trim()).filter(Boolean); const lines = Object.entries(statsNode) .filter(([k]) => /^Statistics_Line_\d+$/.test(k) || /^Value_Line_\d+$/.test(k)) .sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0])) .map(([, v]) => (typeof v === 'string' ? v.trim() : '')); return lines.filter(Boolean).map((line) => { const parts = parseStatisticsCsvLine(line); const row = { raw: line }; if (columns.length === parts.length) { for (let i = 0; i < columns.length; i++) { const col = columns[i].toLowerCase(); if (/^rpn$/i.test(col)) row.rpn = parts[i].replace(/^'|'$/g, ''); else if (/mac/i.test(col)) row.mac = normalizeMac(parts[i]); else if (/^op\[s\]/i.test(col)) row.opSeconds = numOrNull(parts[i]); else if (/^dt\[s\]/i.test(col)) row.dtSeconds = numOrNull(parts[i]); else if (/call cnt/i.test(col)) row.callCount = numOrNull(parts[i]); } } else if (parts.length >= 4) { row.rpn = parts[0].replace(/^'|'$/g, ''); row.mac = normalizeMac(parts[1]); row.opSeconds = numOrNull(parts[2]); row.dtSeconds = numOrNull(parts[3]); if (parts.length >= 5) row.callCount = numOrNull(parts[4]); } return row; }); } function parseStatisticsCsvLine(line) { const parts = []; const re = /'([^']*)'|([^,]+)/g; let m; while ((m = re.exec(line)) !== null) { parts.push((m[1] != null ? m[1] : m[2]).trim()); } return parts.length > 0 ? parts : line.split(',').map((p) => p.trim()); }