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 <unistd.h>
#include <errno.h>
@ -29,6 +32,12 @@ static bool start_message(void)
}
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);

View File

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

View File

@ -4,6 +4,7 @@
#include <liquid/liquid.h>
#include "jsonlogger.h"
#include "layer1/rx.h"
#include "config.h"
@ -20,12 +21,9 @@
#define CHUNKSIZE_RF (CHUNKSIZE_INPUT/2)
#define CHUNKSIZE_BB (CHUNKSIZE_RF/SDR_OVERSAMPLING)
static struct {
size_t preambles_found;
size_t successful_decodes;
size_t failed_decodes;
size_t header_errors;
} m_stats;
#define JSONLOGGER 0
static rx_stats_t m_rx_stats;
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");
//hexdump(packet_data, packet_len);
//fprintf(stderr, "=======================\n");
m_stats.failed_decodes++;
m_rx_stats.failed_decodes++;
break;
case RX_EVT_HEADER_ERROR:
m_stats.header_errors++;
m_rx_stats.header_errors++;
break;
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);
//fprintf(stderr, "====================================\n");
m_stats.successful_decodes++;
m_rx_stats.successful_decodes++;
break;
case RX_EVT_PREAMBLE_FOUND:
//fprintf(stderr, "Found preamble!\n");
m_stats.preambles_found++;
m_rx_stats.preambles_found++;
break;
case RX_EVT_PACKET_DEBUG_INFO_COMPLETE:
// FIXME: print debug info
#if JSONLOGGER
jsonlogger_log_rx_packet_info(&rx->packet_debug_info);
#endif
break;
}
#if JSONLOGGER
jsonlogger_log_rx_stats(&m_rx_stats);
#endif
}
@ -112,6 +116,13 @@ int main(int argc, char **argv)
// ** 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);
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);
@ -147,13 +158,13 @@ int main(int argc, char **argv)
RESULT_CHECK(layer1_rx_process(&rx, bb_samples, n_bb_samples));
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",
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",
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",
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);
#if JSONLOGGER
jsonlogger_shutdown();
#endif
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
thomas marked this conversation as resolved Outdated

Eine Idee dazu noch: wenn man wirklich ein Replay von einem früheren JSON-Dump startet, indem man diesen statt dem FIFO als Args[1] angibt, wäre es cool, wenn man Args[2] weglassen könnte oder zumindest /dev/null angeben könnte. Letzteres geht leider nicht wegen dem Anhängen der Uhrzeit (was an sich aber schon sinnvoll ist).

Eine Idee dazu noch: wenn man wirklich ein Replay von einem früheren JSON-Dump startet, indem man diesen statt dem FIFO als Args[1] angibt, wäre es cool, wenn man Args[2] weglassen könnte oder zumindest /dev/null angeben könnte. Letzteres geht leider nicht wegen dem Anhängen der Uhrzeit (was an sich aber schon sinnvoll ist).

Ist implementiert. Habs auch gleich rebased.

Ist implementiert. Habs auch gleich rebased.

Schaut gut aus. Dann merge ich das mal :)

Schaut gut aus. Dann merge ich das mal :)
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();
}