Files
node-26/drb-edge-node/app/templates/index.html
T
Logan CusanoandClaude Opus 5.5 d07d312e92
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 13s
CI / lint (pull_request) Successful in 7s
CI / test-op25 (pull_request) Successful in 13s
CI / test (push) Successful in 44s
CI / test (pull_request) Successful in 42s
P25 conventional decode mode + conventional-channel attribution (node-26#24, #20)
op25-container
- generate-config takes p25_mode "conventional" + conv_channels [{frequency,
  name, nac}]. It builds fixed fsk4 channels (up to 2 on one non-tunable SDR,
  DC spike kept off the channels) with no control channel to hunt. Two
  channels send audio to UDP 23456/23457, and audio.py -2 + amplify(2) keeps
  levels.
- tk_drb_conv.py (a tk_p25.rx_ctl subclass, copied into OP25 apps) reports
  voice / NAC / encryption (LC service options + LDU2 ESS algid/keyid) for
  conventional channels and applies the per-channel NAC filter, which stock
  tk_p25 does not. Analog uses it too.
- Trunked P25 config is unchanged when p25_mode is absent.

drb-edge-node
- radio_config: system kind from type + config.mode (missing = trunked).
- conventional.py normalizes the console: a distinctive LC TGID, else the
  channel's last TGID (30 s), else a synthetic per-channel id (freq/100 Hz).
  Encrypted rows are never talkers. With exactly one conventional channel,
  orphan audio is attributed to it at close, which fixes analog recording
  (#20). The synthetic id and the real TGID on one channel never split.
- Health/dashboard: P25 conventional listen tab (/api/listen p25conv), and
  conventional decode + encryption checks. The checkin carries radio_channels.
- Tests: edge 296 pass; op25-container 18 (new CI job). Reviewed by
  drb-audio-review (findings fixed; simultaneous two-channel mixdown filed
  as #26) and drb-qa-review.

Not verified on SDR hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:57:15 -04:00

595 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DRB Edge Node</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* Tokens from UI_REDESIGN.md §2.2/§2.3. Status is triple-encoded
(glyph + word + colour) and green is not used. */
:root {
--page: #F4F6F9; --surface: #FFFFFF; --raised: #F7F9FB;
--line: rgba(11,17,27,.11); --line-strong: rgba(11,17,27,.22);
--ink: #101620; --ink-2: #46536A; --ink-muted: #6B788C;
--accent: #2A78D6; --moderate: #B07800; --major: #C0281F;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--page: #0B0E13; --surface: #141922; --raised: #1B2230;
--line: rgba(255,255,255,.09); --line-strong: rgba(255,255,255,.17);
--ink: #E8EBF0; --ink-2: #A5B0C2; --ink-muted: #77839A;
--accent: #3987E5; --moderate: #C98500; --major: #D03B3B;
}
}
:root[data-theme="dark"] {
--page: #0B0E13; --surface: #141922; --raised: #1B2230;
--line: rgba(255,255,255,.09); --line-strong: rgba(255,255,255,.17);
--ink: #E8EBF0; --ink-2: #A5B0C2; --ink-muted: #77839A;
--accent: #3987E5; --moderate: #C98500; --major: #D03B3B;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
a { color: var(--accent); }
a.btn { color: var(--ink); }
body {
font-family: 'IBM Plex Sans', system-ui, sans-serif;
background: var(--page); color: var(--ink);
line-height: 1.5; padding: 24px 16px 48px;
}
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.wrap { max-width: 980px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
h1 { font-size: 1.5rem; font-weight: 600; }
h2 { font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
.sub { color: var(--ink-muted); font-size: .875rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
.card > .sub { margin-bottom: 12px; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn {
font: inherit; font-size: .875rem; font-weight: 500; cursor: pointer;
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--line-strong);
background: var(--raised); color: var(--ink); text-decoration: none; display: inline-flex; align-items: center;
}
.btn:hover { border-color: var(--ink-muted); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn:disabled { opacity: .45; cursor: default; }
.msg { font-size: .875rem; color: var(--ink-2); }
.msg.err { color: var(--major); }
/* Checklist */
.summary { font-weight: 600; margin-bottom: 8px; }
.check { display: grid; grid-template-columns: 22px 1fr; gap: 2px 8px; padding: 10px 0; border-top: 1px solid var(--line); }
.check:first-of-type { border-top: none; }
.glyph { font-size: 1rem; line-height: 1.4; text-align: center; }
.check .label { font-weight: 500; }
.check .label .state { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-left: 6px; }
.check .text { grid-column: 2; color: var(--ink-2); font-size: .9rem; }
.check .action { grid-column: 2; color: var(--ink); font-size: .875rem; }
.check .action:empty { display: none; }
.s-ok .glyph, .s-ok .state { color: var(--ink-2); }
.s-warn .glyph, .s-warn .state { color: var(--moderate); }
.s-error .glyph, .s-error .state { color: var(--major); }
.s-off .glyph, .s-off .state { color: var(--ink-muted); }
.s-off .label { color: var(--ink-2); }
/* Now playing */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; font-size: .9rem; }
.kv dt { color: var(--ink-muted); }
.live { color: var(--accent); font-weight: 600; }
audio { width: 100%; margin-top: 4px; }
pre.log {
font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: .75rem; line-height: 1.45;
background: var(--raised); border: 1px solid var(--line); border-radius: 6px;
padding: 10px; max-height: 280px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin-top: 10px;
}
/* Listen form */
.tabs { display: flex; border: 1px solid var(--line-strong); border-radius: 6px; overflow: hidden; width: fit-content; max-width: 100%; flex-wrap: wrap; }
.tabs label { padding: 7px 12px; font-size: .875rem; cursor: pointer; border-left: 1px solid var(--line); }
.tabs label:first-child { border-left: none; }
.tabs input { position: absolute; opacity: 0; pointer-events: none; }
.tabs label:has(input:checked) { background: var(--accent); color: #fff; }
.tabs label:has(input:disabled) { color: var(--ink-muted); cursor: default; }
.tabs label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
.field { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; max-width: 520px; }
.field label { font-size: .875rem; font-weight: 500; }
.field .hint { font-size: .8rem; color: var(--ink-muted); }
input[type=text], select, textarea {
font: inherit; font-size: .9rem; color: var(--ink); background: var(--raised);
border: 1px solid var(--line-strong); border-radius: 6px; padding: 7px 9px; width: 100%;
}
textarea { min-height: 84px; font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: .8rem; }
.current { padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); margin-bottom: 12px; font-size: .9rem; }
/* SDRs */
.sdr-row { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); flex-wrap: wrap; }
.sdr-rank { width: 1.2rem; color: var(--ink-muted); font-size: .8rem; text-align: right; }
.sdr-name { flex: 1; min-width: 120px; }
.sdr-name small { display: block; color: var(--ink-muted); font-size: .75rem; }
.sdr-move { font: inherit; background: var(--raised); border: 1px solid var(--line-strong); color: var(--ink); border-radius: 6px; width: 30px; height: 30px; cursor: pointer; }
.sdr-move:disabled { opacity: .3; cursor: default; }
.sdr-state { font-size: .75rem; min-width: 6.5rem; text-align: right; color: var(--ink-muted); }
.sdr-state.on { color: var(--accent); }
.sdr-select { width: auto; max-width: 14rem; font-size: .8rem; padding: 4px 6px; }
footer { text-align: center; font-size: .8rem; color: var(--ink-muted); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1 id="node-name">DRB Edge Node</h1>
<div class="sub">Node ID <span class="mono" id="node-id">…</span></div>
</div>
<div class="row">
<a href="/scanner" target="_blank" class="btn">Scanner view</a>
<a href="/logout" class="btn">Log out</a>
</div>
</header>
<!-- 1. Status checklist (GET /api/health) -->
<section class="card" aria-labelledby="h-status">
<h2 id="h-status">Node status</h2>
<div class="summary" id="health-summary">Checking…</div>
<div id="checks"></div>
</section>
<!-- 2. Now playing + OP25 controls -->
<section class="card" aria-labelledby="h-now">
<h2 id="h-now">Listen</h2>
<p class="sub">The node's own live stream. It is a few seconds behind the radio, and silence between transmissions is normal.</p>
<audio id="player" controls preload="none">Your browser can't play the stream — use the direct link.</audio>
<dl class="kv">
<dt>Listening to</dt><dd id="now-system">—</dd>
<dt>Talkgroup</dt><dd id="now-tg">—</dd>
<dt>Last heard</dt><dd id="now-heard">—</dd>
<dt>Stream link</dt><dd><a id="stream-link" class="mono" href="#" target="_blank" rel="noopener">—</a></dd>
</dl>
<div class="row">
<button class="btn" id="op25-start" onclick="op25('start')">Start OP25</button>
<button class="btn" id="op25-stop" onclick="op25('stop')">Stop OP25</button>
<button class="btn" id="log-toggle" onclick="toggleLog()" aria-expanded="false">Show OP25 log</button>
<span class="msg" id="op25-msg" role="status"></span>
</div>
<pre class="log" id="op25-log" hidden></pre>
</section>
<!-- 3. Choose what to listen to (POST /api/listen) -->
<section class="card" aria-labelledby="h-choose">
<h2 id="h-choose">Listen locally</h2>
<p class="sub">Choose what this node's radio tunes to. Works with or without the DRB server. If the server assigns a system later, it takes over.</p>
<div class="current" id="current-source">Nothing chosen yet.</div>
<div class="row" id="source-actions" style="margin:-4px 0 12px" hidden>
<button class="btn" id="revert-btn" onclick="revert()"></button>
<button class="btn" id="ack-btn" onclick="ackOverride()" hidden>Keep for 24h on the server</button>
</div>
<div class="tabs" role="radiogroup" aria-label="Source type">
<label><input type="radio" name="mode" value="p25" checked> P25 trunked</label>
<label><input type="radio" name="mode" value="p25conv"> P25 conventional</label>
<label><input type="radio" name="mode" value="analog"> Analog FM</label>
<label><input type="radio" name="mode" value="system" id="mode-system"> Server's systems</label>
</div>
<div class="pane" data-pane="p25">
<div class="field">
<label for="p25-freqs">Control channel frequencies (MHz)</label>
<input type="text" id="p25-freqs" class="mono" placeholder="851.0125, 851.5125" inputmode="decimal">
<span class="hint">The system's control channels, comma-separated. Find them on RadioReference under your county's trunked system.</span>
</div>
<div class="field">
<label for="p25-name">Name <span class="hint">(optional)</span></label>
<input type="text" id="p25-name" placeholder="County P25">
</div>
<div class="field">
<label for="p25-tgs">Talkgroups to keep <span class="hint">(optional)</span></label>
<textarea id="p25-tgs" placeholder="1001 Fire Dispatch&#10;1002 Fireground"></textarea>
<span class="hint">One per line: decimal ID, then a name. Leave empty to hear every talkgroup.</span>
</div>
</div>
<div class="pane" data-pane="p25conv" hidden>
<div class="field">
<label for="pc-freqs">Channel frequencies (MHz)</label>
<input type="text" id="pc-freqs" class="mono" placeholder="460.4000, 460.4250" inputmode="decimal">
<span class="hint">One or two voice channels, comma-separated, within about 1.5 MHz of each other. No control channel — RadioReference lists these as conventional frequencies with mode P25 (or "DE").</span>
</div>
<div class="field">
<label for="pc-name">Name <span class="hint">(optional)</span></label>
<input type="text" id="pc-name" placeholder="City PD">
</div>
<div class="field">
<label for="pc-nac">NAC <span class="hint">(optional, hex)</span></label>
<input type="text" id="pc-nac" class="mono" placeholder="293">
<span class="hint">RadioReference's NAC for these channels. Leave empty to accept any.</span>
</div>
</div>
<div class="pane" data-pane="analog" hidden>
<div class="field">
<label for="an-freq">Frequency (MHz)</label>
<input type="text" id="an-freq" class="mono" placeholder="154.4300" inputmode="decimal">
<span class="hint">One conventional narrowband FM channel.</span>
</div>
<div class="field">
<label for="an-name">Name <span class="hint">(optional)</span></label>
<input type="text" id="an-name" placeholder="Fire dispatch">
</div>
</div>
<div class="pane" data-pane="system" hidden>
<div class="field">
<label for="sys-pick">System</label>
<select id="sys-pick"></select>
<span class="hint" id="sys-hint"></span>
</div>
</div>
<p class="sub" style="margin-top:12px">DMR isn't supported by this node's decoder yet.</p>
<div class="row" style="margin-top:8px">
<button class="btn primary" id="listen-btn" onclick="listen()">Start listening</button>
<span class="msg" id="listen-msg" role="status"></span>
</div>
</section>
<!-- 4. SDRs (node-26#9, #11) -->
<section class="card" aria-labelledby="h-sdr">
<h2 id="h-sdr">SDRs</h2>
<p class="sub">Only matters with more than one SDR plugged in. OP25 always gets one; every other SDR runs the next enabled service, top first.</p>
<div class="row">
<label for="sdr-op25" style="font-size:.875rem;font-weight:500">OP25 uses</label>
<select id="sdr-op25" class="sdr-select"></select>
</div>
<p id="sdr-op25-note" class="sub" style="margin:6px 0 4px"></p>
<div id="sdr-list"></div>
<p id="sdr-warning" class="msg err" style="margin-top:8px" hidden></p>
<div class="row" style="margin-top:12px">
<button id="sdr-save" class="btn primary" onclick="saveSdr()" disabled>Save SDR settings</button>
<span id="sdr-msg" class="msg"></span>
</div>
</section>
<footer>Updated <span id="last-updated">—</span></footer>
</div>
<script>
const $ = id => document.getElementById(id);
function esc(v) {
return String(v ?? '').replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
async function api(path, opts = {}) {
const r = await fetch(path, opts);
let body = null;
try { body = await r.json(); } catch (e) { /* non-JSON */ }
if (r.status === 401) { location.href = '/login'; throw new Error('Session expired'); }
if (!r.ok) throw new Error((body && body.detail) || r.statusText || `HTTP ${r.status}`);
return body;
}
function ago(epoch) {
const s = Math.max(0, Math.round(Date.now() / 1000 - epoch));
return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.floor(s / 60)} min ago` : `${Math.floor(s / 3600)} h ago`;
}
function mhz(f) {
const n = Number(f);
if (!n) return '';
return (n > 1e5 ? n / 1e6 : n).toFixed(4) + ' MHz';
}
// ── Status checklist ───────────────────────────────────────────────────
const GLYPH = { ok: '✓', warn: '△', error: '▲', off: '–' };
const WORD = { ok: 'OK', warn: 'Attention', error: 'Problem', off: 'Not active' };
let lastHealth = null;
async function refreshHealth() {
try {
const d = await api('/api/health');
lastHealth = d;
const box = $('checks');
box.innerHTML = d.checks.map(c => `
<div class="check s-${c.state}">
<span class="glyph" aria-hidden="true">${GLYPH[c.state] || '?'}</span>
<div class="label">${esc(c.label)}<span class="state">${WORD[c.state] || esc(c.state)}</span></div>
<div class="text">${esc(c.summary)}</div>
<div class="action">${esc(c.action)}</div>
</div>`).join('');
const bad = d.checks.filter(c => c.state === 'error').length;
const warn = d.checks.filter(c => c.state === 'warn').length;
$('health-summary').textContent = bad
? `${bad} problem${bad > 1 ? 's' : ''}${warn ? ` and ${warn} thing${warn > 1 ? 's' : ''} to check` : ''} — see below.`
: warn ? `${warn} thing${warn > 1 ? 's' : ''} need${warn > 1 ? '' : 's'} attention — see below.`
: 'Everything is working.';
const heard = d.last_heard;
$('now-heard').innerHTML = heard
? `<span class="mono">TG ${esc(heard.tgid)}</span>${heard.name ? ' ' + esc(heard.name) : ''}` +
` · ${ago(heard.epoch)}${heard.freq ? ` · <span class="mono">${esc(mhz(heard.freq))}</span>` : ''}`
: 'Nothing heard since the node started.';
const op = d.checks.find(c => c.id === 'op25');
const src = d.checks.find(c => c.id === 'source');
const running = op && op.state === 'ok';
$('op25-start').disabled = running || (src && src.state !== 'ok');
$('op25-stop').disabled = !running;
if (op && op.state === 'error' && $('op25-log').hidden) loadLog();
} catch (e) {
$('health-summary').textContent = `Couldn't load status: ${e.message}`;
}
}
// ── Live status: system, talkgroup, stream URL ─────────────────────────
let streamUrl = null;
let status = null;
async function refreshStatus() {
try {
const d = await api('/api/status');
status = d;
$('node-name').textContent = d.node_name || 'DRB Edge Node';
$('node-id').textContent = d.node_id || '';
document.title = `${d.node_name || 'DRB Edge Node'} — DRB`;
$('now-system').textContent = d.system_name ? `${d.system_name} (${d.system_kind || d.system_type})` : 'Nothing chosen yet';
$('now-tg').innerHTML = d.active_tgid
? `<span class="live">${esc(d.active_tgid_name || 'Talkgroup')}</span> <span class="mono">TG ${esc(d.active_tgid)}</span>`
: 'No call right now';
// The node calls Icecast "localhost"; the browser must use the node's address.
const url = `${location.protocol}//${location.hostname}:${d.icecast_port}${d.icecast_mount}`;
if (url !== streamUrl) {
streamUrl = url;
$('player').src = url;
$('stream-link').href = url;
$('stream-link').textContent = url;
}
renderSource(d);
$('last-updated').textContent = new Date().toLocaleTimeString();
} catch (e) {
console.error('Status fetch failed:', e);
}
}
function renderSource(d) {
const cur = $('current-source');
const actions = $('source-actions');
if (!d.system_name) {
cur.textContent = 'Nothing chosen yet. Pick a source below and press Start listening.';
actions.hidden = true;
return;
}
cur.innerHTML = `Now: <strong>${esc(d.system_name)}</strong> (${esc(d.system_kind || d.system_type)}) — ` +
(d.local_choice ? 'chosen on this dashboard.' : 'assigned by the DRB server.');
actions.hidden = !d.local_choice;
$('revert-btn').textContent = d.has_c2_system ? "Go back to the server's system" : 'Clear and stop';
$('ack-btn').hidden = !(d.local_choice && d.has_c2_system && d.enforce_override_timeout);
}
// ── OP25 controls + log ────────────────────────────────────────────────
async function op25(action) {
$('op25-msg').className = 'msg';
$('op25-msg').textContent = action === 'start' ? 'Starting…' : 'Stopping…';
try {
await api(`/api/op25/${action}`, { method: 'POST' });
$('op25-msg').textContent = action === 'start' ? 'Started. Give it ~20 seconds to lock on.' : 'Stopped.';
} catch (e) {
$('op25-msg').className = 'msg err';
$('op25-msg').textContent = e.message;
}
refreshHealth();
}
async function loadLog() {
const pre = $('op25-log');
try {
const d = await api('/api/op25/log?lines=120');
pre.textContent = d.lines.length ? d.lines.join('\n') : 'OP25 has printed nothing since it last started.';
} catch (e) {
pre.textContent = `Couldn't load the log: ${e.message}`;
}
pre.hidden = false;
$('log-toggle').textContent = 'Hide OP25 log';
$('log-toggle').setAttribute('aria-expanded', 'true');
}
function toggleLog() {
const pre = $('op25-log');
if (pre.hidden) return loadLog();
pre.hidden = true;
$('log-toggle').textContent = 'Show OP25 log';
$('log-toggle').setAttribute('aria-expanded', 'false');
}
// ── Listen locally ─────────────────────────────────────────────────────
let systems = [];
function mode() { return document.querySelector('input[name=mode]:checked').value; }
document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => {
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.dataset.pane !== mode(); });
$('listen-msg').textContent = '';
}));
async function loadSystems() {
try { systems = await api('/api/systems'); } catch (e) { systems = []; }
const pick = $('sys-pick');
const usable = systems.filter(s => String(s.type).toUpperCase() !== 'DMR');
const kind = s => String(s.type).toUpperCase() === 'P25' && s.config && s.config.mode === 'conventional'
? 'P25 conventional' : s.type;
pick.innerHTML = usable.map(s => `<option value="${esc(s.system_id)}">${esc(s.name)} (${esc(kind(s))})</option>`).join('');
const none = usable.length === 0;
$('mode-system').disabled = none;
pick.disabled = none;
$('sys-hint').textContent = none
? "The server's systems appear here once this node is approved and online."
: `${usable.length} system${usable.length > 1 ? 's' : ''} saved from the DRB server.`;
}
function parseFreqs(text) {
return text.split(/[\s,;]+/).filter(Boolean).map(Number);
}
async function listen() {
const m = mode();
const msg = $('listen-msg');
let body;
if (m === 'system') {
body = { mode: 'system', system_id: $('sys-pick').value };
} else if (m === 'p25') {
const freqs = parseFreqs($('p25-freqs').value);
if (!freqs.length || freqs.some(isNaN)) { msg.className = 'msg err'; msg.textContent = 'Enter control channel frequencies in MHz, e.g. 851.0125'; return; }
const tgs = [];
for (const line of $('p25-tgs').value.split('\n').map(l => l.trim()).filter(Boolean)) {
const mm = line.match(/^(\d+)\s*[,\t ]?\s*(.*)$/);
if (!mm) { msg.className = 'msg err'; msg.textContent = `Talkgroup line "${line}" should start with a decimal ID.`; return; }
tgs.push({ id: Number(mm[1]), name: mm[2] });
}
body = { mode: 'p25', frequencies: freqs, name: $('p25-name').value, talkgroups: tgs };
} else if (m === 'p25conv') {
const freqs = parseFreqs($('pc-freqs').value);
if (!freqs.length || freqs.length > 2 || freqs.some(isNaN)) { msg.className = 'msg err'; msg.textContent = 'Enter one or two channel frequencies in MHz, e.g. 460.4000, 460.4250'; return; }
body = { mode: 'p25conv', frequencies: freqs, name: $('pc-name').value, nac: $('pc-nac').value.trim() || null };
} else {
const freqs = parseFreqs($('an-freq').value);
if (freqs.length !== 1 || isNaN(freqs[0])) { msg.className = 'msg err'; msg.textContent = 'Enter one frequency in MHz, e.g. 154.4300'; return; }
body = { mode: 'analog', frequencies: freqs, name: $('an-name').value };
}
$('listen-btn').disabled = true;
msg.className = 'msg';
msg.textContent = 'Applying — restarting OP25…';
try {
const d = await api('/api/listen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
msg.textContent = `Listening to ${d.name}. Give it ~20 seconds, then press play above.`;
} catch (e) {
msg.className = 'msg err';
msg.textContent = e.message;
} finally {
$('listen-btn').disabled = false;
refreshStatus(); refreshHealth();
}
}
async function revert() {
const toServer = status && status.has_c2_system;
if (!toServer && !confirm('Clear this choice and stop OP25?')) return;
const msg = $('listen-msg');
msg.className = 'msg';
msg.textContent = toServer ? "Switching back to the server's system…" : 'Stopping…';
try {
await api('/api/config/revert', { method: 'POST' });
msg.textContent = toServer ? "Back on the server's system." : 'Cleared. OP25 is stopped.';
} catch (e) {
msg.className = 'msg err';
msg.textContent = e.message;
}
refreshStatus(); refreshHealth();
}
async function ackOverride() {
const msg = $('listen-msg');
try {
await api('/api/config/override/ack', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timeout_minutes: 1440 }) });
msg.className = 'msg';
msg.textContent = 'The server will keep this choice for 24 hours.';
} catch (e) {
msg.className = 'msg err';
msg.textContent = `Couldn't reach the server: ${e.message}`;
}
}
// ── SDRs: OP25's dongle, secondary priority and per-service pins ──────
const SDR_LABELS = { adsb: ['ADS-B', 'Aircraft · 1090 MHz'], ais: ['AIS', 'Vessels · 162 MHz'] };
let sdr = null, sdrRows = [], sdrOp25Pin = '', sdrDirty = false;
function deviceOptions(selected, autoLabel) {
const devs = sdr.devices || [];
const known = devs.some(d => d.serial === selected);
return `<option value="">${esc(autoLabel)}</option>` +
devs.map(d => `<option value="${esc(d.serial)}" ${d.serial === selected ? 'selected' : ''}>` +
`SDR ${d.index + 1} · serial ${esc(d.serial)}${d.duplicate_serial ? ' (shared serial!)' : ''}</option>`).join('') +
(selected && !known ? `<option value="${esc(selected)}" selected>serial ${esc(selected)} ` +
`(${sdr.devices === null ? 'not reported' : 'not plugged in'})</option>` : '');
}
function renderSdr() {
const op25sel = $('sdr-op25');
op25sel.innerHTML = deviceOptions(sdrOp25Pin, 'Automatic (first SDR)');
op25sel.onchange = () => { sdrOp25Pin = op25sel.value; markSdrDirty(); };
$('sdr-op25-note').textContent = sdrOp25Pin !== (sdr.pins.op25 || '')
? 'Saving will restart OP25 on the selected SDR.'
: (sdr.op25_serial ? `OP25 is using serial ${sdr.op25_serial}.` : '');
const list = $('sdr-list');
list.innerHTML = '';
let rank = 0;
const running = sdr.running || [];
sdrRows.forEach((row, i) => {
const [name, hint] = SDR_LABELS[row.mode] || [row.mode, ''];
const pinMissing = row.pin && sdr.devices !== null && !sdr.devices.some(d => d.serial === row.pin);
const state = !row.enabled ? 'Off' : sdrDirty ? 'Unsaved' : sdr.running === null ? 'Unknown'
: running.includes(row.mode) ? 'Running' : pinMissing ? 'Pinned SDR missing' : 'Waiting for SDR';
const el = document.createElement('div');
el.className = 'sdr-row';
el.innerHTML = `
<span class="sdr-rank">${row.enabled ? ++rank : ''}</span>
<input type="checkbox" ${row.enabled ? 'checked' : ''} aria-label="Enable ${name}">
<span class="sdr-name">${name}<small>${hint}</small></span>
<select class="sdr-select" aria-label="${name} SDR">${deviceOptions(row.pin, 'Any spare SDR')}</select>
<button class="sdr-move" aria-label="Move ${name} up" ${i === 0 ? 'disabled' : ''}>▲</button>
<button class="sdr-move" aria-label="Move ${name} down" ${i === sdrRows.length - 1 ? 'disabled' : ''}>▼</button>
<span class="sdr-state ${state === 'Running' ? 'on' : ''}">${state}</span>`;
const [box] = el.getElementsByTagName('input');
const [pick] = el.getElementsByTagName('select');
const [up, down] = el.getElementsByTagName('button');
box.onchange = () => { row.enabled = box.checked; markSdrDirty(); };
pick.onchange = () => { row.pin = pick.value; markSdrDirty(); };
up.onclick = () => { [sdrRows[i - 1], sdrRows[i]] = [sdrRows[i], sdrRows[i - 1]]; markSdrDirty(); };
down.onclick = () => { [sdrRows[i + 1], sdrRows[i]] = [sdrRows[i], sdrRows[i + 1]]; markSdrDirty(); };
list.appendChild(el);
});
const warn = $('sdr-warning');
const pinned = [sdrOp25Pin, ...sdrRows.map(r => r.pin)].filter(Boolean);
const problems = [];
if (sdr.devices === null) problems.push("The SDR service isn't responding, so SDRs can't be listed.");
if ((sdr.devices || []).some(d => d.duplicate_serial)) {
problems.push("Two SDRs share a serial number, so they can't be told apart. Give each a unique serial (rtl_eeprom -s) before pinning.");
}
if (new Set(pinned).size !== pinned.length) problems.push('Two services are pinned to the same SDR.');
warn.textContent = problems.join(' ');
warn.hidden = !problems.length;
$('sdr-save').disabled = !sdrDirty || new Set(pinned).size !== pinned.length;
}
function markSdrDirty() { sdrDirty = true; $('sdr-msg').textContent = ''; renderSdr(); }
async function loadSdr() {
if (sdrDirty) return; // never clobber unsaved edits with a poll
try {
sdr = await api('/api/sdr');
sdrOp25Pin = sdr.pins.op25 || '';
sdrRows = [
...sdr.priority.map(mode => ({ mode, enabled: true, pin: sdr.pins[mode] || '' })),
...sdr.modes.filter(m => !sdr.priority.includes(m)).map(mode => ({ mode, enabled: false, pin: sdr.pins[mode] || '' })),
];
renderSdr();
} catch (e) {
console.error('SDR load failed:', e);
}
}
async function saveSdr() {
const pins = { op25: sdrOp25Pin || null };
sdrRows.forEach(r => { pins[r.mode] = r.pin || null; });
const btn = $('sdr-save'), msg = $('sdr-msg');
btn.disabled = true;
msg.textContent = 'Applying…';
try {
const d = await api('/api/sdr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priority: sdrRows.filter(r => r.enabled).map(r => r.mode), pins }),
});
sdrDirty = false;
msg.textContent = d.running === null ? "Saved, but the SDR service didn't respond." : 'Saved.';
await loadSdr();
} catch (e) {
msg.textContent = `Save failed: ${e.message}`;
btn.disabled = false;
}
}
refreshStatus(); setInterval(refreshStatus, 2000);
refreshHealth(); setInterval(refreshHealth, 5000);
loadSystems();
loadSdr(); setInterval(loadSdr, 10000);
</script>
</body>
</html>