// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2024 Simon Ruderich const tracePlaceholder = Symbol('trace-placeholder'); const traceEvent = Symbol('trace-event'); const tracePacket = Symbol('trace-packet'); const traceMaxEvents = 200; const historyMaxPackets = 300; const state = { paused: false, global: { preambles_found: 0, successful_decodes: 0, failed_decodes: 0, header_errors: 0, }, }; function updatePause(pause) { const button = document.querySelector('#pause'); if (pause) { button.value = 'Continue'; } else { button.value = 'Pause'; // Hide line markers chartHistoryHz.options.plugins.lineMarker.index = undefined; chartHistoryDb.options.plugins.lineMarker.index = undefined; chartHistoryMisc.options.plugins.lineMarker.index = undefined; } state.paused = pause; } function updateGlobal() { for (const key in state.global) { document.querySelector(`#${key}`).textContent = state.global[key]; } } function getTime() { const x = new Date(); // HH:MM:SS.ms, thanks JavaScript const hh = ('0' + x.getHours()).slice(-2); const mm = ('0' + x.getMinutes()).slice(-2); const ss = ('0' + x.getSeconds()).slice(-2); const ms = ('00' + x.getMilliseconds()).slice(-3); return `${hh}:${mm}:${ss}.${ms}`; } function addTrace(type, data) { const span = document.createElement('span'); span.textContent = data.string; span.style.color = data.color; span.style.cursor = 'default'; if (type !== tracePlaceholder) { let title = getTime(); if (data.packet !== undefined) { title = `${data.packet._id} (${title})`; } span.title = title; } if (type === tracePacket) { span.onclick = () => { updatePause(true); updateChart(data.packet); updateData(data.packet); // Mark current packet in history plot const index = data.packet._id - chartHistoryHz.data.labels[0]; chartHistoryHz.options.plugins.lineMarker.index = index; chartHistoryDb.options.plugins.lineMarker.index = index; chartHistoryMisc.options.plugins.lineMarker.index = index; // Redraw chartConstellation.update(); chartHistoryHz.update(); chartHistoryDb.update(); chartHistoryMisc.update(); }; span.style.cursor = 'pointer'; } const trace = document.querySelector('#trace'); trace.prepend(span); if (trace.childNodes.length > traceMaxEvents) { trace.removeChild(trace.lastChild); } } const lineMarkerPlugin = { id: 'lineMarker', beforeDatasetsDraw: (chart, args, plugins) => { // Nothing drawn yet if (chart.scales.x === undefined) { return; } // Not yet configured to show a line if (plugins.index === undefined) { return; } const x = chart.scales.x.getPixelForValue(plugins.index); const ctx = chart.ctx; ctx.save(); ctx.beginPath(); ctx.strokeStyle = 'black'; ctx.lineWidth = 3; ctx.moveTo(x, chart.chartArea.top); ctx.lineTo(x, chart.chartArea.bottom); ctx.stroke(); }, }; const chartConstellation = new Chart( document.querySelector('#constellation-canvas'), { type: 'scatter', data: { datasets: [ { label: 'Preamble', data: [], }, { label: 'Header', data: [], }, { label: 'Data', data: [], }, ], }, options: { animation: false, aspectRatio: 1, elements: { point: { radius: 1, }, }, }, }); const chartHistoryHz = new Chart( document.querySelector('#history-hz-canvas'), { type: 'line', data: { labels: [], datasets: [], }, options: { animation: false, maintainAspectRatio: false, // use full width plugins: { lineMarker: {}, }, }, plugins: [ lineMarkerPlugin, ], }); const chartHistoryDb = new Chart( document.querySelector('#history-db-canvas'), { type: 'line', data: { labels: [], datasets: [], }, options: { animation: false, maintainAspectRatio: false, // use full width plugins: { lineMarker: {}, }, }, plugins: [ lineMarkerPlugin, ], }); const chartHistoryMisc = new Chart( document.querySelector('#history-misc-canvas'), { type: 'line', data: { labels: [], datasets: [], }, options: { animation: false, maintainAspectRatio: false, // use full width plugins: { lineMarker: {}, }, }, plugins: [ lineMarkerPlugin, ], }); function updateChart(packet) { chartConstellation.data.datasets[0].data = packet['preamble_symbols']; chartConstellation.data.datasets[1].data = packet['header_symbols']; chartConstellation.data.datasets[2].data = packet['data_symbols']; // .update() is deferred, see redrawPlots() } function updateData(packet) { const keys = []; for (const x in packet) { if (x === '_id' || x === 'preamble_symbols' || x === 'header_symbols' || x === 'data_symbols') { continue } keys.push(x); } keys.sort(); const divs = []; { const div = document.createElement('div'); div.textContent = `Packet: ${packet._id}`; divs.push(div); } for (const x of keys) { const div = document.createElement('div'); div.textContent = `${x}: ${packet[x]}`; divs.push(div); } document.querySelector('#data').replaceChildren(...divs); } function addHistory(packet) { const keysHz = [], keysDb = [], keysMisc = []; for (const x in packet) { if (x === '_id' || x === 'preamble_symbols' || x === 'header_symbols' || x === 'data_symbols') { continue; } if (x.endsWith('Hz')) { keysHz.push(x); } else if (x.endsWith('dB')) { keysDb.push(x); } else { keysMisc.push(x); } } keysHz.sort(); keysDb.sort(); keysMisc.sort(); if (chartHistoryHz.data.datasets.length === 0) { const run = (chart, keys) => { for (const x of keys) { chart.data.datasets.push({ label: x, data: [], }) } }; run(chartHistoryHz, keysHz); run(chartHistoryDb, keysDb); run(chartHistoryMisc, keysMisc); } // Limit to historyMaxPackets items const shift = chartHistoryHz.data.labels.length === historyMaxPackets; if (shift) { chartHistoryHz.data.labels.shift(); chartHistoryDb.data.labels.shift(); chartHistoryMisc.data.labels.shift(); } const run = (chart, keys) => { let i = 0; for (const x of keys) { let data = packet[x]; if (data === -1e38 /* "NaN" */) { data = undefined; } if (shift) { chart.data.datasets[i].data.shift(); } chart.data.datasets[i].data.push(data); i++; } chart.data.labels.push(packet._id); // .update() is deferred, see redrawPlots() }; run(chartHistoryHz, keysHz); run(chartHistoryDb, keysDb); run(chartHistoryMisc, keysMisc); } let redrawPending = false; function redrawPlots() { // Updating the plots is too slow for live updates for all received // messages. Instead of falling behind, skip redraws when the messages // arrive too quickly. if (redrawPending) { return; } redrawPending = true; window.setTimeout(() => { chartHistoryHz.update(); chartHistoryDb.update(); chartHistoryMisc.update(); chartConstellation.update(); redrawPending = false; }, 10 /* ms */); } let packet_id = 0; const ws = new WebSocket(location.origin.replace(/^http/, 'ws') + '/events'); ws.onmessage = (evt) => { if (state.paused) { return; } const msg = JSON.parse(evt.data); if ('preambles_found' in msg) { const old = {}; for (const x in state.global) { old[x] = state.global[x]; } for (const x in msg) { state.global[x] = msg[x]; old[x] = msg[x] - old[x]; // delta } updateGlobal(); // Update trace with new events for (const x in old) { if (old[x] === 0) { continue; } let string, color; if (x === 'preambles_found') { string = 'p'; color = 'gray'; } else if (x === 'successful_decodes') { // Nothing to do as packet will be added to trace continue; } else if (x === 'header_errors') { string = 'H'; color = 'red'; } else if (x === 'failed_decodes') { string = 'D'; color = 'red'; } else { console.log(`Unknown event "${x}"!`); continue; } addTrace(traceEvent, { string: string, color: color, }); } } else if ('data_symbols' in msg) { msg._id = packet_id++; updateChart(msg); updateData(msg); addHistory(msg); redrawPlots(); addTrace(tracePacket, { string: 'P', color: 'black', packet: msg, }); } }; ws.onclose = (evt) => { const div = document.querySelector('#error'); div.textContent = 'Websocket connection closed!'; div.hidden = false; // Log details console.log(evt); }; ws.onerror = ws.onclose; { const pause = document.querySelector('#pause'); pause.onclick = () => { updatePause(!state.paused); } updatePause(false); // Initialize trace for (let i = 0; i < traceMaxEvents; i++) { addTrace(tracePlaceholder, { string: '.', color: 'lightgray', }); } updateGlobal(); }