generated from Grigo/AndroidTemplate
175 lines
7.7 KiB
JavaScript
175 lines
7.7 KiB
JavaScript
/** 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.stats_at != null) snap.statsAt = Number(o.stats_at);
|
|
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',
|
|
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm'];
|
|
const map = { gps: 'gps', packetTime: 'packetTime', 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: '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: '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, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx, staticOpen) {
|
|
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 STATIC_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></div>';
|
|
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 += `<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 STATIC_ROWS) {
|
|
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 = {
|
|
roleLabel,
|
|
parseRadioSnapshot,
|
|
diffSnapshots,
|
|
renderCompareGrid,
|
|
formatRadioPanel,
|
|
DYNAMIC_ROWS,
|
|
STATIC_ROWS
|
|
};
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|