Visualizer #3

Merged
thomas merged 12 commits from rudi_s into main 2024-05-28 11:21:00 +02:00
12 changed files with 697 additions and 15 deletions

View file

@ -1,3 +1,6 @@
// For F_SETPIPE_SZ
#define _GNU_SOURCE
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
#include <errno.h> #include <errno.h>
@ -29,6 +32,12 @@ static bool start_message(void)
} }
return false; return false;
} }
// Increase pipe buffer to prevent/reduce EAGAIN during a JSON
// message to prevent corrupted JSON messages. 1048576 is the
// current maximum as permitted by the Linux kernel.
if (fcntl(m_fifo_fd, F_SETPIPE_SZ, 1048576) < 0) {
perror("fcntl");
}
} }
ssize_t ret = write(m_fifo_fd, "{", 1); ssize_t ret = write(m_fifo_fd, "{", 1);

View file

@ -94,6 +94,9 @@ add_executable(
../src/var_array.c ../src/var_array.c
../src/var_array.h ../src/var_array.h
../src/config.h ../src/config.h
../src/jsonlogger.c
../src/jsonlogger.h
../src/debug_structs.h
layer1/test_rx_file.c layer1/test_rx_file.c
) )

View file

@ -4,6 +4,7 @@
#include <liquid/liquid.h> #include <liquid/liquid.h>
#include "jsonlogger.h"
#include "layer1/rx.h" #include "layer1/rx.h"
#include "config.h" #include "config.h"
@ -20,12 +21,9 @@
#define CHUNKSIZE_RF (CHUNKSIZE_INPUT/2) #define CHUNKSIZE_RF (CHUNKSIZE_INPUT/2)
#define CHUNKSIZE_BB (CHUNKSIZE_RF/SDR_OVERSAMPLING) #define CHUNKSIZE_BB (CHUNKSIZE_RF/SDR_OVERSAMPLING)
static struct { #define JSONLOGGER 0
size_t preambles_found;
size_t successful_decodes; static rx_stats_t m_rx_stats;
size_t failed_decodes;
size_t header_errors;
} m_stats;
static result_t sdr_rf_to_baseband(nco_crcf nco, firdecim_crcf decim, static result_t sdr_rf_to_baseband(nco_crcf nco, firdecim_crcf decim,
@ -69,11 +67,11 @@ void cb_rx(rx_evt_t evt, const layer1_rx_t *rx, uint8_t *packet_data, size_t pac
//fprintf(stderr, "=== FAILED PAYLOAD ===\n"); //fprintf(stderr, "=== FAILED PAYLOAD ===\n");
//hexdump(packet_data, packet_len); //hexdump(packet_data, packet_len);
//fprintf(stderr, "=======================\n"); //fprintf(stderr, "=======================\n");
m_stats.failed_decodes++; m_rx_stats.failed_decodes++;
break; break;
case RX_EVT_HEADER_ERROR: case RX_EVT_HEADER_ERROR:
m_stats.header_errors++; m_rx_stats.header_errors++;
break; break;
case RX_EVT_PACKET_RECEIVED: case RX_EVT_PACKET_RECEIVED:
@ -82,18 +80,24 @@ void cb_rx(rx_evt_t evt, const layer1_rx_t *rx, uint8_t *packet_data, size_t pac
//hexdump(packet_data, packet_len < 64 ? packet_len : 64); //hexdump(packet_data, packet_len < 64 ? packet_len : 64);
//fprintf(stderr, "====================================\n"); //fprintf(stderr, "====================================\n");
m_stats.successful_decodes++; m_rx_stats.successful_decodes++;
break; break;
case RX_EVT_PREAMBLE_FOUND: case RX_EVT_PREAMBLE_FOUND:
//fprintf(stderr, "Found preamble!\n"); //fprintf(stderr, "Found preamble!\n");
m_stats.preambles_found++; m_rx_stats.preambles_found++;
break; break;
case RX_EVT_PACKET_DEBUG_INFO_COMPLETE: case RX_EVT_PACKET_DEBUG_INFO_COMPLETE:
// FIXME: print debug info #if JSONLOGGER
jsonlogger_log_rx_packet_info(&rx->packet_debug_info);
#endif
break; break;
} }
#if JSONLOGGER
jsonlogger_log_rx_stats(&m_rx_stats);
#endif
} }
@ -112,6 +116,13 @@ int main(int argc, char **argv)
// ** Initialize ** // ** Initialize **
#if JSONLOGGER
if(!jsonlogger_init("jsonlog_test.fifo")) {
fprintf(stderr, "Could not initialize JSON logger.\n");
return EXIT_FAILURE;
}
#endif
firdecim_crcf decim = firdecim_crcf_create_kaiser(SDR_OVERSAMPLING, 9, 60.0f); firdecim_crcf decim = firdecim_crcf_create_kaiser(SDR_OVERSAMPLING, 9, 60.0f);
nco_crcf rx_nco = nco_crcf_create(LIQUID_NCO); nco_crcf rx_nco = nco_crcf_create(LIQUID_NCO);
nco_crcf_set_frequency(rx_nco, 2 * 3.14159 * SDR_RX_IF_SHIFT / SDR_RX_SAMPLING_RATE); nco_crcf_set_frequency(rx_nco, 2 * 3.14159 * SDR_RX_IF_SHIFT / SDR_RX_SAMPLING_RATE);
@ -147,13 +158,13 @@ int main(int argc, char **argv)
RESULT_CHECK(layer1_rx_process(&rx, bb_samples, n_bb_samples)); RESULT_CHECK(layer1_rx_process(&rx, bb_samples, n_bb_samples));
fprintf(stderr, "Receiver statistics:\n"); fprintf(stderr, "Receiver statistics:\n");
fprintf(stderr, " Preambles found: %8zd\n", m_stats.preambles_found); fprintf(stderr, " Preambles found: %8zd\n", m_rx_stats.preambles_found);
fprintf(stderr, " Successful decodes: %8zd (%6.2f %%)\n", fprintf(stderr, " Successful decodes: %8zd (%6.2f %%)\n",
m_stats.successful_decodes, m_stats.successful_decodes * 100.0f / m_stats.preambles_found); m_rx_stats.successful_decodes, m_rx_stats.successful_decodes * 100.0f / m_rx_stats.preambles_found);
fprintf(stderr, " Header errors: %8zd (%6.2f %%)\n", fprintf(stderr, " Header errors: %8zd (%6.2f %%)\n",
m_stats.header_errors, m_stats.header_errors * 100.0f / m_stats.preambles_found); m_rx_stats.header_errors, m_rx_stats.header_errors * 100.0f / m_rx_stats.preambles_found);
fprintf(stderr, " Failed decodes: %8zd (%6.2f %%)\n", fprintf(stderr, " Failed decodes: %8zd (%6.2f %%)\n",
m_stats.failed_decodes, m_stats.failed_decodes * 100.0f / m_stats.preambles_found); m_rx_stats.failed_decodes, m_rx_stats.failed_decodes * 100.0f / m_rx_stats.preambles_found);
} }
@ -161,5 +172,9 @@ int main(int argc, char **argv)
layer1_rx_shutdown(&rx); layer1_rx_shutdown(&rx);
#if JSONLOGGER
jsonlogger_shutdown();
#endif
fprintf(stderr, "Done.\n"); fprintf(stderr, "Done.\n");
} }

1
impl/utils/visualizer/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/visualizer

View file

@ -0,0 +1,7 @@
all:
go build
clean:
rm -f visualizer
.PHONY: all clean

View file

@ -0,0 +1,5 @@
module visualizer
go 1.21
require golang.org/x/net v0.25.0

View file

@ -0,0 +1,2 @@
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=

View file

@ -0,0 +1,135 @@
// Visualize debug information from hamnet70.
//
// This program is a HTTP server which reads JSON messages (one per line) from
// a FIFO filled by hamnet70 and passes them to the browser via WebSocket.
// Concurrent browser sessions and reconnects are supported. This binary is
// standalone and can be used without the static/ directory.
package main
import (
"bufio"
"embed"
"io/fs"
"log"
"net/http"
"os"
"sync"
"time"
"golang.org/x/net/websocket"
)
//go:embed static/*
var static embed.FS
func main() {
if len(os.Args) != 2 && len(os.Args) != 3 {
log.SetFlags(0)
log.Fatalf("usage: %s <fifo> [<json-dup-prefix>]", os.Args[0])
}
var mutex sync.Mutex
listeners := make(map[*chan string]struct{})
go func() {
for {
path := os.Args[1]
var dupPath string
if len(os.Args) == 3 {
dupPath = os.Args[2] + "." + time.Now().Format(time.RFC3339)
}
fh, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
var dupFh *os.File
if dupPath != "" {
dupFh, err = os.Create(dupPath)
if err != nil {
log.Fatal(err)
}
}
r := bufio.NewReader(fh)
for {
line, err := r.ReadString('\n')
if err != nil {
log.Printf("read %q: %v", path, err)
break
}
// Duplicate JSON input to a file so it can be replayed later
// if necessary
if dupFh != nil {
_, err = dupFh.WriteString(line)
if err != nil {
log.Fatalf("write %q: %v", dupPath, err)
}
}
// Send to all listeners
mutex.Lock()
for x := range listeners {
*x <- line
}
mutex.Unlock()
}
err = fh.Close()
if err != nil {
log.Fatalf("close %q: %v", path, err)
}
if dupFh != nil {
err = dupFh.Close()
if err != nil {
log.Fatalf("close %q: %v", dupPath, err)
}
}
}
}()
// Use existing "./static/" directory for quick changes
var staticFS http.FileSystem
_, err := os.Stat("static")
if err == nil {
staticFS = http.Dir("static")
} else {
x, err := fs.Sub(static, "static")
if err != nil {
log.Fatal(err)
}
staticFS = http.FS(x)
}
http.Handle("/", http.FileServer(staticFS))
http.Handle("/events", websocket.Handler(func(conn *websocket.Conn) {
// Register new listener for this connection
ch := make(chan string, 512) // buffer up to x number of messages
mutex.Lock()
listeners[&ch] = struct{}{}
mutex.Unlock()
defer func() {
mutex.Lock()
delete(listeners, &ch)
mutex.Unlock()
}()
for {
x := <-ch
err := websocket.Message.Send(conn, x)
if err != nil {
log.Printf("Websocket error: %v", err)
break
}
}
}))
err = http.ListenAndServe("localhost:8080", nil)
if err != nil {
log.Fatal(err)
}
}
// vi: set noet ts=4 sw=4 sts=4:

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,28 @@
#error {
font-weight: bold;
color: red;
}
#trace {
font-family: monospace;
}
#main {
display: flex;
height: 90vh;
}
#constellation {
width: 30vw;
}
#data {
width: 20vw;
}
#history {
width: 50vw;
display: flex;
flex-direction: column;
}
#history-hz, #history-db, #history-misc {
height: 30vh;
}

View file

@ -0,0 +1,50 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hamnet70 Visualizer</title>
<link rel="stylesheet" href="index.css"/>
<script src="chart-v4.4.2.js"></script>
</head>
<body>
<div id="error" hidden></div>
<div>
<input id="pause" type="button" value="">
</div>
<div>
Trace: <span id="trace"></span>
</div>
<div>
Preambles found: <span id="preambles_found"></span>,
Successful decodes: <span id="successful_decodes"></span>,
Header errors: <span id="header_errors"></span>,
Failed decodes: <span id="failed_decodes"></span>
</div>
<div id="main">
<div id="constellation">
<canvas id="constellation-canvas"></canvas>
</div>
<div id="data"></div>
<div id="history">
<div id="history-hz">
<canvas id="history-hz-canvas"></canvas>
</div>
<div id="history-db">
<canvas id="history-db-canvas"></canvas>
</div>
<div id="history-misc">
<canvas id="history-misc-canvas"></canvas>
</div>
</div>
</div>
<script src="index.js"></script>
</body>
</html>

View file

@ -0,0 +1,407 @@
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();
}