[feat](trx-frontend-http): add APRS symbols, map links, and correlation demodulator
- Add APRS symbol icons using hessu/aprs-symbols sprite sheets - Parse uncompressed and compressed position formats for lat/lon - Render clickable OpenStreetMap links for position packets - Replace delay-and-multiply discriminator with mark/space correlation detector for more robust AFSK decoding - Reduce PLL gain from 0.7 to 0.4 for stable clock recovery - Move plugin JS files to plugins/ subdirectory Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -1,287 +0,0 @@
|
|||||||
// --- CW (Morse) Decoder Plugin ---
|
|
||||||
const cwToggleBtn = document.getElementById("cw-toggle-btn");
|
|
||||||
const cwStatusEl = document.getElementById("cw-status");
|
|
||||||
const cwOutputEl = document.getElementById("cw-output");
|
|
||||||
const cwWpmInput = document.getElementById("cw-wpm");
|
|
||||||
const cwToneInput = document.getElementById("cw-tone");
|
|
||||||
const cwThresholdInput = document.getElementById("cw-threshold");
|
|
||||||
const cwThresholdVal = document.getElementById("cw-threshold-val");
|
|
||||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
|
||||||
const CW_MAX_LINES = 200;
|
|
||||||
|
|
||||||
let cwActive = false;
|
|
||||||
let cwWs = null;
|
|
||||||
let cwAudioCtx = null;
|
|
||||||
let cwDecoder = null;
|
|
||||||
|
|
||||||
// ITU Morse code lookup
|
|
||||||
const MORSE_TABLE = {
|
|
||||||
".-": "A", "-...": "B", "-.-.": "C", "-..": "D", ".": "E",
|
|
||||||
"..-.": "F", "--.": "G", "....": "H", "..": "I", ".---": "J",
|
|
||||||
"-.-": "K", ".-..": "L", "--": "M", "-.": "N", "---": "O",
|
|
||||||
".--.": "P", "--.-": "Q", ".-.": "R", "...": "S", "-": "T",
|
|
||||||
"..-": "U", "...-": "V", ".--": "W", "-..-": "X", "-.--": "Y",
|
|
||||||
"--..": "Z",
|
|
||||||
"-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4",
|
|
||||||
".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9",
|
|
||||||
".-.-.-": ".", "--..--": ",", "..--..": "?", ".----.": "'",
|
|
||||||
"-.-.--": "!", "-..-.": "/", "-.--.": "(", "-.--.-": ")",
|
|
||||||
".-...": "&", "---...": ":", "-.-.-.": ";", "-...-": "=",
|
|
||||||
".-.-.": "+", "-....-": "-", "..--.-": "_", ".-..-.": "\"",
|
|
||||||
"...-..-": "$", ".--.-.": "@",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update threshold display
|
|
||||||
cwThresholdInput.addEventListener("input", () => {
|
|
||||||
cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createCwDecoder(sampleRate) {
|
|
||||||
let wpm = parseInt(cwWpmInput.value, 10) || 15;
|
|
||||||
let toneFreq = parseInt(cwToneInput.value, 10) || 700;
|
|
||||||
let threshold = (parseInt(cwThresholdInput.value, 10) || 5) / 100;
|
|
||||||
|
|
||||||
// Goertzel parameters
|
|
||||||
const windowMs = 50; // 50ms analysis window
|
|
||||||
const windowSize = Math.round(sampleRate * windowMs / 1000);
|
|
||||||
const k = Math.round(toneFreq * windowSize / sampleRate);
|
|
||||||
const omega = (2 * Math.PI * k) / windowSize;
|
|
||||||
const coeff = 2 * Math.cos(omega);
|
|
||||||
|
|
||||||
let sampleBuf = new Float32Array(windowSize);
|
|
||||||
let sampleIdx = 0;
|
|
||||||
|
|
||||||
// Tone state tracking
|
|
||||||
let toneOn = false;
|
|
||||||
let toneOnAt = 0;
|
|
||||||
let toneOffAt = 0;
|
|
||||||
let currentSymbol = ""; // accumulates dits/dahs for current character
|
|
||||||
let decoded = "";
|
|
||||||
let lastAppendTime = 0;
|
|
||||||
|
|
||||||
// Timing: 1 unit = 1200/WPM ms
|
|
||||||
function unitMs() { return 1200 / wpm; }
|
|
||||||
|
|
||||||
function goertzelDetect(buf) {
|
|
||||||
let s0 = 0, s1 = 0, s2 = 0;
|
|
||||||
let totalEnergy = 0;
|
|
||||||
for (let i = 0; i < buf.length; i++) {
|
|
||||||
s0 = coeff * s1 - s2 + buf[i];
|
|
||||||
s2 = s1;
|
|
||||||
s1 = s0;
|
|
||||||
totalEnergy += buf[i] * buf[i];
|
|
||||||
}
|
|
||||||
const toneEnergy = (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (buf.length * buf.length);
|
|
||||||
const avgEnergy = totalEnergy / buf.length;
|
|
||||||
if (avgEnergy < 1e-10) return false;
|
|
||||||
return (toneEnergy / avgEnergy) > threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processWindow() {
|
|
||||||
const detected = goertzelDetect(sampleBuf);
|
|
||||||
const now = performance.now();
|
|
||||||
|
|
||||||
// Update signal indicator
|
|
||||||
if (detected) {
|
|
||||||
cwSignalIndicator.className = "cw-signal-on";
|
|
||||||
} else {
|
|
||||||
cwSignalIndicator.className = "cw-signal-off";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detected && !toneOn) {
|
|
||||||
// Tone just turned on
|
|
||||||
toneOn = true;
|
|
||||||
const offDuration = now - toneOffAt;
|
|
||||||
if (toneOffAt > 0) {
|
|
||||||
const u = unitMs();
|
|
||||||
if (offDuration > u * 5) {
|
|
||||||
// Word gap (7 units, use 5 as threshold)
|
|
||||||
if (currentSymbol) {
|
|
||||||
const ch = MORSE_TABLE[currentSymbol] || "?";
|
|
||||||
appendChar(ch);
|
|
||||||
currentSymbol = "";
|
|
||||||
}
|
|
||||||
appendChar(" ");
|
|
||||||
} else if (offDuration > u * 2) {
|
|
||||||
// Character gap (3 units, use 2 as threshold)
|
|
||||||
if (currentSymbol) {
|
|
||||||
const ch = MORSE_TABLE[currentSymbol] || "?";
|
|
||||||
appendChar(ch);
|
|
||||||
currentSymbol = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// else: inter-element gap, do nothing
|
|
||||||
}
|
|
||||||
toneOnAt = now;
|
|
||||||
} else if (!detected && toneOn) {
|
|
||||||
// Tone just turned off
|
|
||||||
toneOn = false;
|
|
||||||
const onDuration = now - toneOnAt;
|
|
||||||
const u = unitMs();
|
|
||||||
if (onDuration > u * 2) {
|
|
||||||
currentSymbol += "-"; // dah (3 units, use 2 as threshold)
|
|
||||||
} else {
|
|
||||||
currentSymbol += "."; // dit
|
|
||||||
}
|
|
||||||
toneOffAt = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush pending character after long silence
|
|
||||||
if (!toneOn && currentSymbol && toneOffAt > 0) {
|
|
||||||
const silenceDuration = now - toneOffAt;
|
|
||||||
if (silenceDuration > unitMs() * 5) {
|
|
||||||
const ch = MORSE_TABLE[currentSymbol] || "?";
|
|
||||||
appendChar(ch);
|
|
||||||
currentSymbol = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendChar(ch) {
|
|
||||||
decoded += ch;
|
|
||||||
// Append to output element
|
|
||||||
const now = Date.now();
|
|
||||||
if (!cwOutputEl.lastElementChild || now - lastAppendTime > 10000 || ch === "\n") {
|
|
||||||
const line = document.createElement("div");
|
|
||||||
line.className = "cw-line";
|
|
||||||
cwOutputEl.appendChild(line);
|
|
||||||
}
|
|
||||||
lastAppendTime = now;
|
|
||||||
const lastLine = cwOutputEl.lastElementChild;
|
|
||||||
if (lastLine) {
|
|
||||||
lastLine.textContent += ch;
|
|
||||||
}
|
|
||||||
// Cap lines
|
|
||||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
|
||||||
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
|
||||||
}
|
|
||||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processSamples(mono) {
|
|
||||||
for (let i = 0; i < mono.length; i++) {
|
|
||||||
sampleBuf[sampleIdx++] = mono[i];
|
|
||||||
if (sampleIdx >= windowSize) {
|
|
||||||
processWindow();
|
|
||||||
sampleIdx = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateConfig() {
|
|
||||||
wpm = parseInt(cwWpmInput.value, 10) || 15;
|
|
||||||
threshold = (parseInt(cwThresholdInput.value, 10) || 5) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { processSamples, updateConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCw() {
|
|
||||||
if (cwActive) { stopCw(); return; }
|
|
||||||
if (!hasWebCodecs) {
|
|
||||||
cwStatusEl.textContent = "Requires Chrome/Edge";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
cwWs = new WebSocket(`${proto}//${location.host}/audio`);
|
|
||||||
cwWs.binaryType = "arraybuffer";
|
|
||||||
cwStatusEl.textContent = "Connecting…";
|
|
||||||
|
|
||||||
let decoderEngine = null;
|
|
||||||
|
|
||||||
cwWs.onopen = () => {
|
|
||||||
cwStatusEl.textContent = "Waiting for stream info…";
|
|
||||||
};
|
|
||||||
|
|
||||||
cwWs.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;
|
|
||||||
cwAudioCtx = new AudioContext({ sampleRate: sr });
|
|
||||||
decoderEngine = createCwDecoder(sr);
|
|
||||||
|
|
||||||
let cwFrameCount = 0;
|
|
||||||
cwDecoder = new AudioDecoder({
|
|
||||||
output: (frame) => {
|
|
||||||
if (cwFrameCount++ === 0) {
|
|
||||||
console.log("[CW-DBG] First PCM frame:", frame.numberOfFrames, "samples,", frame.numberOfChannels, "ch, format:", frame.format, "sr:", frame.sampleRate);
|
|
||||||
}
|
|
||||||
const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels);
|
|
||||||
frame.copyTo(buf, { planeIndex: 0 });
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
decoderEngine.processSamples(mono);
|
|
||||||
frame.close();
|
|
||||||
},
|
|
||||||
error: (e) => { console.error("CW AudioDecoder error", e); }
|
|
||||||
});
|
|
||||||
cwDecoder.configure({
|
|
||||||
codec: "opus",
|
|
||||||
sampleRate: sr,
|
|
||||||
numberOfChannels: ch,
|
|
||||||
});
|
|
||||||
|
|
||||||
cwActive = true;
|
|
||||||
cwToggleBtn.style.borderColor = "#00d17f";
|
|
||||||
cwToggleBtn.style.color = "#00d17f";
|
|
||||||
cwToggleBtn.textContent = "Stop CW";
|
|
||||||
cwStatusEl.textContent = "Listening…";
|
|
||||||
|
|
||||||
// Allow live config updates
|
|
||||||
cwWpmInput.addEventListener("change", decoderEngine.updateConfig);
|
|
||||||
cwThresholdInput.addEventListener("input", decoderEngine.updateConfig);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("CW stream info error", e);
|
|
||||||
cwStatusEl.textContent = "Error";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary Opus data
|
|
||||||
if (!cwDecoder) return;
|
|
||||||
try {
|
|
||||||
cwDecoder.decode(new EncodedAudioChunk({
|
|
||||||
type: "key",
|
|
||||||
timestamp: performance.now() * 1000,
|
|
||||||
data: new Uint8Array(evt.data),
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore individual decode errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
cwWs.onclose = () => {
|
|
||||||
stopCw();
|
|
||||||
};
|
|
||||||
|
|
||||||
cwWs.onerror = () => {
|
|
||||||
cwStatusEl.textContent = "Connection error";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopCw() {
|
|
||||||
cwActive = false;
|
|
||||||
if (cwWs) { cwWs.close(); cwWs = null; }
|
|
||||||
if (cwAudioCtx) { cwAudioCtx.close(); cwAudioCtx = null; }
|
|
||||||
if (cwDecoder) {
|
|
||||||
try { cwDecoder.close(); } catch (e) {}
|
|
||||||
cwDecoder = null;
|
|
||||||
}
|
|
||||||
cwToggleBtn.style.borderColor = "";
|
|
||||||
cwToggleBtn.style.color = "";
|
|
||||||
cwToggleBtn.textContent = "Start CW";
|
|
||||||
cwStatusEl.textContent = "Stopped";
|
|
||||||
cwSignalIndicator.className = "cw-signal-off";
|
|
||||||
}
|
|
||||||
|
|
||||||
cwToggleBtn.addEventListener("click", startCw);
|
|
||||||
+178
-56
@@ -30,7 +30,7 @@ function crc16ccitt(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
|
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
|
||||||
// Uses delay-and-multiply frequency discriminator for robust non-coherent decoding.
|
// Uses mark/space correlation detector (non-coherent FSK matched filter).
|
||||||
function createDemodulator(sampleRate) {
|
function createDemodulator(sampleRate) {
|
||||||
const BAUD = 1200;
|
const BAUD = 1200;
|
||||||
const MARK = 1200;
|
const MARK = 1200;
|
||||||
@@ -52,35 +52,27 @@ function createDemodulator(sampleRate) {
|
|||||||
const ENERGY_WINDOW = Math.round(sampleRate * 0.05);
|
const ENERGY_WINDOW = Math.round(sampleRate * 0.05);
|
||||||
const ENERGY_THRESHOLD = 0.001;
|
const ENERGY_THRESHOLD = 0.001;
|
||||||
|
|
||||||
// Bandpass pre-filter: biquad centered at 1700 Hz (AFSK midpoint), Q=1.2
|
// Mark/space correlation detector
|
||||||
// Passes ~1000-2500 Hz, removes out-of-band noise
|
// Mix input with cos/sin reference oscillators at mark and space frequencies,
|
||||||
const bpfW0 = 2 * Math.PI * ((MARK + SPACE) / 2) / sampleRate;
|
// then integrate over one bit period to get I/Q energy at each frequency.
|
||||||
const bpfAlpha = Math.sin(bpfW0) / (2 * 1.2);
|
const markPhaseInc = 2 * Math.PI * MARK / sampleRate;
|
||||||
const bpfA0 = 1 + bpfAlpha;
|
const spacePhaseInc = 2 * Math.PI * SPACE / sampleRate;
|
||||||
const bpfCoeffs = {
|
let markPhase = 0;
|
||||||
b0: bpfAlpha / bpfA0, b1: 0, b2: -bpfAlpha / bpfA0,
|
let spacePhase = 0;
|
||||||
a1: (-2 * Math.cos(bpfW0)) / bpfA0, a2: (1 - bpfAlpha) / bpfA0,
|
|
||||||
};
|
|
||||||
let bpfState = [0, 0, 0, 0]; // x1, x2, y1, y2
|
|
||||||
|
|
||||||
// Delay-and-multiply discriminator
|
// Sliding-window matched filter (1 bit period)
|
||||||
// Delay = Fs / (2 * Δf) where Δf = space - mark = 1000 Hz
|
const corrLen = Math.round(samplesPerBit);
|
||||||
// At this delay: mark tone → negative product, space tone → positive product
|
const markIBuf = new Float32Array(corrLen);
|
||||||
const DELAY = Math.round(sampleRate / (2 * (SPACE - MARK)));
|
const markQBuf = new Float32Array(corrLen);
|
||||||
const delayBuf = new Float32Array(DELAY);
|
const spaceIBuf = new Float32Array(corrLen);
|
||||||
let delayIdx = 0;
|
const spaceQBuf = new Float32Array(corrLen);
|
||||||
|
let corrIdx = 0;
|
||||||
// Moving-average LPF over half a bit period
|
let markISum = 0, markQSum = 0, spaceISum = 0, spaceQSum = 0;
|
||||||
// First null at 2*mark freq (2400 Hz), cleanly removing discriminator artifacts
|
|
||||||
const avgLen = Math.round(samplesPerBit / 2);
|
|
||||||
const avgBuf = new Float32Array(avgLen);
|
|
||||||
let avgIdx = 0;
|
|
||||||
let avgSum = 0;
|
|
||||||
|
|
||||||
// Clock recovery (PLL)
|
// Clock recovery (PLL)
|
||||||
let lastTone = 0;
|
let lastBit = 0;
|
||||||
let bitPhase = 0;
|
let bitPhase = 0;
|
||||||
const PLL_GAIN = 0.7;
|
const PLL_GAIN = 0.4;
|
||||||
|
|
||||||
// NRZI state
|
// NRZI state
|
||||||
let prevSampledBit = 0;
|
let prevSampledBit = 0;
|
||||||
@@ -93,13 +85,14 @@ function createDemodulator(sampleRate) {
|
|||||||
const frames = [];
|
const frames = [];
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
bpfState = [0, 0, 0, 0];
|
markPhase = 0;
|
||||||
delayBuf.fill(0);
|
spacePhase = 0;
|
||||||
delayIdx = 0;
|
markIBuf.fill(0); markQBuf.fill(0);
|
||||||
avgBuf.fill(0);
|
spaceIBuf.fill(0); spaceQBuf.fill(0);
|
||||||
avgIdx = 0;
|
corrIdx = 0;
|
||||||
avgSum = 0;
|
markISum = 0; markQSum = 0;
|
||||||
lastTone = 0;
|
spaceISum = 0; spaceQSum = 0;
|
||||||
|
lastBit = 0;
|
||||||
bitPhase = 0;
|
bitPhase = 0;
|
||||||
prevSampledBit = 0;
|
prevSampledBit = 0;
|
||||||
ones = 0;
|
ones = 0;
|
||||||
@@ -119,31 +112,35 @@ function createDemodulator(sampleRate) {
|
|||||||
energyCount = 0;
|
energyCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bandpass pre-filter
|
// Mix with mark/space reference oscillators
|
||||||
const c = bpfCoeffs;
|
const mI = s * Math.cos(markPhase);
|
||||||
const filtered = c.b0 * s + c.b1 * bpfState[0] + c.b2 * bpfState[1]
|
const mQ = s * Math.sin(markPhase);
|
||||||
- c.a1 * bpfState[2] - c.a2 * bpfState[3];
|
const sI = s * Math.cos(spacePhase);
|
||||||
bpfState[1] = bpfState[0]; bpfState[0] = s;
|
const sQ = s * Math.sin(spacePhase);
|
||||||
bpfState[3] = bpfState[2]; bpfState[2] = filtered;
|
markPhase += markPhaseInc;
|
||||||
|
spacePhase += spacePhaseInc;
|
||||||
|
if (markPhase > 6.283185307) markPhase -= 6.283185307;
|
||||||
|
if (spacePhase > 6.283185307) spacePhase -= 6.283185307;
|
||||||
|
|
||||||
// Delay-and-multiply frequency discriminator
|
// Sliding-window integration (matched filter over 1 bit period)
|
||||||
const delayed = delayBuf[delayIdx];
|
markISum += mI - markIBuf[corrIdx];
|
||||||
delayBuf[delayIdx] = filtered;
|
markQSum += mQ - markQBuf[corrIdx];
|
||||||
delayIdx = (delayIdx + 1) % DELAY;
|
spaceISum += sI - spaceIBuf[corrIdx];
|
||||||
|
spaceQSum += sQ - spaceQBuf[corrIdx];
|
||||||
|
markIBuf[corrIdx] = mI;
|
||||||
|
markQBuf[corrIdx] = mQ;
|
||||||
|
spaceIBuf[corrIdx] = sI;
|
||||||
|
spaceQBuf[corrIdx] = sQ;
|
||||||
|
corrIdx = (corrIdx + 1) % corrLen;
|
||||||
|
|
||||||
const disc = filtered * delayed;
|
// Compare mark vs space energy (I²+Q²)
|
||||||
|
const markEnergy = markISum * markISum + markQSum * markQSum;
|
||||||
// Moving-average LPF
|
const spaceEnergy = spaceISum * spaceISum + spaceQSum * spaceQSum;
|
||||||
avgSum += disc - avgBuf[avgIdx];
|
const bit = markEnergy > spaceEnergy ? 1 : 0;
|
||||||
avgBuf[avgIdx] = disc;
|
|
||||||
avgIdx = (avgIdx + 1) % avgLen;
|
|
||||||
|
|
||||||
// mark (1200 Hz) → negative, space (2200 Hz) → positive
|
|
||||||
const bit = avgSum < 0 ? 1 : 0;
|
|
||||||
|
|
||||||
// PLL clock recovery
|
// PLL clock recovery
|
||||||
if (bit !== lastTone) {
|
if (bit !== lastBit) {
|
||||||
lastTone = bit;
|
lastBit = bit;
|
||||||
const error = bitPhase - samplesPerBit / 2;
|
const error = bitPhase - samplesPerBit / 2;
|
||||||
bitPhase -= PLL_GAIN * error;
|
bitPhase -= PLL_GAIN * error;
|
||||||
}
|
}
|
||||||
@@ -318,7 +315,117 @@ function parseAPRS(ax25) {
|
|||||||
else if (dt === "`" || dt === "'") type = "Mic-E";
|
else if (dt === "`" || dt === "'") type = "Mic-E";
|
||||||
}
|
}
|
||||||
|
|
||||||
return { srcCall, destCall, path, info: infoStr, type };
|
const result = { srcCall, destCall, path, info: infoStr, type };
|
||||||
|
|
||||||
|
if (type === "Position") {
|
||||||
|
const pos = parseAprsPosition(infoStr);
|
||||||
|
if (pos) {
|
||||||
|
result.lat = pos.lat;
|
||||||
|
result.lon = pos.lon;
|
||||||
|
result.symbolTable = pos.symbolTable;
|
||||||
|
result.symbolCode = pos.symbolCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAprsPosition(infoStr) {
|
||||||
|
if (infoStr.length < 1) return null;
|
||||||
|
const dt = infoStr[0];
|
||||||
|
let posStr;
|
||||||
|
|
||||||
|
if (dt === "!" || dt === "=") {
|
||||||
|
posStr = infoStr.substring(1);
|
||||||
|
} else if (dt === "/" || dt === "@") {
|
||||||
|
if (infoStr.length < 8) return null;
|
||||||
|
posStr = infoStr.substring(8);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posStr.length < 1) return null;
|
||||||
|
|
||||||
|
// Compressed format: first char is symbol table (not a digit)
|
||||||
|
// Layout: T YYYY XXXX C [cs T] — 10 chars minimum
|
||||||
|
const firstChar = posStr[0];
|
||||||
|
if (firstChar < "0" || firstChar > "9") {
|
||||||
|
return parseAprsCompressed(posStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncompressed: DDMM.MMN/DDDMM.MMEsYYY...
|
||||||
|
// Need at least: 8 lat + 1 table + 9 lon + 1 code = 19 chars
|
||||||
|
if (posStr.length < 19) return null;
|
||||||
|
|
||||||
|
const latStr = posStr.substring(0, 8); // DDMM.MMN
|
||||||
|
const symbolTable = posStr[8];
|
||||||
|
const lonStr = posStr.substring(9, 18); // DDDMM.MME
|
||||||
|
const symbolCode = posStr[18];
|
||||||
|
|
||||||
|
const lat = parseAprsLat(latStr);
|
||||||
|
const lon = parseAprsLon(lonStr);
|
||||||
|
if (lat === null || lon === null) return null;
|
||||||
|
|
||||||
|
return { lat, lon, symbolTable, symbolCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAprsCompressed(posStr) {
|
||||||
|
// Compressed position: SymTable(1) Lat(4) Lon(4) SymCode(1) = 10 chars min
|
||||||
|
if (posStr.length < 10) return null;
|
||||||
|
|
||||||
|
const symbolTable = posStr[0];
|
||||||
|
const latChars = posStr.substring(1, 5);
|
||||||
|
const lonChars = posStr.substring(5, 9);
|
||||||
|
const symbolCode = posStr[9];
|
||||||
|
|
||||||
|
// Base-91 decode: each char value = (ASCII - 33)
|
||||||
|
let latVal = 0;
|
||||||
|
let lonVal = 0;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const lc = latChars.charCodeAt(i) - 33;
|
||||||
|
const xc = lonChars.charCodeAt(i) - 33;
|
||||||
|
if (lc < 0 || lc > 90 || xc < 0 || xc > 90) return null;
|
||||||
|
latVal = latVal * 91 + lc;
|
||||||
|
lonVal = lonVal * 91 + xc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = 90 - latVal / 380926;
|
||||||
|
const lon = -180 + lonVal / 190463;
|
||||||
|
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: Math.round(lat * 1e6) / 1e6,
|
||||||
|
lon: Math.round(lon * 1e6) / 1e6,
|
||||||
|
symbolTable,
|
||||||
|
symbolCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAprsLat(s) {
|
||||||
|
// DDMM.MMN
|
||||||
|
if (s.length < 8) return null;
|
||||||
|
const deg = parseInt(s.substring(0, 2), 10);
|
||||||
|
const min = parseFloat(s.substring(2, 7));
|
||||||
|
const ns = s[7];
|
||||||
|
if (isNaN(deg) || isNaN(min)) return null;
|
||||||
|
let lat = deg + min / 60;
|
||||||
|
if (ns === "S" || ns === "s") lat = -lat;
|
||||||
|
else if (ns !== "N" && ns !== "n") return null;
|
||||||
|
return Math.round(lat * 1e6) / 1e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAprsLon(s) {
|
||||||
|
// DDDMM.MME
|
||||||
|
if (s.length < 9) return null;
|
||||||
|
const deg = parseInt(s.substring(0, 3), 10);
|
||||||
|
const min = parseFloat(s.substring(3, 8));
|
||||||
|
const ew = s[8];
|
||||||
|
if (isNaN(deg) || isNaN(min)) return null;
|
||||||
|
let lon = deg + min / 60;
|
||||||
|
if (ew === "W" || ew === "w") lon = -lon;
|
||||||
|
else if (ew !== "E" && ew !== "e") return null;
|
||||||
|
return Math.round(lon * 1e6) / 1e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAprsPacket(pkt) {
|
function addAprsPacket(pkt) {
|
||||||
@@ -330,7 +437,22 @@ function addAprsPacket(pkt) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
const crcTag = pkt.crcOk ? "" : ' <span style="color:var(--accent-red);">[CRC]</span>';
|
const crcTag = pkt.crcOk ? "" : ' <span style="color:var(--accent-red);">[CRC]</span>';
|
||||||
row.innerHTML = `<span class="aprs-time">${ts}</span><span class="aprs-call">${pkt.srcCall}</span>>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${pkt.info}</span>${crcTag}`;
|
let symbolHtml = "";
|
||||||
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
|
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||||
|
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||||
|
const col = code % 16;
|
||||||
|
const row2 = Math.floor(code / 16);
|
||||||
|
const bgX = -(col * 24);
|
||||||
|
const bgY = -(row2 * 24);
|
||||||
|
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||||
|
}
|
||||||
|
let posHtml = "";
|
||||||
|
if (pkt.lat != null && pkt.lon != null) {
|
||||||
|
const osmUrl = `https://www.openstreetmap.org/?mlat=${pkt.lat}&mlon=${pkt.lon}#map=15/${pkt.lat}/${pkt.lon}`;
|
||||||
|
posHtml = ` <a class="aprs-pos" href="${osmUrl}" target="_blank">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`;
|
||||||
|
}
|
||||||
|
row.innerHTML = `<span class="aprs-time">${ts}</span>${symbolHtml}<span class="aprs-call">${pkt.srcCall}</span>>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${pkt.info}</span>${posHtml}${crcTag}`;
|
||||||
aprsPacketsEl.prepend(row);
|
aprsPacketsEl.prepend(row);
|
||||||
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
|
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
|
||||||
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
|
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
|
||||||
@@ -218,6 +218,9 @@ small { color: var(--text-muted); }
|
|||||||
.aprs-packet:last-child { border-bottom: none; }
|
.aprs-packet:last-child { border-bottom: none; }
|
||||||
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
||||||
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||||
|
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
|
||||||
|
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
|
||||||
|
.aprs-pos:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
|
||||||
.cw-config { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
.cw-config { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||||||
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
||||||
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
||||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
||||||
pub const APRS_JS: &str = include_str!("../assets/web/aprs.js");
|
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
|
||||||
pub const CW_JS: &str = include_str!("../assets/web/cw.js");
|
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
|
||||||
|
|
||||||
pub fn index_html() -> String {
|
pub fn index_html() -> String {
|
||||||
INDEX_HTML
|
INDEX_HTML
|
||||||
|
|||||||
Reference in New Issue
Block a user