/** 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', 'on air', 'tx speed', 'rx speed', 'per', 'rx quality' ]); 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 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, 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.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 = ['role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent', 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm']; const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality', packet: 'packet', payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed', frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' }; 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: '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: '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 => s.bwKhz != null ? `${s.bwKhz} kHz` : '—' }, { key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' }, { 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 = '