Files
LoraMapTester/server/static/radio-ui.js
T
2026-07-06 08:50:30 +03:00

310 lines
14 KiB
JavaScript

/** Shared radio stats parsing/formatting (mirror of Android RadioSnapshot). */
(function (global) {
'use strict';
const MODEM_LORA = 'LORA';
const MODEM_GFSK = 'GFSK';
const KNOWN_LABELS = new Set([
'send', 'receive', 'modem', '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',
'bitrate', 'fdev', 'modulation index', 'rx bandwidth', 'sync word', 'modem mode'
]);
function normalizeModemMode(mode) {
if (!mode || String(mode).trim() === '') return MODEM_LORA;
const upper = String(mode).trim().toUpperCase();
return upper === MODEM_GFSK ? MODEM_GFSK : MODEM_LORA;
}
function isGfskMode(mode) {
return normalizeModemMode(mode) === MODEM_GFSK;
}
function isLoraMode(mode) {
return !isGfskMode(mode);
}
function roleLabel(role) {
if (role === 'TX') return 'Передатчик (TX)';
if (role === 'RX') return 'Приёмник (RX)';
return role || '—';
}
function modemModeLabel(mode) {
if (isGfskMode(mode)) return 'GFSK';
if (normalizeModemMode(mode) === MODEM_LORA) return 'LoRa';
return mode || '—';
}
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,
modemMode: 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,
bitrateBps: null,
fdevHz: null,
modulationIndex: null,
rxBandwidthHz: null,
syncWordLengthBits: 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.modem_mode) snap.modemMode = normalizeModemMode(o.modem_mode);
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.bitrate_bps != null) snap.bitrateBps = Number(o.bitrate_bps);
if (o.fdev_hz != null) snap.fdevHz = Number(o.fdev_hz);
if (o.modulation_index != null) snap.modulationIndex = Number(o.modulation_index);
if (o.rx_bandwidth_hz != null) snap.rxBandwidthHz = Number(o.rx_bandwidth_hz);
if (o.sync_word_length_bits != null) snap.syncWordLengthBits = Number(o.sync_word_length_bits);
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', 'modemMode', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
'packetReceive', 'packetTotal', 'packetError', 'crcError', 'preambleDetected', 'headerValid',
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm', 'codeRate', 'crcEnabled',
'bitrateBps', 'fdevHz', 'modulationIndex', 'rxBandwidthHz', 'syncWordLengthBits'];
const map = {
gps: 'gps', packetTime: 'packetTime', role: 'role', modemMode: 'modemMode', 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',
bitrateBps: 'bitrate', fdevHz: 'fdev', modulationIndex: 'modIndex',
rxBandwidthHz: 'rxBw', syncWordLengthBits: 'syncWord'
};
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 COMMON_STATIC_ROWS = [
{ key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) },
{ key: 'modemMode', label: 'Режим', fmt: s => modemModeLabel(s.modemMode) },
{ key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' },
{ key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' },
{ key: 'preambleLength', label: 'Preamble', fmt: s => s.preambleLength != null ? String(s.preambleLength) : '—' },
{ 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` : '—' }
];
const LORA_STATIC_ROWS = [
{ key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' },
{ key: 'bw', label: 'BW', fmt: s => fmtBw(s.bwKhz) },
{ key: 'codeRate', label: 'Code Rate', fmt: s => s.codeRate || '—' },
{ key: 'lowDataRateOpt', label: 'LDR', fmt: s => s.lowDataRateOpt || '—' },
{ key: 'crc', label: 'CRC', fmt: s => fmtCrc(s.crcEnabled) }
];
const GFSK_STATIC_ROWS = [
{ key: 'bitrate', label: 'Bitrate', fmt: s => s.bitrateBps != null ? `${s.bitrateBps} bit/s` : '—' },
{ key: 'fdev', label: 'FDev', fmt: s => s.fdevHz != null ? `${s.fdevHz} Hz` : '—' },
{ key: 'modIndex', label: 'Mod Idx', fmt: s => s.modulationIndex != null ? String(s.modulationIndex) : '—' },
{ key: 'rxBw', label: 'Rx BW', fmt: s => s.rxBandwidthHz != null ? `${s.rxBandwidthHz} Hz` : '—' },
{ key: 'syncWord', label: 'Sync Word', fmt: s => s.syncWordLengthBits != null ? `${s.syncWordLengthBits} bit` : '—' }
];
function staticRowsForCompare(txSnap, rxSnap) {
const rows = [...COMMON_STATIC_ROWS];
if (isGfskMode(txSnap?.modemMode) || isGfskMode(rxSnap?.modemMode)) {
rows.push(...GFSK_STATIC_ROWS);
}
if (isLoraMode(txSnap?.modemMode) || isLoraMode(rxSnap?.modemMode)) {
rows.push(...LORA_STATIC_ROWS);
}
return rows;
}
function staticRowsForSnap(snap) {
const rows = [...COMMON_STATIC_ROWS];
if (isGfskMode(snap?.modemMode)) rows.push(...GFSK_STATIC_ROWS);
else rows.push(...LORA_STATIC_ROWS);
return rows;
}
/** @deprecated use staticRowsForSnap */
const STATIC_ROWS = [...COMMON_STATIC_ROWS, ...LORA_STATIC_ROWS];
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
const staticRows = staticRowsForCompare(txSnap, rxSnap);
let html = '<div class="radio-compare-grid">';
html += '<div class="radio-compare-head">';
html += `<span><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}</span>`;
html += `<span><span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</span>`;
html += '</div>';
for (const row of DYNAMIC_ROWS) {
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
}
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
for (const row of staticRows) {
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
}
html += '</details></div>';
return html;
}
function formatRadioPanel(snap, changed, staticOpen) {
if (!snap) return '—';
const ch = changed || new Set();
const staticRows = staticRowsForSnap(snap);
let html = '';
for (const row of DYNAMIC_ROWS) {
const cls = ch.has(row.key) ? ' class="changed"' : '';
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
}
for (const [label, value] of Object.entries(snap.extraFields || {})) {
html += `<div><b>${escapeHtml(label)}:</b> ${escapeHtml(value)}</div>`;
}
html += `<details class="radio-static"${staticOpen ? ' open' : ''}><summary>Статика</summary>`;
for (const row of staticRows) {
const cls = ch.has(row.key) ? ' class="changed"' : '';
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
}
html += '</details>';
return html;
}
global.RadioUI = {
MODEM_LORA,
MODEM_GFSK,
normalizeModemMode,
isGfskMode,
isLoraMode,
roleLabel,
modemModeLabel,
parseRadioSnapshot,
diffSnapshots,
renderCompareGrid,
formatRadioPanel,
staticRowsForSnap,
staticRowsForCompare,
DYNAMIC_ROWS,
STATIC_ROWS
};
})(typeof window !== 'undefined' ? window : globalThis);