Visualizer #3

Merged
thomas merged 12 commits from rudi_s into main 2024-05-28 11:21:00 +02:00
9 changed files with 498 additions and 0 deletions
Showing only changes of commit 6fc4df205c - Show all commits

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,125 @@
// 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) != 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]
dupPath := os.Args[2] + "." + time.Now().Format(time.RFC3339)
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 :)
fh, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
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
_, 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)
}
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,25 @@
#error {
font-weight: bold;
color: red;
}
#trace {
font-family: monospace;
}
#main {
display: flex;
}
#constellation {
height: 85vh;
width: 30vw;
}
#data {
padding-top: 1em;
padding-right: 1em;
text-wrap: nowrap;
}
#history {
max-height: 80vh;
flex-grow: 1;
}

View file

@ -0,0 +1,42 @@
<!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">
<canvas id="history-canvas"></canvas>
</div>
</div>
<script src="index.js"></script>
</body>
</html>

View file

@ -0,0 +1,271 @@
const tracePlaceholder = Symbol('trace-placeholder');
const traceEvent = Symbol('trace-event');
const tracePacket = Symbol('trace-packet');
const traceMaxEvents = 200;
const state = {
start: new Date(),
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';
}
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) {
span.title = getTime();
}
if (type === tracePacket) {
span.onclick = () => {
updatePause(true);
updateChart(data.packet);
updateData(data.packet);
};
span.style.cursor = 'pointer';
}
const trace = document.querySelector('#trace');
trace.prepend(span);
if (trace.childNodes.length > traceMaxEvents) {
trace.removeChild(trace.lastChild);
}
}
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,
},
},
scales: {
x: {
min: -1.5,
max: 1.5,
},
y: {
min: -1.5,
max: 1.5,
},
},
},
});
const chartHistory = new Chart(
document.querySelector('#history-canvas'), {
type: 'line',
data: {
labels: [],
datasets: [],
},
options: {
animation: false,
},
});
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'];
chartConstellation.update();
}
function updateData(packet) {
const keys = [];
for (const x in packet) {
if (x === 'preamble_symbols'
|| x === 'header_symbols'
|| x === 'data_symbols') {
continue
}
keys.push(x);
}
keys.sort();
const divs = [];
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 keys = [];
for (const x in packet) {
if (x === 'preamble_symbols'
|| x === 'header_symbols'
|| x === 'data_symbols') {
continue;
}
if (x === 'header_evm' || x === 'data_evm') {
continue;
};
keys.push(x);
}
keys.sort();
if (chartHistory.data.datasets.length === 0) {
for (const x of keys) {
chartHistory.data.datasets.push({
label: x,
data: [],
})
}
}
let i = 0;
for (const x of keys) {
chartHistory.data.datasets[i].data.push(packet[x]);
i++;
}
chartHistory.data.labels.push((new Date() - state.start)/1000);
chartHistory.update();
}
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) {
updateChart(msg);
updateData(msg);
addHistory(msg);
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();
}