Files
node-26/drb-edge-node/app/templates/index.html
T
Logan CusanoandClaude Opus 5.5 e2a5b63067
CI / lint (push) Successful in 21s
CI / test-op25 (push) Successful in 28s
CI / test (push) Successful in 1m0s
edge-node: Trunk Recorder backend behind RECORDER_BACKEND (node-26#45)
RECORDER_BACKEND=trunk-recorder (default op25, unchanged):
- tr_config: TR config.json + talkgroup/channel CSVs from the C2 system
  config (P25 trunked, P25 conventional, analog with squelch).
- tr_calls: TR call JSON -> MQTT call_start/call_end + /upload fields
  (talkgroup, start/stop, freq, units from srcList, emergency, encrypted).
- trunk_recorder: inbox ingest through the #44 spool with deterministic
  call_ids, supervisor requests, live audio via simplestream -> pacat ->
  drb_sink for Discord (one talkgroup at a time).
- radio_backend: start/stop dispatch so OP25 never grabs the SDR, and the
  OP25 watcher/PulseAudio recorder stay off (no double uploads).
- health/dashboard/checkin show the active backend and TR health.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:21:48 -04:00

633 lines
32 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>Recorder</dt><dd id="now-backend">—</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 class="field">
<label for="an-sq-mode">Squelch</label>
<select id="an-sq-mode">
<option value="noise" selected>Noise (recommended)</option>
<option value="voice">Voice (noise + speech detector)</option>
<option value="power">Power (old, needs calibration)</option>
</select>
<span class="hint">Noise squelch opens on a real carrier and needs no tuning per site.</span>
</div>
<div class="field">
<label for="an-sq-level">Squelch level (dB) <span class="hint">(optional)</span></label>
<input type="text" id="an-sq-level" class="mono" placeholder="8" inputmode="decimal">
<span class="hint" id="an-sq-hint">Noise/voice: dB of quieting to open — 8 default, 12-15 for strong signals only, ~5 for weak ones.</span>
</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.';
// 'recorder' is the Trunk Recorder backend's check (node-26#45).
const op = d.checks.find(c => c.id === 'op25' || c.id === 'recorder');
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';
const isTR = d.recorder_backend === 'trunk-recorder';
const trState = isTR && d.trunk_recorder && d.trunk_recorder.status ? d.trunk_recorder.status.state : null;
$('now-backend').textContent = isTR
? `Trunk Recorder${trState ? ` (${trState.replace(/_/g, ' ')})` : ' (not running)'} — every talkgroup recorded at once`
: 'OP25 — one call at a time';
recorderName = isTR ? 'Trunk Recorder' : 'OP25';
$('op25-start').textContent = `Start ${recorderName}`;
$('op25-stop').textContent = `Stop ${recorderName}`;
if ($('op25-log').hidden) $('log-toggle').textContent = `Show ${recorderName} log`;
$('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 / Trunk Recorder controls + log ───────────────────────────────
let recorderName = 'OP25';
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') : `${recorderName} 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 ${recorderName} 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 ${recorderName} log`;
$('log-toggle').setAttribute('aria-expanded', 'false');
}
// ── Listen locally ─────────────────────────────────────────────────────
let systems = [];
function mode() { return document.querySelector('input[name=mode]:checked').value; }
$('an-sq-mode').addEventListener('change', () => {
const power = $('an-sq-mode').value === 'power';
$('an-sq-level').placeholder = power ? '-60' : '8';
$('an-sq-hint').textContent = power
? 'Power: absolute signal level in dB to open (-60 default); depends on this SDR, gain and antenna.'
: 'Noise/voice: dB of quieting to open — 8 default, 12-15 for strong signals only, ~5 for weak ones.';
});
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; }
const sqMode = $('an-sq-mode').value;
const sqRaw = $('an-sq-level').value.trim();
const sqLevel = sqRaw === '' ? null : Number(sqRaw);
if (sqLevel !== null && isNaN(sqLevel)) { msg.className = 'msg err'; msg.textContent = 'Squelch level must be a number of dB, e.g. 8 (or -60 for power mode).'; return; }
body = { mode: 'analog', frequencies: freqs, name: $('an-name').value, squelch_mode: sqMode };
if (sqLevel !== null) body[sqMode === 'power' ? 'squelch_threshold' : 'squelch_db'] = sqLevel;
}
$('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>