/** Shared radio stats parsing/formatting (mirror of Android RadioSnapshot). */ (function (global) { 'use strict'; const KNOWN_LABELS = new Set([ 'send', 'receive', 'frequency', 'power', 'rssi', 'snr', 'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload', 'packet receive', 'packet total', 'packet error', 'crc error', 'preamble detected', 'header valid', 'on air', 'tx speed', 'rx speed', 'per', 'rx quality', 'code rate', 'preamble length', 'low data rate', 'crc', 'payload length', 'tx timeout' ]); function roleLabel(role) { if (role === 'TX') return 'Передатчик (TX)'; if (role === 'RX') return 'Приёмник (RX)'; return role || '—'; } function isKnownLabel(label) { const n = String(label || '').toLowerCase().trim(); for (const k of KNOWN_LABELS) { if (n === k || n.includes(k)) return true; } return false; } function fmtCrc(enabled) { if (enabled == null) return '—'; return enabled ? 'On' : 'Off'; } function fmtBw(khz) { return khz != null ? `${Number(khz).toFixed(2)} kHz` : '—'; } function parseRadioSnapshot(meta, roleFallback, rssiFallback) { const snap = { role: roleFallback || null, frame: null, frequencyMhz: null, sf: null, bwKhz: null, powerDbm: null, rssiDbm: rssiFallback ?? null, snrDb: null, packet: null, payload: null, onAirMs: null, txPktPerS: null, rxPktPerS: null, perPercent: null, rxQualityPercent: null, codeRate: null, preambleLength: null, lowDataRateOpt: null, crcEnabled: null, payloadLengthBytes: null, txTimeoutMs: null, packetReceive: null, packetTotal: null, packetError: null, crcError: null, preambleDetected: null, headerValid: null, extraFields: {} }; if (!meta) return snap; let o = meta; if (typeof meta === 'string') { try { o = JSON.parse(meta); } catch (e) { return snap; } } if (o.role) snap.role = o.role; if (o.frame) snap.frame = o.frame; if (o.rssi_dbm != null) snap.rssiDbm = Number(o.rssi_dbm); if (o.power_dbm != null) snap.powerDbm = Number(o.power_dbm); if (o.snr_db != null) snap.snrDb = Number(o.snr_db); if (o.frequency_hz != null) snap.frequencyMhz = Number(o.frequency_hz) / 1e6; if (o.spreading_factor != null) snap.sf = Number(o.spreading_factor); if (o.bandwidth_khz != null) snap.bwKhz = Number(o.bandwidth_khz); if (o.packet != null) snap.packet = Number(o.packet); if (o.payload) snap.payload = String(o.payload); if (o.on_air_ms != null) snap.onAirMs = Number(o.on_air_ms); if (o.tx_pkt_per_s != null) snap.txPktPerS = Number(o.tx_pkt_per_s); if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s); if (o.per_percent != null) snap.perPercent = Number(o.per_percent); if (o.rx_quality_percent != null) snap.rxQualityPercent = Number(o.rx_quality_percent); if (o.stats_at != null) snap.statsAt = Number(o.stats_at); if (o.code_rate != null) snap.codeRate = String(o.code_rate); if (o.preamble_length != null) snap.preambleLength = Number(o.preamble_length); if (o.low_data_rate_opt != null) snap.lowDataRateOpt = String(o.low_data_rate_opt); if (o.crc_enabled != null) snap.crcEnabled = Boolean(o.crc_enabled); if (o.payload_length_bytes != null) snap.payloadLengthBytes = Number(o.payload_length_bytes); if (o.tx_timeout_ms != null) snap.txTimeoutMs = Number(o.tx_timeout_ms); if (o.packet_receive != null) snap.packetReceive = Number(o.packet_receive); if (o.packet_total != null) snap.packetTotal = Number(o.packet_total); if (o.packet_error != null) snap.packetError = Number(o.packet_error); if (o.crc_error != null) snap.crcError = Number(o.crc_error); if (o.preamble_detected != null) snap.preambleDetected = Number(o.preamble_detected); if (o.header_valid != null) snap.headerValid = Number(o.header_valid); if (o.fields && typeof o.fields === 'object') { for (const [k, v] of Object.entries(o.fields)) { if (!isKnownLabel(k)) snap.extraFields[k] = String(v); const nk = String(k).toLowerCase().trim(); if (snap.rxQualityPercent == null && nk.includes('rx quality')) { const n = parseFloat(String(v).replace('%', '').trim()); if (!Number.isNaN(n)) snap.rxQualityPercent = n; } } } return snap; } function diffSnapshots(a, b) { const changed = new Set(); if (!a || !b) return changed; const keys = ['gps', 'packetTime', 'role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent', 'packetReceive', 'packetTotal', 'packetError', 'crcError', 'preambleDetected', 'headerValid', 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm', 'codeRate', 'crcEnabled']; const map = { gps: 'gps', packetTime: 'packetTime', role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality', packet: 'packet', payload: 'payload', perPercent: 'per', packetReceive: 'packetReceive', packetTotal: 'packetTotal', packetError: 'packetError', crcError: 'crcError', preambleDetected: 'preambleDetected', headerValid: 'headerValid', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed', frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power', codeRate: 'codeRate', crcEnabled: 'crc' }; for (const k of keys) { if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]); } return changed; } const DYNAMIC_ROWS = [ { key: 'gps', label: 'GPS', fmt: s => s.gps || '—' }, { key: 'packetTime', label: 'Время пакета', fmt: s => s.packetTime || '—' }, { key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' }, { key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' }, { key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' }, { key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' }, { key: 'payload', label: 'Payload', fmt: s => s.payload || '—' }, { key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' }, { key: 'packetReceive', label: 'Принято', fmt: s => s.packetReceive != null ? String(s.packetReceive) : '—' }, { key: 'packetTotal', label: 'Всего', fmt: s => s.packetTotal != null ? String(s.packetTotal) : '—' }, { key: 'packetError', label: 'Ошибки', fmt: s => s.packetError != null ? String(s.packetError) : '—' }, { key: 'crcError', label: 'CRC err', fmt: s => s.crcError != null ? String(s.crcError) : '—' }, { key: 'preambleDetected', label: 'Preamble', fmt: s => s.preambleDetected != null ? String(s.preambleDetected) : '—' }, { key: 'headerValid', label: 'Header OK', fmt: s => s.headerValid != null ? String(s.headerValid) : '—' }, { key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' }, { key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' } ]; const STATIC_ROWS = [ { key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) }, { key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' }, { key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' }, { key: 'bw', label: 'BW', fmt: s => fmtBw(s.bwKhz) }, { key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' }, { key: 'codeRate', label: 'Code Rate', fmt: s => s.codeRate || '—' }, { key: 'preambleLength', label: 'Preamble', fmt: s => s.preambleLength != null ? String(s.preambleLength) : '—' }, { key: 'lowDataRateOpt', label: 'LDR', fmt: s => s.lowDataRateOpt || '—' }, { key: 'crc', label: 'CRC', fmt: s => fmtCrc(s.crcEnabled) }, { key: 'payloadLength', label: 'Payl.len', fmt: s => s.payloadLengthBytes != null ? `${s.payloadLengthBytes} B` : '—' }, { key: 'txTimeout', label: 'TX Timeout', fmt: s => s.txTimeoutMs != null ? `${s.txTimeoutMs} ms` : '—' }, { key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' } ]; function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/&/g, '&').replace(//g, '>'); } function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) { let html = '
'; html += '
'; html += `TX ${escapeHtml(txId || '—')}`; html += `RX ${escapeHtml(rxId || '—')}`; html += '
'; for (const row of DYNAMIC_ROWS) { const txCls = changedTx && changedTx.has(row.key) ? ' changed' : ''; const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : ''; html += `
${row.label}`; html += `${escapeHtml(row.fmt(txSnap))}`; html += `${escapeHtml(row.fmt(rxSnap))}
`; } html += `
Статика`; for (const row of STATIC_ROWS) { const txCls = changedTx && changedTx.has(row.key) ? ' changed' : ''; const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : ''; html += `
${row.label}`; html += `${escapeHtml(row.fmt(txSnap))}`; html += `${escapeHtml(row.fmt(rxSnap))}
`; } html += '
'; return html; } function formatRadioPanel(snap, changed, staticOpen) { if (!snap) return '—'; const ch = changed || new Set(); let html = ''; for (const row of DYNAMIC_ROWS) { const cls = ch.has(row.key) ? ' class="changed"' : ''; html += `${row.label}: ${escapeHtml(row.fmt(snap))}`; } for (const [label, value] of Object.entries(snap.extraFields || {})) { html += `
${escapeHtml(label)}: ${escapeHtml(value)}
`; } html += `
Статика`; for (const row of STATIC_ROWS) { const cls = ch.has(row.key) ? ' class="changed"' : ''; html += `${row.label}: ${escapeHtml(row.fmt(snap))}`; } html += '
'; return html; } global.RadioUI = { roleLabel, parseRadioSnapshot, diffSnapshots, renderCompareGrid, formatRadioPanel, DYNAMIC_ROWS, STATIC_ROWS }; })(typeof window !== 'undefined' ? window : globalThis);