This commit is contained in:
2026-05-10 12:24:36 +02:00
parent 703b08b1b7
commit 897b156a53
2 changed files with 306 additions and 326 deletions
+4 -5
View File
@@ -186,19 +186,18 @@ async def socket_handler(socket: asyncio.StreamReader, writer: asyncio.StreamWri
while True: while True:
qdata, ws = await writer_q.get() qdata, ws = await writer_q.get()
if not qdata or not ws: break if not qdata or not ws: break
try: try:
writer.write(qdata) writer.write(qdata)
await writer.drain() await writer.drain()
data = await socket.read(256) data = await socket.read(256)
if not data: if not data:
await ws.send(json.dumps({"event": "error", "error": "fm95 socket closed"})) await ws.send(json.dumps({"event": "error", "error": "fm95 socket closed"}))
break raise ConnectionResetError("fm95 socket closed") # ← let reconnect handle it
await ws.send(json.dumps({"event": "fm95", "data": base64.b64encode(data).decode()})) await ws.send(json.dumps({"event": "fm95", "data": base64.b64encode(data).decode()}))
except Exception as e: except Exception as e:
await ws.send(json.dumps({"event": "error", "error": str(e)})) try: await ws.send(json.dumps({"event": "error", "error": str(e)}))
break except Exception: pass
raise
def websocket_server_process(shared_data: dict, imc_q: multiprocessing.Queue, ws_q: multiprocessing.Queue): def websocket_server_process(shared_data: dict, imc_q: multiprocessing.Queue, ws_q: multiprocessing.Queue):
async def runner(): async def runner():
+302 -321
View File
@@ -1,351 +1,332 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FM95 Processor Status</title> <title>FM95 Processor Status</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
background: #0a0e1a; background: #0a0e1a;
color: #e0e6f0; color: #e0e6f0;
font-family: 'Segoe UI', system-ui, sans-serif; font-family: 'Segoe UI', system-ui, sans-serif;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 30px 16px; padding: 30px 16px;
} }
header { text-align: center; margin-bottom: 22px; } header { text-align: center; margin-bottom: 22px; }
header h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: #7eb8f7; } header h1 { font-size: 1.5rem; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: #7eb8f7; }
header p { font-size: 0.75rem; color: #4a5a7a; margin-top: 4px; letter-spacing: 1px; text-transform: uppercase; } header p { font-size: 0.75rem; color: #4a5a7a; margin-top: 4px; letter-spacing: 1px; text-transform: uppercase; }
#status-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 22px; font-size: 0.8rem; color: #4a5a7a; } #status-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 22px; font-size: 0.8rem; color: #4a5a7a; }
#status-dot { width: 9px; height: 9px; border-radius: 50%; background: #444; transition: background 0.3s; } #status-dot { width: 9px; height: 9px; border-radius: 50%; background: #444; transition: background 0.3s; }
#status-dot.connecting { background: #f0a500; box-shadow: 0 0 6px #f0a500; } #status-dot.connecting { background: #f0a500; box-shadow: 0 0 6px #f0a500; }
#status-dot.connected { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; } #status-dot.connected { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; }
#status-dot.error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; } #status-dot.error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px; gap: 14px;
width: 100%; width: 100%;
max-width: 960px; max-width: 960px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.card { .card {
background: #111827; background: #111827;
border: 1px solid #1e2d45; border: 1px solid #1e2d45;
border-radius: 12px; border-radius: 12px;
padding: 16px 18px; padding: 16px 18px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.card::before { .card::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; left: 0; right: 0; top: 0; left: 0; right: 0;
height: 3px; height: 3px;
background: var(--accent, #7eb8f7); background: var(--accent, #7eb8f7);
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
} }
.card-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 1.5px; color: #4a6080; margin-bottom: 8px; } .card-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 1.5px; color: #4a6080; margin-bottom: 8px; }
.card-value { font-size: 1.8rem; font-weight: 700; color: var(--accent, #7eb8f7); line-height: 1; font-variant-numeric: tabular-nums; } .card-value { font-size: 1.8rem; font-weight: 700; color: var(--accent, #7eb8f7); line-height: 1; font-variant-numeric: tabular-nums; }
.card-unit { font-size: 0.8rem; color: #3a5070; margin-left: 3px; font-weight: 400; } .card-unit { font-size: 0.8rem; color: #3a5070; margin-left: 3px; font-weight: 400; }
.bar-wrap { margin-top: 10px; background: #0a0e1a; border-radius: 4px; height: 5px; overflow: hidden; } .bar-wrap { margin-top: 10px; background: #0a0e1a; border-radius: 4px; height: 5px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; background: var(--accent, #7eb8f7); width: 0%; transition: width 0.4s ease; } .bar-fill { height: 100%; border-radius: 4px; background: var(--accent, #7eb8f7); width: 0%; transition: width 0.4s ease; }
.card-desc { font-size: 0.68rem; color: #2e4060; margin-top: 6px; } .card-desc { font-size: 0.68rem; color: #2e4060; margin-top: 6px; }
/* Oscilloscope */ /* Oscilloscope */
.scope-wrap { .scope-wrap {
width: 100%; width: 100%;
max-width: 960px; max-width: 960px;
background: #0b1120; background: #0b1120;
border: 1px solid #1e2d45; border: 1px solid #1e2d45;
border-radius: 14px; border-radius: 14px;
padding: 16px 18px; padding: 16px 18px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.scope-header { .scope-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
} }
.scope-title { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1.5px; color: #4a6080; } .scope-title { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1.5px; color: #4a6080; }
.scope-legend { display: flex; gap: 18px; } .scope-legend { display: flex; gap: 18px; }
.scope-legend span { font-size: 0.7rem; display: flex; align-items: center; gap: 5px; } .scope-legend span { font-size: 0.7rem; display: flex; align-items: center; gap: 5px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
canvas#scope { canvas#scope {
width: 100%; width: 100%;
height: 200px; height: 200px;
display: block; display: block;
border-radius: 6px; border-radius: 6px;
background: #060b14; background: #060b14;
image-rendering: pixelated; image-rendering: pixelated;
} }
.scope-controls { display: flex; gap: 16px; margin-top: 10px; align-items: center; } .scope-controls { display: flex; gap: 16px; margin-top: 10px; align-items: center; }
.scope-controls label { font-size: 0.72rem; color: #4a6080; } .scope-controls label { font-size: 0.72rem; color: #4a6080; }
.scope-controls select, .scope-controls input { .scope-controls select, .scope-controls input {
background: #111827; color: #7eb8f7; border: 1px solid #1e2d45; background: #111827; color: #7eb8f7; border: 1px solid #1e2d45;
border-radius: 6px; padding: 3px 8px; font-size: 0.72rem; border-radius: 6px; padding: 3px 8px; font-size: 0.72rem;
} }
#last-update { font-size: 0.7rem; color: #2e4060; } #last-update { font-size: 0.7rem; color: #2e4060; }
</style> </style>
</head> </head>
<body> <body>
<header>
<h1>FM95 Processor</h1>
<p>Live Status Monitor</p>
</header>
<header> <div id="status-bar">
<h1>FM95 Processor</h1> <div id="status-dot" class="connecting"></div>
<p>Live Status Monitor</p> <span id="status-text">Connecting…</span>
</header> </div>
<div id="status-bar"> <div class="grid">
<div id="status-dot" class="connecting"></div> <div class="card" style="--accent:#a78bfa">
<span id="status-text">Connecting…</span> <div class="card-label">MPX Power</div>
</div> <div class="card-value" id="mpx_power"><span class="card-unit">dB</span></div>
<div class="bar-wrap"><div class="bar-fill" id="bar_mpx_power"></div></div>
<div class="card-desc">Multiplex signal power · dBr</div>
</div>
<div class="card" style="--accent:#34d399">
<div class="card-label">BS412 Gain</div>
<div class="card-value" id="bs412_gain"></div>
<div class="bar-wrap"><div class="bar-fill" id="bar_bs412_gain"></div></div>
<div class="card-desc">BS412 loudness gain · linear</div>
</div>
<div class="card" style="--accent:#fb923c">
<div class="card-label">AGC Gain</div>
<div class="card-value" id="agc_gain"></div>
<div class="bar-wrap"><div class="bar-fill" id="bar_agc_gain"></div></div>
<div class="card-desc">Automatic gain control · linear</div>
</div>
</div>
<div class="grid"> <!-- Oscilloscope -->
<div class="card" style="--accent:#a78bfa"> <div class="scope-wrap">
<div class="card-label">MPX Power</div> <div class="scope-header">
<div class="card-value" id="mpx_power"><span class="card-unit">dB</span></div> <div class="scope-title">Oscilloscope — Input &amp; Audio Level</div>
<div class="bar-wrap"><div class="bar-fill" id="bar_mpx_power"></div></div> <div class="scope-legend">
<div class="card-desc">Multiplex signal power · dBr</div> <span><span class="dot" style="background:#f472b6"></span>Input Level</span>
</div> <span><span class="dot" style="background:#38bdf8"></span>Audio Level</span>
<div class="card" style="--accent:#34d399"> <span><span class="dot" style="background:#fb923c"></span>AGC</span>
<div class="card-label">BS412 Gain</div> </div>
<div class="card-value" id="bs412_gain"></div> </div>
<div class="bar-wrap"><div class="bar-fill" id="bar_bs412_gain"></div></div> <canvas id="scope"></canvas>
<div class="card-desc">BS412 loudness gain · linear</div> <div class="scope-controls">
</div> <label>History:
<div class="card" style="--accent:#fb923c"> <select id="histSel">
<div class="card-label">AGC Gain</div> <option value="100">100 pts</option>
<div class="card-value" id="agc_gain"></div> <option value="200" selected>200 pts</option>
<div class="bar-wrap"><div class="bar-fill" id="bar_agc_gain"></div></div> <option value="400">400 pts</option>
<div class="card-desc">Automatic gain control · linear</div> </select>
</div> </label>
</div> <label style="margin-left:auto; display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="gridCb" checked> Grid
</label>
</div>
</div>
<!-- Oscilloscope --> <div id="last-update">Waiting for data…</div>
<div class="scope-wrap">
<div class="scope-header">
<div class="scope-title">Oscilloscope — Input &amp; Audio Level</div>
<div class="scope-legend">
<span><span class="dot" style="background:#f472b6"></span>Input Level</span>
<span><span class="dot" style="background:#38bdf8"></span>Audio Level</span>
<span><span class="dot" style="background:#fb923c"></span>AGC</span>
</div>
</div>
<canvas id="scope"></canvas>
<div class="scope-controls">
<label>History:
<select id="histSel">
<option value="100">100 pts</option>
<option value="200" selected>200 pts</option>
<option value="400">400 pts</option>
</select>
</label>
<label>Y scale:
<select id="yscaleSel">
<option value="1">-1 1</option>
<option value="2">-2 2</option>
<option value="auto" selected>Auto</option>
</select>
</label>
<label style="margin-left:auto; display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="gridCb" checked> Grid
</label>
</div>
</div>
<div id="last-update">Waiting for data…</div> <script>
const POLL_MSG = JSON.stringify({ action: "fm95", data: "/w==" });
const FIELDS = ['mpx_power','bs412_gain','agc_gain','input_level','audio_level'];
const FIELD_CFG = {
mpx_power: { unit:'dB', fmt: v=>v.toFixed(3), bar: v=>Math.max(0,Math.min(100,(v/3)*100)), warn: v=>v>3 },
bs412_gain: { unit:'x', fmt: v=>v.toFixed(4), bar: v=>Math.max(0,Math.min(100,v*10)), warn: v=>v>2 },
agc_gain: { unit:'x', fmt: v=>v.toFixed(4), bar: v=>Math.max(0,Math.min(100,v*10)), warn: v=>v>3 },
};
<script> const MAX_PTS = 400;
const POLL_MSG = JSON.stringify({ action: "fm95", data: "/w==" }); let histInput = new Array(MAX_PTS).fill(null);
const FIELDS = ['mpx_power','bs412_gain','agc_gain','input_level','audio_level']; let histAudio = new Array(MAX_PTS).fill(null);
const FIELD_CFG = { let histAgc = new Array(MAX_PTS).fill(null);
mpx_power: { unit:'dB', fmt: v=>v.toFixed(3), bar: v=>Math.max(0,Math.min(100,(v/3)*100)), warn: v=>v>3 },
bs412_gain: { unit:'x', fmt: v=>v.toFixed(4), bar: v=>Math.max(0,Math.min(100,v*10)), warn: v=>v>2 },
agc_gain: { unit:'x', fmt: v=>v.toFixed(4), bar: v=>Math.max(0,Math.min(100,v*10)), warn: v=>v>3 },
};
const MAX_PTS = 400; const canvas = document.getElementById('scope');
let histInput = new Array(MAX_PTS).fill(null); const ctx = canvas.getContext('2d');
let histAudio = new Array(MAX_PTS).fill(null); const histSel = document.getElementById('histSel');
let histAgc = new Array(MAX_PTS).fill(null); const gridCb = document.getElementById('gridCb');
const canvas = document.getElementById('scope'); function resizeCanvas() {
const ctx = canvas.getContext('2d'); const r = window.devicePixelRatio || 1;
const histSel = document.getElementById('histSel'); const w = canvas.parentElement.clientWidth - 36;
const yscaleSel= document.getElementById('yscaleSel'); canvas.style.width = w + 'px';
const gridCb = document.getElementById('gridCb'); canvas.style.height = '200px';
canvas.width = w * r;
canvas.height = 200 * r;
ctx.scale(r, r);
} resizeCanvas();
window.addEventListener('resize', () => { ctx.setTransform(1,0,0,1,0,0); resizeCanvas(); drawScope(); });
function resizeCanvas() { function drawScope() {
const r = window.devicePixelRatio || 1; const W = canvas.width / (window.devicePixelRatio || 1);
const w = canvas.parentElement.clientWidth - 36; const H = canvas.height / (window.devicePixelRatio || 1);
canvas.style.width = w + 'px'; const nPts = parseInt(histSel.value);
canvas.style.height = '200px'; const showGrid = gridCb.checked;
canvas.width = w * r;
canvas.height = 200 * r;
ctx.scale(r, r);
}
resizeCanvas();
window.addEventListener('resize', () => { ctx.setTransform(1,0,0,1,0,0); resizeCanvas(); drawScope(); });
function drawScope() { ctx.clearRect(0, 0, W, H);
const W = canvas.width / (window.devicePixelRatio || 1);
const H = canvas.height / (window.devicePixelRatio || 1);
const nPts = parseInt(histSel.value);
const yscale = yscaleSel.value;
const showGrid = gridCb.checked;
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#060b14';
ctx.fillRect(0, 0, W, H);
// background const vals = [...histInput, ...histAudio, ...histAgc].filter(v => v !== null);
ctx.fillStyle = '#060b14'; if (vals.length) {
ctx.fillRect(0, 0, W, H); yMin = Math.min(...vals);
yMax = Math.max(...vals);
const pad = (yMax - yMin) * 0.1 || 0.05;
yMin -= pad; yMax += pad;
}
// determine y range const toY = v => H - ((v - yMin) / (yMax - yMin)) * H;
let yMin = -1, yMax = 1;
if (yscale === '2') { yMin = -2; yMax = 2; }
else if (yscale === 'auto') {
const vals = [...histInput, ...histAudio, ...histAgc].filter(v => v !== null);
if (vals.length) {
yMin = Math.min(...vals);
yMax = Math.max(...vals);
const pad = (yMax - yMin) * 0.1 || 0.05;
yMin -= pad; yMax += pad;
}
}
const toY = v => H - ((v - yMin) / (yMax - yMin)) * H; // grid
if (showGrid) {
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
const hLines = 5;
for (let i = 0; i <= hLines; i++) {
const y = (i / hLines) * H;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
const val = yMax - (i / hLines) * (yMax - yMin);
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = '10px monospace';
ctx.fillText(val.toFixed(2), 4, y - 3);
}
const vLines = 8;
for (let i = 1; i < vLines; i++) {
const x = (i / vLines) * W;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// zero line
if (yMin < 0 && yMax > 0) {
const y0 = toY(0);
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.moveTo(0, y0); ctx.lineTo(W, y0); ctx.stroke();
ctx.setLineDash([]);
}
}
// grid // draw series
if (showGrid) { function drawLine(hist, color) {
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; const slice = hist.slice(-nPts);
ctx.lineWidth = 1; const step = W / (nPts - 1);
const hLines = 5; ctx.strokeStyle = color;
for (let i = 0; i <= hLines; i++) { ctx.lineWidth = 1.5;
const y = (i / hLines) * H; ctx.beginPath();
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); let started = false;
const val = yMax - (i / hLines) * (yMax - yMin); for (let i = 0; i < slice.length; i++) {
ctx.fillStyle = 'rgba(255,255,255,0.25)'; if (slice[i] === null) { started = false; continue; }
ctx.font = '10px monospace'; const x = i * step;
ctx.fillText(val.toFixed(2), 4, y - 3); const y = toY(slice[i]);
} if (!started) { ctx.moveTo(x, y); started = true; }
const vLines = 8; else ctx.lineTo(x, y);
for (let i = 1; i < vLines; i++) { }
const x = (i / vLines) * W; ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); // glow
} ctx.strokeStyle = color + '44';
// zero line ctx.lineWidth = 4;
if (yMin < 0 && yMax > 0) { ctx.stroke();
const y0 = toY(0); }
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.setLineDash([4,4]);
ctx.beginPath(); ctx.moveTo(0, y0); ctx.lineTo(W, y0); ctx.stroke();
ctx.setLineDash([]);
}
}
// draw series drawLine(histInput, '#f472b6');
function drawLine(hist, color) { drawLine(histAudio, '#38bdf8');
const slice = hist.slice(-nPts); drawLine(histAgc, '#fb923c');
const step = W / (nPts - 1); }
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
let started = false;
for (let i = 0; i < slice.length; i++) {
if (slice[i] === null) { started = false; continue; }
const x = i * step;
const y = toY(slice[i]);
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
ctx.stroke();
// glow
ctx.strokeStyle = color + '44';
ctx.lineWidth = 4;
ctx.stroke();
}
drawLine(histInput, '#f472b6'); function decode(b64) {
drawLine(histAudio, '#38bdf8'); const bin = atob(b64);
drawLine(histAgc, '#fb923c'); const buf = new ArrayBuffer(bin.length);
} const u8 = new Uint8Array(buf);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
const view = new DataView(buf);
return FIELDS.map((_, i) => view.getFloat32(i * 4, true));
}
function decode(b64) { function updateUI(values) {
const bin = atob(b64); values.forEach((v, i) => {
const buf = new ArrayBuffer(bin.length); const key = FIELDS[i];
const u8 = new Uint8Array(buf); const cfg = FIELD_CFG[key];
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); if(cfg) {
const view = new DataView(buf); const el = document.getElementById(key);
return FIELDS.map((_, i) => view.getFloat32(i * 4, true)); const bar = document.getElementById('bar_' + key);
} el.innerHTML = `${cfg.fmt(v)}${cfg.unit ? `<span class="card-unit">${cfg.unit}</span>` : ''}`;
bar.style.width = cfg.bar(v) + '%';
bar.style.background = cfg.warn(v) ? '#ef4444' : '';
}
});
function updateUI(values) { // push to oscilloscope history
values.forEach((v, i) => { histAgc.push(values[2]); if (histAgc.length > MAX_PTS) histAgc.shift();
const key = FIELDS[i]; histInput.push(values[3]); if (histInput.length > MAX_PTS) histInput.shift();
const cfg = FIELD_CFG[key]; histAudio.push(values[4]); if (histAudio.length > MAX_PTS) histAudio.shift();
if(cfg) { drawScope();
const el = document.getElementById(key);
const bar = document.getElementById('bar_' + key);
el.innerHTML = `${cfg.fmt(v)}${cfg.unit ? `<span class="card-unit">${cfg.unit}</span>` : ''}`;
bar.style.width = cfg.bar(v) + '%';
bar.style.background = cfg.warn(v) ? '#ef4444' : '';
}
});
// push to oscilloscope history document.getElementById('last-update').textContent = `Last update: ${new Date().toLocaleTimeString()}`;
histAgc.push(values[2]); if (histAgc.length > MAX_PTS) histAgc.shift(); }
histInput.push(values[3]); if (histInput.length > MAX_PTS) histInput.shift();
histAudio.push(values[4]); if (histAudio.length > MAX_PTS) histAudio.shift();
drawScope();
document.getElementById('last-update').textContent = `Last update: ${new Date().toLocaleTimeString()}`; histSel.addEventListener('change', drawScope);
} gridCb.addEventListener('change', drawScope);
histSel.addEventListener('change', drawScope); function setStatus(state, text) {
yscaleSel.addEventListener('change', drawScope); const dot = document.getElementById('status-dot');
gridCb.addEventListener('change', drawScope); dot.className = '';
dot.classList.add(state);
document.getElementById('status-text').textContent = text;
}
// --- WebSocket --- let ws, pollTimer;
function setStatus(state, text) { function connect() {
const dot = document.getElementById('status-dot'); const proto = location.protocol === 'https:' ? 'wss' : 'ws';
dot.className = ''; ws = new WebSocket(`${proto}://${location.host}/ws`);
dot.classList.add(state); setStatus('connecting', 'Connecting…');
document.getElementById('status-text').textContent = text;
}
let ws, pollTimer; ws.addEventListener('open', () => {
function connect() { setStatus('connected', 'Connected');
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; poll();
ws = new WebSocket(`${proto}://${location.host}/ws`); pollTimer = setInterval(poll, 70 + (Math.random() * 30));
setStatus('connecting', 'Connecting…'); });
ws.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.event === 'fm95' && msg.data) updateUI(decode(msg.data));
} catch(err) { console.warn('Parse error', err); }
});
ws.addEventListener('close', () => {
clearInterval(pollTimer);
setStatus('error', 'Disconnected — retrying in 3s…');
setTimeout(connect, 3000);
});
ws.addEventListener('error', () => setStatus('error', 'WebSocket error'));
}
function poll() { if (ws && ws.readyState === WebSocket.OPEN) ws.send(POLL_MSG); }
ws.addEventListener('open', () => { connect();
setStatus('connected', 'Connected'); drawScope();
poll(); </script>
pollTimer = setInterval(poll, 70 + (Math.random() * 30)); </body>
});
ws.addEventListener('message', e => {
try {
const msg = JSON.parse(e.data);
if (msg.event === 'fm95' && msg.data) updateUI(decode(msg.data));
} catch(err) { console.warn('Parse error', err); }
});
ws.addEventListener('close', () => {
clearInterval(pollTimer);
setStatus('error', 'Disconnected — retrying in 3s…');
setTimeout(connect, 3000);
});
ws.addEventListener('error', () => setStatus('error', 'WebSocket error'));
}
function poll() { if (ws && ws.readyState === WebSocket.OPEN) ws.send(POLL_MSG); }
connect();
drawScope();
</script>
</body>
</html> </html>