[feat](trx-frontend-http): add APRS decoder plugin with sub-tab navigation

Replace server-side /frontends endpoint with client-side plugin system.
The Plugins tab now has Overview and APRS sub-tabs. The APRS plugin
decodes packets from RX audio using Bell 202 AFSK demodulation (1200
baud), AX.25 frame decoding with NRZI/HDLC, and CRC-16-CCITT
validation. Decoded packets are displayed in a scrolling log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-08 14:11:58 +01:00
parent 7eaa39ea4a
commit d44390db00
4 changed files with 389 additions and 17 deletions
@@ -615,17 +615,367 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => {
connect();
// --- Plugins tab ---
fetch("/frontends").then(r => r.json()).then(names => {
const list = document.getElementById("plugins-list");
if (!Array.isArray(names) || names.length === 0) {
list.innerHTML = '<div class="plugin-item" style="color:var(--text-muted);">No frontends registered</div>';
// --- Sub-tab navigation (Plugins tab) ---
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
bar.addEventListener("click", (e) => {
const btn = e.target.closest(".sub-tab[data-subtab]");
if (!btn) return;
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const parent = bar.parentElement;
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
parent.querySelector(`#subtab-${btn.dataset.subtab}`).style.display = "";
});
});
// --- APRS Decoder Plugin ---
const aprsToggleBtn = document.getElementById("aprs-toggle-btn");
const aprsStatus = document.getElementById("aprs-status");
const aprsPacketsEl = document.getElementById("aprs-packets");
const APRS_MAX_PACKETS = 100;
let aprsActive = false;
let aprsWs = null;
let aprsAudioCtx = null;
let aprsDecoder = null;
// CRC-16-CCITT lookup table
const CRC_CCITT_TABLE = new Uint16Array(256);
(function initCrc() {
for (let i = 0; i < 256; i++) {
let crc = i;
for (let j = 0; j < 8; j++) {
crc = (crc & 1) ? ((crc >>> 1) ^ 0x8408) : (crc >>> 1);
}
CRC_CCITT_TABLE[i] = crc;
}
})();
function crc16ccitt(bytes) {
let crc = 0xFFFF;
for (let i = 0; i < bytes.length; i++) {
crc = (crc >>> 8) ^ CRC_CCITT_TABLE[(crc ^ bytes[i]) & 0xFF];
}
return crc ^ 0xFFFF;
}
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
function createDemodulator(sampleRate) {
const BAUD = 1200;
const MARK = 1200;
const SPACE = 2200;
const samplesPerBit = sampleRate / BAUD;
const windowLen = Math.round(samplesPerBit);
// Correlation buffers
let markI = 0, markQ = 0, spaceI = 0, spaceQ = 0;
const ringLen = windowLen;
const ringMarkI = new Float32Array(ringLen);
const ringMarkQ = new Float32Array(ringLen);
const ringSpaceI = new Float32Array(ringLen);
const ringSpaceQ = new Float32Array(ringLen);
let ringIdx = 0;
// Clock recovery
let sampleCount = 0;
let lastBit = 0;
let bitPhase = 0;
// HDLC state
let ones = 0;
let frameBits = [];
let inFrame = false;
let shiftReg = 0;
let bitCount = 0;
const frames = [];
function processSample(s) {
const t = sampleCount / sampleRate;
const mI = s * Math.cos(2 * Math.PI * MARK * t);
const mQ = s * Math.sin(2 * Math.PI * MARK * t);
const sI = s * Math.cos(2 * Math.PI * SPACE * t);
const sQ = s * Math.sin(2 * Math.PI * SPACE * t);
// Sliding window correlation
markI += mI - ringMarkI[ringIdx];
markQ += mQ - ringMarkQ[ringIdx];
spaceI += sI - ringSpaceI[ringIdx];
spaceQ += sQ - ringSpaceQ[ringIdx];
ringMarkI[ringIdx] = mI;
ringMarkQ[ringIdx] = mQ;
ringSpaceI[ringIdx] = sI;
ringSpaceQ[ringIdx] = sQ;
ringIdx = (ringIdx + 1) % ringLen;
const markEnergy = markI * markI + markQ * markQ;
const spaceEnergy = spaceI * spaceI + spaceQ * spaceQ;
const bit = markEnergy > spaceEnergy ? 1 : 0;
// Clock recovery via zero-crossing
if (bit !== lastBit) {
lastBit = bit;
// Nudge phase toward center of bit
bitPhase = samplesPerBit / 2;
}
bitPhase--;
if (bitPhase <= 0) {
bitPhase += samplesPerBit;
processBit(bit);
}
sampleCount++;
}
function processBit(rawBit) {
// NRZI decode: no transition = 1, transition = 0
// We track previous raw bit; same = 1, different = 0
const nrziBit = (rawBit === lastBit) ? 1 : 0;
// Check for flag (0x7E = 01111110 in bit order)
shiftReg = ((shiftReg >> 1) | (rawBit << 7)) & 0xFF;
bitCount++;
if (nrziBit === 1) {
ones++;
} else {
if (ones === 5) {
// Bit stuffing — skip this zero
ones = 0;
return;
}
ones = 0;
}
if (shiftReg === 0x7E) {
// Flag detected
if (inFrame && frameBits.length >= 136) {
// Minimum AX.25 frame: 14 addr + 1 ctrl + 1 pid + 1 info + 2 fcs = 19 bytes = 152 bits
// But we check >= 136 bits (17 bytes) to be lenient
const frame = bitsToBytes(frameBits);
if (frame) frames.push(frame);
}
frameBits = [];
inFrame = true;
ones = 0;
return;
}
if (inFrame) {
frameBits.push(nrziBit);
}
}
function bitsToBytes(bits) {
const byteLen = Math.floor(bits.length / 8);
if (byteLen < 17) return null;
const bytes = new Uint8Array(byteLen);
for (let i = 0; i < byteLen; i++) {
let b = 0;
for (let j = 0; j < 8; j++) {
b |= (bits[i * 8 + j] << j);
}
bytes[i] = b;
}
// Verify FCS (last 2 bytes)
const payload = bytes.subarray(0, byteLen - 2);
const fcs = bytes[byteLen - 2] | (bytes[byteLen - 1] << 8);
const computed = crc16ccitt(payload);
if (computed !== fcs) return null;
return payload;
}
function processBuffer(samples) {
for (let i = 0; i < samples.length; i++) {
processSample(samples[i]);
}
const result = frames.splice(0);
return result;
}
return { processBuffer };
}
// AX.25 address extraction
function decodeAX25Address(bytes, offset) {
let call = "";
for (let i = 0; i < 6; i++) {
const ch = bytes[offset + i] >> 1;
if (ch > 32) call += String.fromCharCode(ch);
}
call = call.trimEnd();
const ssid = (bytes[offset + 6] >> 1) & 0x0F;
const last = (bytes[offset + 6] & 0x01) === 1;
return { call, ssid, last };
}
function parseAX25(frame) {
if (frame.length < 16) return null;
const dest = decodeAX25Address(frame, 0);
const src = decodeAX25Address(frame, 7);
let offset = 14;
const digis = [];
let lastAddr = src.last;
while (!lastAddr && offset + 7 <= frame.length) {
const digi = decodeAX25Address(frame, offset);
digis.push(digi);
lastAddr = digi.last;
offset += 7;
}
if (offset + 2 > frame.length) return null;
const control = frame[offset];
const pid = frame[offset + 1];
const info = frame.subarray(offset + 2);
return { src, dest, digis, control, pid, info };
}
function parseAPRS(ax25) {
const srcCall = ax25.src.ssid ? `${ax25.src.call}-${ax25.src.ssid}` : ax25.src.call;
const destCall = ax25.dest.ssid ? `${ax25.dest.call}-${ax25.dest.ssid}` : ax25.dest.call;
const path = ax25.digis.map((d) => d.ssid ? `${d.call}-${d.ssid}` : d.call).join(",");
const infoStr = new TextDecoder().decode(ax25.info);
let type = "Unknown";
if (infoStr.length > 0) {
const dt = infoStr[0];
if (dt === "!" || dt === "=" || dt === "/" || dt === "@") type = "Position";
else if (dt === ":") type = "Message";
else if (dt === ">") type = "Status";
else if (dt === "T") type = "Telemetry";
else if (dt === ";") type = "Object";
else if (dt === ")") type = "Item";
else if (dt === "`" || dt === "'") type = "Mic-E";
}
return { srcCall, destCall, path, info: infoStr, type };
}
function addAprsPacket(pkt) {
const row = document.createElement("div");
row.className = "aprs-packet";
const now = new Date();
const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
row.innerHTML = `<span class="aprs-time">${ts}</span><span class="aprs-call">${pkt.srcCall}</span>&gt;${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${pkt.info}</span>`;
aprsPacketsEl.prepend(row);
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
}
}
function startAprs() {
if (aprsActive) { stopAprs(); return; }
if (!hasWebCodecs) {
aprsStatus.textContent = "Requires Chrome/Edge";
return;
}
list.innerHTML = names.map(n => `<div class="plugin-item">${n}</div>`).join("");
}).catch(err => {
console.error("Failed to fetch frontends", err);
});
const proto = location.protocol === "https:" ? "wss:" : "ws:";
aprsWs = new WebSocket(`${proto}//${location.host}/audio`);
aprsWs.binaryType = "arraybuffer";
aprsStatus.textContent = "Connecting…";
let demodulator = null;
aprsWs.onopen = () => {
aprsStatus.textContent = "Waiting for stream info…";
};
aprsWs.onmessage = (evt) => {
if (typeof evt.data === "string") {
try {
const info = JSON.parse(evt.data);
const sr = info.sample_rate || 48000;
const ch = info.channels || 1;
aprsAudioCtx = new AudioContext({ sampleRate: sr });
demodulator = createDemodulator(sr);
aprsDecoder = new AudioDecoder({
output: (frame) => {
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
frame.copyTo(buf, { planeIndex: 0 });
// Use first channel only
let mono;
if (frame.numberOfChannels === 1) {
mono = buf;
} else {
mono = new Float32Array(frame.numberOfFrames);
for (let i = 0; i < frame.numberOfFrames; i++) {
mono[i] = buf[i * frame.numberOfChannels];
}
}
const frames = demodulator.processBuffer(mono);
for (const f of frames) {
const ax25 = parseAX25(f);
if (!ax25) continue;
const pkt = parseAPRS(ax25);
addAprsPacket(pkt);
}
frame.close();
},
error: (e) => { console.error("APRS AudioDecoder error", e); }
});
aprsDecoder.configure({
codec: "opus",
sampleRate: sr,
numberOfChannels: ch,
});
aprsActive = true;
aprsToggleBtn.style.borderColor = "#00d17f";
aprsToggleBtn.style.color = "#00d17f";
aprsToggleBtn.textContent = "Stop APRS";
aprsStatus.textContent = "Listening…";
} catch (e) {
console.error("APRS stream info error", e);
aprsStatus.textContent = "Error";
}
return;
}
// Binary Opus data
if (!aprsDecoder) return;
try {
aprsDecoder.decode(new EncodedAudioChunk({
type: "key",
timestamp: performance.now() * 1000,
data: new Uint8Array(evt.data),
}));
} catch (e) {
// Ignore individual decode errors
}
};
aprsWs.onclose = () => {
stopAprs();
};
aprsWs.onerror = () => {
aprsStatus.textContent = "Connection error";
};
}
function stopAprs() {
aprsActive = false;
if (aprsWs) { aprsWs.close(); aprsWs = null; }
if (aprsAudioCtx) { aprsAudioCtx.close(); aprsAudioCtx = null; }
if (aprsDecoder) {
try { aprsDecoder.close(); } catch (e) {}
aprsDecoder = null;
}
aprsToggleBtn.style.borderColor = "";
aprsToggleBtn.style.color = "";
aprsToggleBtn.textContent = "Start APRS";
aprsStatus.textContent = "Stopped";
}
aprsToggleBtn.addEventListener("click", startAprs);
// --- Signal measurement ---
const sigMeasureBtn = document.getElementById("sig-measure-btn");
@@ -118,7 +118,25 @@
</div>
</div>
<div id="tab-plugins" class="tab-panel" style="display:none;">
<div id="plugins-list"></div>
<div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="overview">Overview</button>
<button class="sub-tab" data-subtab="aprs">APRS</button>
</div>
<div id="subtab-overview" class="sub-tab-panel">
<div class="plugin-item">
<strong>APRS Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Decodes APRS packets from RX audio using Bell 202 AFSK (1200 baud). Switch to the APRS tab to start.
</div>
</div>
</div>
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
<div class="aprs-controls">
<button id="aprs-toggle-btn" type="button">Start APRS</button>
<small id="aprs-status" style="color:var(--text-muted);">Stopped</small>
</div>
<div id="aprs-packets"></div>
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<table class="about-table">
@@ -209,6 +209,17 @@ small { color: var(--text-muted); }
cursor: pointer;
}
.sub-tab-bar { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 0.75rem; }
.sub-tab { background: transparent; border: none; border-bottom: 2px solid transparent; border-radius: 0; padding: 0.35rem 0.75rem; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; height: auto; }
.sub-tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
.sub-tab:hover:not(.active) { color: var(--text); }
.aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
#aprs-packets { max-height: 360px; overflow-y: auto; }
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
.aprs-packet:last-child { border-bottom: none; }
.aprs-call { color: var(--accent-green); font-weight: 600; }
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
button:focus-visible, input:focus-visible, select:focus-visible {
outline: 2px solid var(--accent-green);
outline-offset: 2px;
@@ -26,12 +26,6 @@ const FAVICON_BYTES: &[u8] = include_bytes!(concat!(
const LOGO_BYTES: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-logo.png"));
#[get("/frontends")]
pub async fn frontends_api() -> Result<impl Responder, Error> {
let names = trx_frontend::registered_frontends();
Ok(HttpResponse::Ok().json(names))
}
#[get("/status")]
pub async fn status_api(
state: web::Data<watch::Receiver<RigState>>,
@@ -235,7 +229,6 @@ pub async fn set_tx_limit(
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(index)
.service(frontends_api)
.service(status_api)
.service(events)
.service(toggle_power)