multi source

This commit is contained in:
Martino Ferrari
2026-05-19 13:14:26 +02:00
parent e6ab50e90e
commit b4d6e2443c
8 changed files with 862 additions and 436 deletions
+302 -176
View File
@@ -3,8 +3,8 @@ package main
import ( import (
"encoding/json" "encoding/json"
"log" "log"
"math"
"net/http" "net/http"
"sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -59,7 +59,6 @@ func (c *wsClient) readPump() {
if err != nil { if err != nil {
break break
} }
// Handle client messages (ping, setWindow, etc.) currently just log.
var env map[string]interface{} var env map[string]interface{}
if json.Unmarshal(msg, &env) == nil { if json.Unmarshal(msg, &env) == nil {
if t, ok := env["type"].(string); ok { if t, ok := env["type"].(string); ok {
@@ -70,6 +69,28 @@ func (c *wsClient) readPump() {
case c.send <- resp: case c.send <- resp:
default: default:
} }
case "addSource":
label, _ := env["label"].(string)
addr, _ := env["addr"].(string)
if addr != "" {
select {
case c.hub.commandCh <- hubCmd{op: "wsAddSource", label: label, addr: addr}:
default:
}
}
case "removeSource":
id, _ := env["id"].(string)
if id != "" {
select {
case c.hub.commandCh <- hubCmd{op: "wsRemoveSource", sourceID: id}:
default:
}
}
case "saveSources":
select {
case c.hub.commandCh <- hubCmd{op: "wsSaveSources"}:
default:
}
} }
} }
} }
@@ -82,80 +103,109 @@ func (c *wsClient) readPump() {
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
ReadBufferSize: 4096, ReadBufferSize: 4096,
WriteBufferSize: 64 * 1024, WriteBufferSize: 64 * 1024,
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
} }
// Hub is the central data broker between the UDP client and WebSocket clients. // sourceHubState holds all data for one active data source.
type Hub struct { // Only accessed from the Run() goroutine.
mu sync.RWMutex type sourceHubState struct {
signals []SignalInfo id, label, addr, connState string
configJS []byte // cached JSON config message signals []SignalInfo
configSeq uint64 // incremented on every UpdateConfig call configJS []byte
// Time-signal calibration — only accessed from Run() goroutine.
timeSigCalib map[string]float64
configSeq uint64
configSeqAtCalib uint64
}
// taggedSample is a DataSample annotated with its source ID.
type taggedSample struct {
sourceID string
sample DataSample
}
// hubCmd carries a command to the Run() goroutine.
type hubCmd struct {
op string // "addSource","removeSource","setSourceState","updateConfig",
// "wsAddSource","wsRemoveSource","wsSaveSources"
sourceID string
label string
addr string
state string
sigs []SignalInfo
}
// Hub is the central broker between UDP clients and WebSocket clients.
// All map state is accessed exclusively from the Run() goroutine.
type Hub struct {
clients map[*wsClient]bool clients map[*wsClient]bool
register chan *wsClient register chan *wsClient
unregister chan *wsClient unregister chan *wsClient
broadcastCh chan []byte // all sends go through Run() to avoid races on c.send broadcastCh chan []byte
dataCh chan taggedSample
commandCh chan hubCmd
dataCh chan DataSample // incoming samples from UDP goroutine sm *SourceManager // set after construction; used for WS-initiated source changes
// Time-signal calibration: only accessed from the Run() goroutine.
// For temporal-array signals (TimeMode=FirstSample/LastSample), the
// MARTe2 Time signal value (uint32 microseconds from start) is used as
// the timing anchor. We calibrate a per-signal wall-clock offset once
// on the first data packet so that subsequent packets are stamped purely
// from the embedded timer value → perfect continuity, no jitter gaps.
timeSigCalib map[string]float64 // key=time-signal name, value=wallTime-timerSecs offset
configSeqAtCalib uint64 // configSeq value when timeSigCalib was last reset
} }
// NewHub creates an initialised Hub. // NewHub creates an initialised Hub.
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{
clients: make(map[*wsClient]bool), clients: make(map[*wsClient]bool),
register: make(chan *wsClient, 8), register: make(chan *wsClient, 8),
unregister: make(chan *wsClient, 8), unregister: make(chan *wsClient, 8),
broadcastCh: make(chan []byte, 64), broadcastCh: make(chan []byte, 64),
dataCh: make(chan DataSample, 256), dataCh: make(chan taggedSample, 256),
timeSigCalib: make(map[string]float64), commandCh: make(chan hubCmd, 64),
} }
} }
// UpdateConfig stores a new signal config and broadcasts it to all WS clients. // AddSource notifies the Hub that a new source has been registered.
func (h *Hub) UpdateConfig(sigs []SignalInfo) { func (h *Hub) AddSource(id, label, addr string) {
msg, err := json.Marshal(map[string]interface{}{
"type": "config",
"signals": sigs,
})
if err != nil {
log.Printf("hub: marshal config: %v", err)
return
}
h.mu.Lock()
h.signals = sigs
h.configJS = msg
h.configSeq++
h.mu.Unlock()
h.broadcast(msg)
}
// PushData enqueues a data sample for broadcasting to WebSocket clients.
func (h *Hub) PushData(s DataSample) {
select { select {
case h.dataCh <- s: case h.commandCh <- hubCmd{op: "addSource", sourceID: id, label: label, addr: addr}:
default:
}
}
// RemoveSource notifies the Hub that a source has been removed.
func (h *Hub) RemoveSource(id string) {
select {
case h.commandCh <- hubCmd{op: "removeSource", sourceID: id}:
default:
}
}
// SetSourceState updates the connection state of a source.
func (h *Hub) SetSourceState(id, state string) {
select {
case h.commandCh <- hubCmd{op: "setSourceState", sourceID: id, state: state}:
default:
}
}
// UpdateConfigForSource stores a new signal config for a source and broadcasts it.
func (h *Hub) UpdateConfigForSource(sourceID string, sigs []SignalInfo) {
select {
case h.commandCh <- hubCmd{op: "updateConfig", sourceID: sourceID, sigs: sigs}:
default:
}
}
// PushDataForSource enqueues a data sample from a specific source.
func (h *Hub) PushDataForSource(sourceID string, s DataSample) {
select {
case h.dataCh <- taggedSample{sourceID: sourceID, sample: s}:
default: default:
// Drop if buffer full to avoid blocking the UDP goroutine.
} }
} }
// broadcast enqueues a message for delivery to all WebSocket clients. // broadcast enqueues a message for delivery to all WebSocket clients.
// All actual sends happen inside Run() to avoid concurrent access to c.send.
func (h *Hub) broadcast(msg []byte) { func (h *Hub) broadcast(msg []byte) {
select { select {
case h.broadcastCh <- msg: case h.broadcastCh <- msg:
default: default:
// Drop if the broadcast queue is full.
} }
} }
@@ -166,135 +216,244 @@ func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
log.Printf("ws upgrade: %v", err) log.Printf("ws upgrade: %v", err)
return return
} }
c := &wsClient{ c := &wsClient{hub: h, conn: conn, send: make(chan []byte, 64)}
hub: h,
conn: conn,
send: make(chan []byte, 64),
}
h.register <- c h.register <- c
go c.writePump() go c.writePump()
go c.readPump() go c.readPump()
} }
// Run is the hub's main goroutine. It must be started with go hub.Run(). // buildSourcesMsg serialises the current source list as a JSON "sources" message.
func buildSourcesMsg(sm map[string]*sourceHubState) []byte {
type srcInfo struct {
ID string `json:"id"`
Label string `json:"label"`
Addr string `json:"addr"`
State string `json:"state"`
}
list := make([]srcInfo, 0, len(sm))
for _, src := range sm {
list = append(list, srcInfo{ID: src.id, Label: src.label, Addr: src.addr, State: src.connState})
}
msg, _ := json.Marshal(map[string]interface{}{"type": "sources", "sources": list})
return msg
}
// Run is the hub's main goroutine. Must be started with go hub.Run().
func (h *Hub) Run() { func (h *Hub) Run() {
// Batch data at ≤30 Hz.
ticker := time.NewTicker(time.Second / 30) ticker := time.NewTicker(time.Second / 30)
defer ticker.Stop() defer ticker.Stop()
// Accumulate samples between ticks. sourcesMap := make(map[string]*sourceHubState)
pending := make([]DataSample, 0, 64) var sourcesMsg []byte
// pending[sourceID] accumulates samples between 30 Hz ticks.
pending := make(map[string][]DataSample)
rebuildSources := func() {
sourcesMsg = buildSourcesMsg(sourcesMap)
h.broadcast(sourcesMsg)
}
for { for {
select { select {
case c := <-h.register: case c := <-h.register:
h.mu.Lock()
h.clients[c] = true h.clients[c] = true
cfg := h.configJS // Send current state to the new client.
h.mu.Unlock() if sourcesMsg != nil {
// Send current config immediately if we have one. select { case c.send <- sourcesMsg: default: }
if cfg != nil { }
select { for _, src := range sourcesMap {
case c.send <- cfg: if src.configJS != nil {
default: select { case c.send <- src.configJS: default: }
} }
} }
case c := <-h.unregister: case c := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[c]; ok { if _, ok := h.clients[c]; ok {
delete(h.clients, c) delete(h.clients, c)
close(c.send) close(c.send)
} }
h.mu.Unlock()
case msg := <-h.broadcastCh: case msg := <-h.broadcastCh:
h.mu.RLock()
for c := range h.clients { for c := range h.clients {
select { select { case c.send <- msg: default: }
case c.send <- msg: }
default:
case cmd := <-h.commandCh:
switch cmd.op {
case "addSource":
sourcesMap[cmd.sourceID] = &sourceHubState{
id: cmd.sourceID,
label: cmd.label,
addr: cmd.addr,
connState: "connecting",
timeSigCalib: make(map[string]float64),
}
rebuildSources()
case "removeSource":
delete(sourcesMap, cmd.sourceID)
delete(pending, cmd.sourceID)
rebuildSources()
case "setSourceState":
if src, ok := sourcesMap[cmd.sourceID]; ok {
src.connState = cmd.state
rebuildSources()
}
case "updateConfig":
src, ok := sourcesMap[cmd.sourceID]
if !ok {
continue
}
src.signals = cmd.sigs
src.configSeq++
cfgMsg, err := json.Marshal(map[string]interface{}{
"type": "config",
"sourceId": cmd.sourceID,
"signals": cmd.sigs,
})
if err != nil {
log.Printf("hub: marshal config: %v", err)
continue
}
src.configJS = cfgMsg
h.broadcast(cfgMsg)
case "wsAddSource":
if h.sm != nil {
go func(label, addr string) { h.sm.Add(label, addr) }(cmd.label, cmd.addr)
}
case "wsRemoveSource":
if h.sm != nil {
go func(id string) { h.sm.Remove(id) }(cmd.sourceID)
}
case "wsSaveSources":
if h.sm != nil {
if err := h.sm.Save(); err != nil {
log.Printf("hub: save sources: %v", err)
}
} }
} }
h.mu.RUnlock()
case s := <-h.dataCh: case ts := <-h.dataCh:
pending = append(pending, s) pending[ts.sourceID] = append(pending[ts.sourceID], ts.sample)
case <-ticker.C: case <-ticker.C:
if len(pending) == 0 { for srcID, samples := range pending {
continue if len(samples) == 0 {
} continue
h.mu.RLock() }
sigs := h.signals src, ok := sourcesMap[srcID]
noClients := len(h.clients) == 0 if !ok || len(src.signals) == 0 || len(h.clients) == 0 {
h.mu.RUnlock() pending[srcID] = pending[srcID][:0]
continue
if noClients || len(sigs) == 0 { }
pending = pending[:0] msg := h.buildDataMessageForSource(src, samples)
continue pending[srcID] = pending[srcID][:0]
} if msg != nil {
h.broadcast(msg)
msg := h.buildDataMessage(pending, sigs) }
pending = pending[:0]
if msg != nil {
h.broadcast(msg)
} }
} }
} }
} }
// maxBatchPoints is the maximum number of points sent per signal per 30 Hz display tick. // ─── Data serialisation ───────────────────────────────────────────────────────
// For temporal-array signals (e.g. 1 MSps packed as 1000 samples/packet), the expanded
// sample stream is decimated to this limit before transmission to WebSocket clients.
const maxBatchPoints = 2000
// sigData carries one signal's worth of time+value pairs in a single batch message. // maxScalarPoints caps scalar/spatial-array signals per 30 Hz tick.
// At typical cycle rates (≤10 kHz) a tick accumulates at most ~333 samples,
// so this cap is almost never hit.
const maxScalarPoints = 2000
// maxTemporalPoints caps temporal-array (packed-burst) signals per 30 Hz tick.
// Raised significantly vs scalars because temporal arrays carry high-frequency
// waveforms: at 5 Msps / 5 kHz update rate a tick produces ~167 k samples;
// sending 20 k points limits the wire to ~320 KB/ch/tick while giving a
// minimum visible Δt of ≈ 1.6 µs (vs ≈16 µs with the old 2 k cap).
const maxTemporalPoints = 20000
// lttbDecimate reduces (tIn, vIn) to at most threshold representative points
// using the Largest-Triangle-Three-Buckets algorithm.
// Returns the original slices unchanged when len(tIn) ≤ threshold.
func lttbDecimate(tIn, vIn []float64, threshold int) ([]float64, []float64) {
n := len(tIn)
if n <= threshold || threshold < 3 {
return tIn, vIn
}
outT := make([]float64, threshold)
outV := make([]float64, threshold)
outT[0], outV[0] = tIn[0], vIn[0]
outT[threshold-1], outV[threshold-1] = tIn[n-1], vIn[n-1]
every := float64(n-2) / float64(threshold-2)
a := 0
for i := 0; i < threshold-2; i++ {
// Centroid of the next bucket
avgS := int(float64(i+1)*every) + 1
avgE := int(float64(i+2)*every) + 1
if avgE > n {
avgE = n
}
avgT, avgV, cnt := 0.0, 0.0, 0
for j := avgS; j < avgE; j++ {
avgT += tIn[j]; avgV += vIn[j]; cnt++
}
if cnt > 0 {
avgT /= float64(cnt); avgV /= float64(cnt)
}
// Pick the point in the current bucket that forms the largest triangle
rS := int(float64(i)*every) + 1
rE := int(float64(i+1)*every) + 1
if rE > n {
rE = n
}
maxArea, next := -1.0, rS
aT, aV := tIn[a], vIn[a]
for j := rS; j < rE; j++ {
area := math.Abs((aT-avgT)*(vIn[j]-aV) - (aT-tIn[j])*(avgV-aV))
if area > maxArea {
maxArea = area; next = j
}
}
outT[i+1], outV[i+1] = tIn[next], vIn[next]
a = next
}
return outT, outV
}
// sigData carries one signal's worth of time+value pairs.
type sigData struct { type sigData struct {
T []float64 `json:"t"` // unix seconds T []float64 `json:"t"`
V []float64 `json:"v"` // physical values V []float64 `json:"v"`
} }
// dataMsg is the JSON envelope for batched data sent to WebSocket clients. // dataMsg is the JSON envelope sent to WebSocket clients.
// Each signal carries its own time axis so that temporal arrays (packed sample
// bursts with a known sampling rate) and scalar signals can coexist cleanly.
type dataMsg struct { type dataMsg struct {
Type string `json:"type"` Type string `json:"type"`
Signals map[string]sigData `json:"signals"` SourceID string `json:"sourceId"`
Signals map[string]sigData `json:"signals"`
} }
// buildDataMessage merges a batch of DataSamples into one JSON message. // buildDataMessageForSource serialises a batch of samples for one source.
// // Signal keys in the output are prefixed with "sourceId:" so the browser can
// Three cases are handled: // store them in a single flat buffer map without collision.
// func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample) []byte {
// 1. Temporal array (NumElements > 1, TimeMode == FirstSample or LastSample):
// The N samples in each packet represent a contiguous time burst at SamplingRate.
// The embedded TimeSignal value (uint32 microseconds) is used as the timing
// anchor for each burst. A wall-clock offset is calibrated once on the first
// packet so that abs-time stays consistent with other signals.
// The full expanded stream is decimated to maxBatchPoints if needed.
//
// 2. Scalar signal (NumElements == 1):
// One {t, v} pair per packet wall arrival time used as timestamp.
//
// 3. Spatial / PacketTime array (NumElements > 1, TimeMode == 0):
// Each element is tracked as a separate stream keyed "sig[i]", with wall
// arrival time as the shared timestamp.
func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
if len(batch) == 0 { if len(batch) == 0 {
return nil return nil
} }
// Reset time-signal calibration whenever the config has changed. // Reset time-signal calibration whenever the signal config changed.
h.mu.RLock() if src.configSeq != src.configSeqAtCalib {
seq := h.configSeq src.configSeqAtCalib = src.configSeq
h.mu.RUnlock() src.timeSigCalib = make(map[string]float64)
if seq != h.configSeqAtCalib {
h.configSeqAtCalib = seq
h.timeSigCalib = make(map[string]float64)
} }
sigs := src.signals
pfx := src.id + ":"
out := make(map[string]sigData, len(sigs)*2) out := make(map[string]sigData, len(sigs)*2)
for _, sig := range sigs { for _, sig := range sigs {
@@ -303,19 +462,15 @@ func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
switch { switch {
case isTemporal: case isTemporal:
// Resolve time signal (scalar that gives the anchor time in microseconds).
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs) hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
var timeSigName string var timeSigName string
if hasTimeSig { if hasTimeSig {
timeSigName = sigs[sig.TimeSignalIdx].Name timeSigName = sigs[sig.TimeSignalIdx].Name
} }
dt := 0.0 dt := 0.0
if sig.SamplingRate > 0 { if sig.SamplingRate > 0 {
dt = 1.0 / sig.SamplingRate dt = 1.0 / sig.SamplingRate
} }
// Expand each packet's N samples into individual time-stamped points.
allT := make([]float64, 0, len(batch)*n) allT := make([]float64, 0, len(batch)*n)
allV := make([]float64, 0, len(batch)*n) allV := make([]float64, 0, len(batch)*n)
@@ -324,37 +479,25 @@ func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
if !ok || len(vals) < n { if !ok || len(vals) < n {
continue continue
} }
// Compute the anchor timestamp for this burst.
// Prefer the embedded time-signal value (microseconds) so that
// consecutive bursts are perfectly contiguous regardless of jitter.
var anchorTime float64 var anchorTime float64
anchorIsFirstSample := (sig.TimeMode == TimeModeFirstSample) anchorIsFirstSample := sig.TimeMode == TimeModeFirstSample
if hasTimeSig { if hasTimeSig {
tVals, tOk := s.Values[timeSigName] tVals, tOk := s.Values[timeSigName]
if tOk && len(tVals) >= 1 { if tOk && len(tVals) >= 1 {
// Time signal is uint32 microseconds from system start.
timerS := tVals[0] * 1e-6 timerS := tVals[0] * 1e-6
wallT := float64(s.WallTime.UnixNano()) / 1e9 wallT := float64(s.WallTime.UnixNano()) / 1e9
if _, exists := src.timeSigCalib[timeSigName]; !exists {
// Calibrate the wall-clock offset once per session so that src.timeSigCalib[timeSigName] = wallT - timerS
// anchor times can be expressed as absolute Unix timestamps.
if _, exists := h.timeSigCalib[timeSigName]; !exists {
h.timeSigCalib[timeSigName] = wallT - timerS
} }
anchorTime = h.timeSigCalib[timeSigName] + timerS anchorTime = src.timeSigCalib[timeSigName] + timerS
} else { } else {
// Time signal missing in this packet fall back to wall clock.
anchorTime = float64(s.WallTime.UnixNano()) / 1e9 anchorTime = float64(s.WallTime.UnixNano()) / 1e9
anchorIsFirstSample = false // wallT = last sample anchorIsFirstSample = false
} }
} else { } else {
// No time signal configured use wall arrival as last-sample anchor.
anchorTime = float64(s.WallTime.UnixNano()) / 1e9 anchorTime = float64(s.WallTime.UnixNano()) / 1e9
anchorIsFirstSample = false anchorIsFirstSample = false
} }
for k := 0; k < n; k++ { for k := 0; k < n; k++ {
var t float64 var t float64
if anchorIsFirstSample { if anchorIsFirstSample {
@@ -367,24 +510,10 @@ func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
} }
} }
// Decimate to maxBatchPoints if necessary. decimT, decimV := lttbDecimate(allT, allV, maxTemporalPoints)
step := 1 out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
if len(allT) > maxBatchPoints {
step = len(allT) / maxBatchPoints
if step < 1 {
step = 1
}
}
decimT := make([]float64, 0, len(allT)/step+1)
decimV := make([]float64, 0, len(allV)/step+1)
for i := 0; i < len(allT); i += step {
decimT = append(decimT, allT[i])
decimV = append(decimV, allV[i])
}
out[sig.Name] = sigData{T: decimT, V: decimV}
case n == 1: case n == 1:
// Scalar signal: one sample per packet.
ts := make([]float64, 0, len(batch)) ts := make([]float64, 0, len(batch))
vs := make([]float64, 0, len(batch)) vs := make([]float64, 0, len(batch))
for _, s := range batch { for _, s := range batch {
@@ -395,12 +524,12 @@ func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
ts = append(ts, float64(s.WallTime.UnixNano())/1e9) ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
vs = append(vs, vals[0]) vs = append(vs, vals[0])
} }
out[sig.Name] = sigData{T: ts, V: vs} out[pfx+sig.Name] = sigData{T: ts, V: vs}
default: default:
// Spatial / PacketTime array: one stream per element, keyed "sig[i]". // Spatial / PacketTime array: one stream per element.
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
key := arrayKey(sig.Name, i) key := pfx + arrayKey(sig.Name, i)
ts := make([]float64, 0, len(batch)) ts := make([]float64, 0, len(batch))
vs := make([]float64, 0, len(batch)) vs := make([]float64, 0, len(batch))
for _, s := range batch { for _, s := range batch {
@@ -416,10 +545,7 @@ func (h *Hub) buildDataMessage(batch []DataSample, sigs []SignalInfo) []byte {
} }
} }
result, err := json.Marshal(dataMsg{ result, err := json.Marshal(dataMsg{Type: "data", SourceID: src.id, Signals: out})
Type: "data",
Signals: out,
})
if err != nil { if err != nil {
log.Printf("hub: marshal data: %v", err) log.Printf("hub: marshal data: %v", err)
return nil return nil
+25 -7
View File
@@ -2,11 +2,13 @@ package main
import ( import (
"embed" "embed"
"errors"
"flag" "flag"
"fmt" "fmt"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"os"
) )
// version is set at build time via -ldflags "-X main.version=..." // version is set at build time via -ldflags "-X main.version=..."
@@ -15,19 +17,36 @@ var version = "dev"
//go:embed static //go:embed static
var staticFiles embed.FS var staticFiles embed.FS
// multiFlag allows a flag to be repeated: --source a --source b
type multiFlag []string
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
func main() { func main() {
streamerAddr := flag.String("streamer", "127.0.0.1:44500", "MARTe2 UDP streamer address (host:port)") var sourceArgs multiFlag
flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port (repeatable)`)
sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)")
listenAddr := flag.String("listen", ":8080", "HTTP listen address") listenAddr := flag.String("listen", ":8080", "HTTP listen address")
clientPort := flag.Int("clientport", 44900, "Local UDP port to bind for streamer data")
flag.Parse() flag.Parse()
hub := NewHub() hub := NewHub()
sm := NewSourceManager(hub, *sourcesFile)
hub.sm = sm
go hub.Run() go hub.Run()
udpClient := NewUDPClient(*streamerAddr, *clientPort, hub) // Load sources from file first (if specified), then add any CLI --source flags.
go udpClient.Run() if *sourcesFile != "" {
if err := sm.Load(*sourcesFile); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("sources-file load: %v", err)
}
}
for _, arg := range sourceArgs {
label, addr := ParseSourceArg(arg)
sm.Add(label, addr)
}
// Serve the embedded static/ directory (index.html, style.css, app.js).
sub, err := fs.Sub(staticFiles, "static") sub, err := fs.Sub(staticFiles, "static")
if err != nil { if err != nil {
log.Fatalf("static sub-fs: %v", err) log.Fatalf("static sub-fs: %v", err)
@@ -38,8 +57,7 @@ func main() {
fmt.Fprint(w, version) fmt.Fprint(w, version)
}) })
log.Printf("WebUI listening on %s (streamer=%s, local UDP port=%d, version=%s)", log.Printf("WebUI listening on %s (version=%s)", *listenAddr, version)
*listenAddr, *streamerAddr, *clientPort, version)
if err := http.ListenAndServe(*listenAddr, nil); err != nil { if err := http.ListenAndServe(*listenAddr, nil); err != nil {
log.Fatalf("http: %v", err) log.Fatalf("http: %v", err)
} }
+211 -48
View File
@@ -14,7 +14,8 @@ const TRACE_COLORS = [
/* ════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════
Globals Globals
════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════ */
let signals = []; // sourcesMap: id → {id, label, addr, state, signals:[]}
const sourcesMap = {};
let buffers = {}; let buffers = {};
let plots = []; let plots = [];
let nextPlotId = 1; let nextPlotId = 1;
@@ -102,7 +103,8 @@ function connectWS() {
ws.onerror = () => { }; ws.onerror = () => { };
ws.onmessage = evt => { ws.onmessage = evt => {
let msg; try { msg = JSON.parse(evt.data); } catch { return; } let msg; try { msg = JSON.parse(evt.data); } catch { return; }
if (msg.type === 'config') onConfig(msg); if (msg.type === 'sources') onSources(msg);
else if (msg.type === 'config') onConfig(msg);
else if (msg.type === 'data') onData(msg); else if (msg.type === 'data') onData(msg);
}; };
} }
@@ -147,19 +149,32 @@ function numElements(sig) { return (sig.numRows || 1) * (sig.numCols || 1); }
function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; } function isTemporal(sig) { return numElements(sig) > 1 && (sig.timeMode || 0) !== 0; }
function onConfig(msg) { function onConfig(msg) {
const sid = msg.sourceId;
if (!sid) return;
// Ensure source exists (may arrive before 'sources' message in some edge cases).
if (!sourcesMap[sid]) {
sourcesMap[sid] = { id: sid, label: sid, addr: '', state: 'connected', signals: [] };
}
const src = sourcesMap[sid];
const newSigs = msg.signals || []; const newSigs = msg.signals || [];
const oldSigs = src.signals || [];
const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0); const fp = s => s.name + ':' + s.typeCode + ':' + (s.numRows || 1) + ':' + (s.numCols || 1) + ':' + (s.timeMode || 0);
const changed = newSigs.length !== signals.length || newSigs.some((s, i) => fp(s) !== fp(signals[i])); const changed = newSigs.length !== oldSigs.length || newSigs.some((s, i) => fp(s) !== fp(oldSigs[i]));
signals = newSigs; src.signals = newSigs;
if (changed) { if (changed) {
buffers = {}; // Remove old buffers for this source only (prefix: "sid:").
signals.forEach(sig => { const prefix = sid + ':';
Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
newSigs.forEach(sig => {
const n = numElements(sig); const n = numElements(sig);
if (isTemporal(sig)) { buffers[sig.name] = makeBuffer(TEMPORAL_CAP); } const base = prefix + sig.name;
else if (n === 1) { buffers[sig.name] = makeBuffer(); } if (isTemporal(sig)) { buffers[base] = makeBuffer(TEMPORAL_CAP); }
else { for (let i = 0; i < n; i++) buffers[sig.name + '[' + i + ']'] = makeBuffer(); } else if (n === 1) { buffers[base] = makeBuffer(); }
else { for (let i = 0; i < n; i++) buffers[base + '[' + i + ']'] = makeBuffer(); }
}); });
trigDisarm(); trig.snapshot = null; trig.prevVal = null; if (trig.signal && trig.signal.startsWith(prefix)) {
trigDisarm(); trig.snapshot = null; trig.prevVal = null;
}
zoomHistory.length = 0; zoomHistory.length = 0;
document.getElementById('btn-zoom-back').style.display = 'none'; document.getElementById('btn-zoom-back').style.display = 'none';
} }
@@ -345,11 +360,17 @@ function sliceTypedArrayRange(t, v, t0, t1) {
// Temporal array signals have a meaningful SamplingRate; scalars return 0. // Temporal array signals have a meaningful SamplingRate; scalars return 0.
// Used to prefer high-freq signals as the master time grid regardless of trace order. // Used to prefer high-freq signals as the master time grid regardless of trace order.
function getKeySamplingRate(key) { function getKeySamplingRate(key) {
const direct = signals.find(s => s.name === key); // key format: "sourceId:signalName" or "sourceId:signalName[i]"
if (direct) return direct.samplingRate || 0; for (const src of Object.values(sourcesMap)) {
// Array element key like "Sig[3]" — strip the index const prefix = src.id + ':';
const sig = signals.find(s => key.startsWith(s.name + '[')); if (!key.startsWith(prefix)) continue;
return sig ? (sig.samplingRate || 0) : 0; const localKey = key.slice(prefix.length);
const direct = (src.signals || []).find(s => s.name === localKey);
if (direct) return direct.samplingRate || 0;
const sig = (src.signals || []).find(s => localKey.startsWith(s.name + '['));
if (sig) return sig.samplingRate || 0;
}
return 0;
} }
// Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default). // Returns a uPlot paths function for dashed/dotted lines, or null for solid (uPlot default).
@@ -1230,16 +1251,23 @@ document.getElementById('btn-trig-stop').addEventListener('click', () => {
function buildTrigSignalSelect() { function buildTrigSignalSelect() {
const sel = document.getElementById('trig-signal'), cur = sel.value; const sel = document.getElementById('trig-signal'), cur = sel.value;
sel.innerHTML = '<option value="">— none —</option>'; sel.innerHTML = '<option value="">— none —</option>';
signals.forEach(sig => { Object.values(sourcesMap).forEach(src => {
const n = numElements(sig); const prefix = src.id + ':';
if (isTemporal(sig) || n === 1) { const srcLabel = src.label || src.addr || src.id;
const o = document.createElement('option'); o.value = sig.name; o.textContent = sig.name; sel.appendChild(o); (src.signals || []).forEach(sig => {
} else { const n = numElements(sig);
for (let i = 0; i < n; i++) { if (isTemporal(sig) || n === 1) {
const key = sig.name + '[' + i + ']', o = document.createElement('option'); const key = prefix + sig.name;
o.value = key; o.textContent = key; sel.appendChild(o); const o = document.createElement('option');
o.value = key; o.textContent = srcLabel + ': ' + sig.name; sel.appendChild(o);
} else {
for (let i = 0; i < n; i++) {
const key = prefix + sig.name + '[' + i + ']';
const o = document.createElement('option');
o.value = key; o.textContent = srcLabel + ': ' + sig.name + '[' + i + ']'; sel.appendChild(o);
}
} }
} });
}); });
if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur; if (cur && [...sel.options].some(o => o.value === cur)) sel.value = cur;
trig.signal = sel.value; trig.signal = sel.value;
@@ -1248,34 +1276,79 @@ function buildTrigSignalSelect() {
/* ════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════
Sidebar Sidebar
════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════ */
const _typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64'];
function buildSidebar() { function buildSidebar() {
const list = document.getElementById('signal-list'); const list = document.getElementById('signal-list');
list.innerHTML = ''; list.innerHTML = '';
if (!signals.length) { const sources = Object.values(sourcesMap);
list.innerHTML = '<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">No signals</div>';
return; sources.forEach(src => {
} const sigs = src.signals || [];
const typeNames = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'f32', 'f64']; const prefix = src.id + ':';
signals.forEach(sig => {
const n = numElements(sig), temporal = isTemporal(sig); // Source header
const typeName = typeNames[sig.typeCode] || '?'; const grp = document.createElement('div');
if (n === 1 || temporal) { grp.className = 'source-group';
list.appendChild(makeDraggable(sig.name, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
} else { const hdr = document.createElement('div');
const group = document.createElement('div'); group.className = 'array-group'; hdr.className = 'source-group-header';
const header = document.createElement('div'); header.className = 'array-header';
header.innerHTML = '<span class="array-arrow">&#x25B6;</span><span class="sig-name">' + escHtml(sig.name) + '</span>' const dot = document.createElement('span');
+ (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '') dot.className = 'source-state-dot ' + (src.state || 'disconnected');
+ '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
header.addEventListener('click', () => header.classList.toggle('open')); const nameEl = document.createElement('span');
const children = document.createElement('div'); children.className = 'array-children'; nameEl.className = 'source-name';
for (let i = 0; i < n; i++) { nameEl.textContent = src.label || src.addr || src.id;
const key = sig.name + '[' + i + ']', child = makeDraggable(key, key, typeName, sig.unit || ''); nameEl.title = src.addr || '';
child.className = 'array-child'; children.appendChild(child);
const addrEl = document.createElement('span');
addrEl.className = 'source-addr';
if (src.label && src.addr) addrEl.textContent = src.addr;
const rmBtn = document.createElement('button');
rmBtn.className = 'source-remove-btn';
rmBtn.title = 'Remove source';
rmBtn.textContent = '×';
rmBtn.addEventListener('click', () => removeSource(src.id));
hdr.append(dot, nameEl, addrEl, rmBtn);
grp.appendChild(hdr);
sigs.forEach(sig => {
const n = numElements(sig), temporal = isTemporal(sig);
const typeName = _typeNames[sig.typeCode] || '?';
const globalKey = prefix + sig.name;
if (n === 1 || temporal) {
grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));
} else {
const group = document.createElement('div'); group.className = 'array-group';
const header = document.createElement('div'); header.className = 'array-header';
header.innerHTML = '<span class="array-arrow">&#x25B6;</span><span class="sig-name">' + escHtml(sig.name) + '</span>'
+ (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '')
+ '<span class="type-badge">[' + n + '] ' + typeName + '</span>';
header.addEventListener('click', () => header.classList.toggle('open'));
const children = document.createElement('div'); children.className = 'array-children';
for (let i = 0; i < n; i++) {
const key = globalKey + '[' + i + ']';
const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');
child.className = 'array-child'; children.appendChild(child);
}
group.appendChild(header); group.appendChild(children); grp.appendChild(group);
} }
group.appendChild(header); group.appendChild(children); list.appendChild(group); });
}
list.appendChild(grp);
}); });
if (!sources.length) {
const empty = document.createElement('div');
empty.style.cssText = 'padding:16px 14px;color:var(--overlay0);font-size:12px;text-align:center;';
empty.textContent = 'No sources configured';
list.appendChild(empty);
}
list.appendChild(makeAddSourceSection());
} }
function makeDraggable(key, label, typeName, unit) { function makeDraggable(key, label, typeName, unit) {
const item = document.createElement('div'); const item = document.createElement('div');
@@ -1487,7 +1560,9 @@ function addBadge(plotId, key) {
e.preventDefault(); e.preventDefault();
showSignalMenu(key, plotId, e.clientX, e.clientY); showSignalMenu(key, plotId, e.clientX, e.clientY);
}); });
badge.appendChild(dot); badge.appendChild(document.createTextNode(key)); badge.appendChild(x); // Show signal name without the "sourceId:" prefix in the badge label.
const displayName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
badge.appendChild(dot); badge.appendChild(document.createTextNode(displayName)); badge.appendChild(x);
c.appendChild(badge); c.appendChild(badge);
} }
function removeBadge(plotId, key) { function removeBadge(plotId, key) {
@@ -1650,6 +1725,93 @@ function setSidebar(open) {
} }
document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen)); document.getElementById('btn-sidebar').addEventListener('click', () => setSidebar(!sidebarOpen));
/* ════════════════════════════════════════════════════════════════
Multi-source management
════════════════════════════════════════════════════════════════ */
function onSources(msg) {
const srcs = msg.sources || [];
const newIds = new Set(srcs.map(s => s.id));
// Remove sources that disappeared.
Object.keys(sourcesMap).forEach(id => {
if (!newIds.has(id)) {
const prefix = id + ':';
Object.keys(buffers).forEach(k => { if (k.startsWith(prefix)) delete buffers[k]; });
delete sourcesMap[id];
}
});
// Update or create entries.
srcs.forEach(s => {
if (!sourcesMap[s.id]) {
sourcesMap[s.id] = { id: s.id, label: s.label, addr: s.addr, state: s.state, signals: [] };
} else {
Object.assign(sourcesMap[s.id], { label: s.label, addr: s.addr, state: s.state });
}
});
buildSidebar();
}
function addSourceWS(label, addr) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'addSource', label, addr }));
}
}
function removeSource(id) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'removeSource', id }));
}
}
function saveSourcesWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'saveSources' }));
}
}
function makeAddSourceSection() {
const section = document.createElement('div');
section.className = 'add-source-section';
const title = document.createElement('div');
title.className = 'add-source-title';
title.innerHTML = '<span class="add-src-arrow">&#x25B6;</span> Add Source';
const body = document.createElement('div');
body.className = 'add-source-body';
const addrInput = document.createElement('input');
addrInput.className = 'add-src-input'; addrInput.type = 'text';
addrInput.placeholder = 'host:port';
const labelInput = document.createElement('input');
labelInput.className = 'add-src-input'; labelInput.type = 'text';
labelInput.placeholder = 'label (optional)';
const addBtn = document.createElement('button');
addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
addBtn.addEventListener('click', () => {
const addr = addrInput.value.trim(); if (!addr) return;
addSourceWS(labelInput.value.trim(), addr);
addrInput.value = ''; labelInput.value = '';
});
addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
const saveBtn = document.createElement('button');
saveBtn.className = 'add-src-btn save-src-btn';
saveBtn.textContent = 'Save list'; saveBtn.title = 'Save source list to file';
saveBtn.addEventListener('click', saveSourcesWS);
body.append(addrInput, labelInput, addBtn, saveBtn);
section.append(title, body);
title.addEventListener('click', () => {
const open = section.classList.toggle('open');
title.querySelector('.add-src-arrow').style.transform = open ? 'rotate(90deg)' : '';
});
return section;
}
/* ════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════
Utility Utility
════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════ */
@@ -1736,6 +1898,7 @@ function initSignalMenu() {
════════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════════ */
buildLayoutMenu(); buildLayoutMenu();
applyLayout('l1x1'); applyLayout('l1x1');
buildSidebar(); // show "Add Source" section even before WS connection
initSignalMenu(); initSignalMenu();
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV); document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
connectWS(); connectWS();
+146 -156
View File
@@ -1,163 +1,153 @@
<!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>MARTe2 UDP Streamer</title> <title>UDP Scope</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/uPlot.min.css"> <link rel="stylesheet" href="/uPlot.min.css">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<script src="/uPlot.iife.min.js"></script> <script src="/uPlot.iife.min.js"></script>
</head> </head>
<body> <body>
<!-- ── Top bar ───────────────────────────────────────────────── -->
<!-- ── Top bar ───────────────────────────────────────────────── --> <div id="topbar">
<div id="topbar"> <button id="btn-sidebar" class="ctrl-btn active" title="Toggle sidebar"></button>
<button id="btn-sidebar" class="ctrl-btn active" title="Toggle sidebar"></button> <span id="app-title">UDP Scope</span>
<span id="app-title">MARTe2 UDP Streamer</span> <div class="topbar-vsep"></div>
<div class="topbar-vsep"></div> <button id="btn-layout" class="ctrl-btn layout-toggle" title="Select layout">⊞ 1×1 ▾</button>
<button id="btn-layout" class="ctrl-btn layout-toggle" title="Select layout">⊞ 1×1 ▾</button> <div class="topbar-sep"></div>
<div class="topbar-sep"></div> <div id="cursor-readout">
<div id="cursor-readout"> <span id="cur-ta">A: —</span><span class="cur-sep"></span>
<span id="cur-ta">A: —</span><span class="cur-sep"></span> <span id="cur-tb">B: —</span><span class="cur-sep"></span>
<span id="cur-tb">B: —</span><span class="cur-sep"></span> <span id="cur-dt">ΔT: —</span>
<span id="cur-dt">ΔT: —</span> </div>
</div> <span class="ctrl-label" id="lbl-window">Window:</span>
<span class="ctrl-label" id="lbl-window">Window:</span> <select id="window-select" class="ctrl-select">
<select id="window-select" class="ctrl-select"> <option value="1">1 s</option><option value="5" selected>5 s</option>
<option value="1">1 s</option><option value="5" selected>5 s</option> <option value="10">10 s</option><option value="30">30 s</option>
<option value="10">10 s</option><option value="30">30 s</option> <option value="60">60 s</option>
<option value="60">60 s</option> </select>
</select> <button id="btn-cursor" class="ctrl-btn" style="display:none">Cursor</button>
<button id="btn-cursor" class="ctrl-btn" style="display:none">Cursor</button> <button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button> <button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button> <button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button>
<button id="btn-csv-all" class="ctrl-btn" title="Export all signals to CSV">⬇ CSV</button> <button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button>
<button id="btn-sync-resume" class="ctrl-btn resume-btn" style="display:none">↺ Auto</button> <button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button> <button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button> </div>
</div> <!-- ── Trigger bar ───────────────────────────────────────────── -->
<div id="trigbar">
<!-- ── Trigger bar ───────────────────────────────────────────── --> <div class="trig-group">
<div id="trigbar"> <span class="trig-label">Signal</span>
<div class="trig-group"> <select id="trig-signal" class="trig-select"><option value="">— none —</option></select>
<span class="trig-label">Signal</span> </div>
<select id="trig-signal" class="trig-select"><option value="">— none —</option></select> <div class="trig-sep"></div>
</div> <div class="trig-group">
<div class="trig-sep"></div> <span class="trig-label">Edge</span>
<div class="trig-group"> <select id="trig-edge" class="trig-select">
<span class="trig-label">Edge</span> <option value="rising">Rising ↑</option>
<select id="trig-edge" class="trig-select"> <option value="falling">Falling ↓</option>
<option value="rising">Rising ↑</option> <option value="both">Both ↕</option>
<option value="falling">Falling ↓</option> </select>
<option value="both">Both ↕</option> </div>
</select> <div class="trig-sep"></div>
</div> <div class="trig-group">
<div class="trig-sep"></div> <span class="trig-label">Threshold</span>
<div class="trig-group"> <input id="trig-threshold" class="trig-input" type="number" value="0" step="any">
<span class="trig-label">Threshold</span> </div>
<input id="trig-threshold" class="trig-input" type="number" value="0" step="any"> <div class="trig-sep"></div>
</div> <div class="trig-group">
<div class="trig-sep"></div> <span class="trig-label">Window</span>
<div class="trig-group"> <select id="trig-window" class="trig-select">
<span class="trig-label">Window</span> <option value="0.0001">100 μs</option><option value="0.001">1 ms</option>
<select id="trig-window" class="trig-select"> <option value="0.01">10 ms</option><option value="0.1">100 ms</option>
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option> <option value="0.5">500 ms</option><option value="1" selected>1 s</option>
<option value="0.01">10 ms</option><option value="0.1">100 ms</option> <option value="5">5 s</option><option value="10">10 s</option>
<option value="0.5">500 ms</option><option value="1" selected>1 s</option> </select>
<option value="5">5 s</option><option value="10">10 s</option> </div>
</select> <div class="trig-sep"></div>
</div> <div class="trig-group">
<div class="trig-sep"></div> <span class="trig-label">Pre</span>
<div class="trig-group"> <input id="trig-pre" class="trig-range" type="range" min="0" max="100" value="20">
<span class="trig-label">Pre</span> <span class="trig-range-val" id="trig-pre-val">20%</span>
<input id="trig-pre" class="trig-range" type="range" min="0" max="100" value="20"> </div>
<span class="trig-range-val" id="trig-pre-val">20%</span> <div class="trig-sep"></div>
</div> <div class="trig-group">
<div class="trig-sep"></div> <span class="trig-label">Mode</span>
<div class="trig-group"> <select id="trig-mode" class="trig-select">
<span class="trig-label">Mode</span> <option value="normal">Normal</option>
<select id="trig-mode" class="trig-select"> <option value="single">Single</option>
<option value="normal">Normal</option> </select>
<option value="single">Single</option> </div>
</select> <div class="trig-sep"></div>
</div> <div class="trig-group" style="gap:8px">
<div class="trig-sep"></div> <span id="trig-status-badge">IDLE</span>
<div class="trig-group" style="gap:8px"> <button id="btn-trig-stop" style="display:none">Stop</button>
<span id="trig-status-badge">IDLE</span> <button id="btn-trig-rearm">Rearm</button>
<button id="btn-trig-stop" style="display:none">Stop</button>
<button id="btn-trig-rearm">Rearm</button>
</div>
</div>
<!-- ── Body ─────────────────────────────────────────────────── -->
<div id="body">
<div id="sidebar">
<div id="sidebar-header">Signals</div>
<div id="signal-list">
<div style="padding:20px 14px;color:var(--overlay0);font-size:12px;text-align:center;">
Waiting for streamer&hellip;
</div> </div>
</div> </div>
</div> <!-- ── Body ─────────────────────────────────────────────────── -->
<div id="main"> <div id="body">
<div id="plot-grid" class="l1x1"></div> <div id="sidebar">
</div> <div id="sidebar-header">Signals</div>
</div> <div id="signal-list"></div>
</div>
<!-- ── Status bar ─────────────────────────────────────────────── --> <div id="main">
<div id="statusbar"> <div id="plot-grid" class="l1x1"></div>
<div id="sb-left"> </div>
<div id="status-led"></div>
<span id="status-text">Disconnected</span>
<span id="sb-tsage"></span>
</div>
<span id="build-version"></span>
</div>
<div id="layout-menu"></div>
<!-- ── Signal style context menu ─────────────────────────────── -->
<div id="sig-ctx-menu" style="display:none">
<div class="ctx-menu-header">Style: <span id="ctx-menu-key" class="ctx-menu-key"></span></div>
<div class="ctx-row">
<label>Color</label>
<input type="color" id="ctx-color">
</div>
<div class="ctx-row">
<label>Width</label>
<div class="ctx-btns" id="ctx-width-btns">
<button class="ctx-btn" data-w="1">1px</button>
<button class="ctx-btn active" data-w="1.5">1.5</button>
<button class="ctx-btn" data-w="2">2px</button>
<button class="ctx-btn" data-w="3">3px</button>
</div> </div>
</div> <!-- ── Status bar ─────────────────────────────────────────────── -->
<div class="ctx-row"> <div id="statusbar">
<label>Line</label> <div id="sb-left">
<div class="ctx-btns" id="ctx-dash-btns"> <div id="status-led"></div>
<button class="ctx-btn active" data-dash="solid">——</button> <span id="status-text">Disconnected</span>
<button class="ctx-btn" data-dash="dashed">╌╌</button> <span id="sb-tsage"></span>
<button class="ctx-btn" data-dash="dotted">·····</button> </div>
<span id="build-version"></span>
</div> </div>
</div> <div id="layout-menu"></div>
<div class="ctx-row"> <!-- ── Signal style context menu ─────────────────────────────── -->
<label>Marker</label> <div id="sig-ctx-menu" style="display:none">
<div class="ctx-btns" id="ctx-marker-btns"> <div class="ctx-menu-header">Style:
<button class="ctx-btn active" data-marker="none">none</button> <span id="ctx-menu-key" class="ctx-menu-key"></span></div>
<button class="ctx-btn" data-marker="circle"></button> <div class="ctx-row">
<button class="ctx-btn" data-marker="square"></button> <label>Color</label>
<button class="ctx-btn" data-marker="cross"></button> <input type="color" id="ctx-color">
<button class="ctx-btn" data-marker="diamond"></button> </div>
<div class="ctx-row">
<label>Width</label>
<div class="ctx-btns" id="ctx-width-btns">
<button class="ctx-btn" data-w="1">1px</button>
<button class="ctx-btn active" data-w="1.5">1.5</button>
<button class="ctx-btn" data-w="2">2px</button>
<button class="ctx-btn" data-w="3">3px</button>
</div>
</div>
<div class="ctx-row">
<label>Line</label>
<div class="ctx-btns" id="ctx-dash-btns">
<button class="ctx-btn active" data-dash="solid">——</button>
<button class="ctx-btn" data-dash="dashed">╌╌</button>
<button class="ctx-btn" data-dash="dotted">·····</button>
</div>
</div>
<div class="ctx-row">
<label>Marker</label>
<div class="ctx-btns" id="ctx-marker-btns">
<button class="ctx-btn active" data-marker="none">none</button>
<button class="ctx-btn" data-marker="circle"></button>
<button class="ctx-btn" data-marker="square"></button>
<button class="ctx-btn" data-marker="cross"></button>
<button class="ctx-btn" data-marker="diamond"></button>
</div>
</div>
<div class="ctx-row">
<label>Size</label>
<input type="range" class="ctx-range" id="ctx-marker-size" min="2" max="10" value="4">
<span class="ctx-range-val" id="ctx-marker-size-val">4px</span>
</div>
</div> </div>
</div> <script src="/app.js"></script>
<div class="ctx-row"> </body>
<label>Size</label> </html>
<input type="range" class="ctx-range" id="ctx-marker-size" min="2" max="10" value="4">
<span class="ctx-range-val" id="ctx-marker-size-val">4px</span>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
+65
View File
@@ -312,6 +312,71 @@ input[type=range].trig-range::-webkit-slider-thumb {
.ctx-range { width:80px; } .ctx-range { width:80px; }
.ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; } .ctx-range-val { font-size:11px; color:var(--mauve); min-width:26px; }
/* ── Source groups ────────────────────────────────────────────── */
.source-group { margin-bottom:2px; }
.source-group-header {
display:flex; align-items:center; gap:5px;
padding:5px 8px 5px 10px; margin:4px 6px 2px;
background:var(--surface0); border-radius:6px;
}
.source-state-dot {
width:7px; height:7px; border-radius:50%; flex-shrink:0;
background:var(--overlay0); transition:background var(--transition);
}
.source-state-dot.connected { background:var(--green); }
.source-state-dot.connecting { background:var(--yellow); animation:pulse-orange 1.5s infinite; }
.source-state-dot.disconnected { background:var(--red); }
.source-name {
font-size:11px; font-weight:600; color:var(--subtext1); flex:1;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.source-addr {
font-size:10px; color:var(--overlay0); font-family:monospace;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:90px;
}
.source-remove-btn {
background:none; border:none; color:var(--overlay0);
cursor:pointer; font-size:15px; line-height:1; padding:0 2px; flex-shrink:0;
transition:color var(--transition);
}
.source-remove-btn:hover { color:var(--red); }
/* ── Add source section ───────────────────────────────────────── */
.add-source-section {
border-top:1px solid var(--surface0); margin-top:4px;
}
.add-source-title {
display:flex; align-items:center; gap:5px;
padding:6px 10px; cursor:pointer; user-select:none;
font-size:11px; color:var(--overlay0);
transition:color var(--transition);
}
.add-source-title:hover { color:var(--subtext1); }
.add-src-arrow {
font-size:9px; display:inline-block;
transition:transform var(--transition);
}
.add-source-body {
display:none; flex-direction:column; gap:5px;
padding:0 10px 10px;
}
.add-source-section.open .add-source-body { display:flex; }
.add-src-input {
background:var(--surface0); color:var(--text);
border:1px solid var(--surface1); border-radius:5px;
padding:4px 8px; font-size:12px; outline:none; width:100%;
}
.add-src-input:focus { border-color:var(--accent); }
.add-src-btn {
background:var(--surface0); color:var(--accent);
border:1px solid var(--surface1); border-radius:5px;
padding:4px 10px; font-size:12px; cursor:pointer; width:100%;
transition:background var(--transition),border-color var(--transition);
}
.add-src-btn:hover { background:rgba(137,180,250,0.15); border-color:var(--accent); }
.save-src-btn { color:var(--green); }
.save-src-btn:hover { background:rgba(166,227,161,0.1); border-color:var(--green); }
/* ── Empty state ──────────────────────────────────────────────── */ /* ── Empty state ──────────────────────────────────────────────── */
#empty-state { #empty-state {
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
+23 -26
View File
@@ -12,20 +12,20 @@ const (
readBufSize = 65536 readBufSize = 65536
) )
// UDPClient manages the UDP connection to the MARTe2 streamer. // UDPClient manages the UDP connection to one MARTe2 streamer source.
type UDPClient struct { type UDPClient struct {
serverAddr string serverAddr string
localPort int sourceID string
hub *Hub hub *Hub
stopCh chan struct{} stopCh chan struct{}
} }
// NewUDPClient creates a UDPClient. Call Run() in a goroutine. // NewUDPClient creates a UDPClient bound to a specific source ID. Call Run() in a goroutine.
func NewUDPClient(serverAddr string, localPort int, hub *Hub) *UDPClient { func NewUDPClient(serverAddr, sourceID string, hub *Hub) *UDPClient {
return &UDPClient{ return &UDPClient{
serverAddr: serverAddr, serverAddr: serverAddr,
localPort: localPort, sourceID: sourceID,
hub: hub, hub: hub,
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
} }
@@ -45,13 +45,14 @@ func (u *UDPClient) Run() {
default: default:
} }
log.Printf("udp: connecting to %s (local port %d)", u.serverAddr, u.localPort) u.hub.SetSourceState(u.sourceID, "connecting")
log.Printf("[%s] udp: connecting to %s", u.sourceID, u.serverAddr)
err := u.runSession() err := u.runSession()
if err != nil { if err != nil {
log.Printf("udp: session ended: %v", err) log.Printf("[%s] udp: session ended: %v", u.sourceID, err)
} }
u.hub.SetSourceState(u.sourceID, "disconnected")
// Check stop before sleeping.
select { select {
case <-u.stopCh: case <-u.stopCh:
return return
@@ -63,8 +64,8 @@ func (u *UDPClient) Run() {
// runSession opens a UDP socket, sends CONNECT, reads data until the server // runSession opens a UDP socket, sends CONNECT, reads data until the server
// goes silent or an error occurs. // goes silent or an error occurs.
func (u *UDPClient) runSession() error { func (u *UDPClient) runSession() error {
localAddr := &net.UDPAddr{Port: u.localPort} // Port 0 lets the OS pick a free local port.
conn, err := net.ListenUDP("udp4", localAddr) conn, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil { if err != nil {
return err return err
} }
@@ -75,36 +76,33 @@ func (u *UDPClient) runSession() error {
return err return err
} }
// Send CONNECT.
if _, err := conn.WriteToUDP(BuildConnectPacket(), serverAddr); err != nil { if _, err := conn.WriteToUDP(BuildConnectPacket(), serverAddr); err != nil {
return err return err
} }
log.Printf("udp: sent CONNECT") log.Printf("[%s] udp: sent CONNECT", u.sourceID)
reassembler := NewReassembler(2 * time.Second) reassembler := NewReassembler(2 * time.Second)
buf := make([]byte, readBufSize) buf := make([]byte, readBufSize)
var currentSigs []SignalInfo var currentSigs []SignalInfo
for { for {
// Silence timeout.
conn.SetReadDeadline(time.Now().Add(silenceTimeout)) conn.SetReadDeadline(time.Now().Add(silenceTimeout))
n, _, err := conn.ReadFromUDP(buf) n, _, err := conn.ReadFromUDP(buf)
arrivalTime := time.Now() arrivalTime := time.Now()
if err != nil { if err != nil {
// Send DISCONNECT best-effort before returning.
conn.WriteToUDP(BuildDisconnectPacket(), serverAddr) conn.WriteToUDP(BuildDisconnectPacket(), serverAddr)
return err return err
} }
if n < HeaderSize { if n < HeaderSize {
log.Printf("udp: short datagram (%d bytes), skipping", n) log.Printf("[%s] udp: short datagram (%d bytes), skipping", u.sourceID, n)
continue continue
} }
hdr, err := ParseHeader(buf[:n]) hdr, err := ParseHeader(buf[:n])
if err != nil { if err != nil {
log.Printf("udp: parse header: %v", err) log.Printf("[%s] udp: parse header: %v", u.sourceID, err)
continue continue
} }
@@ -120,37 +118,36 @@ func (u *UDPClient) runSession() error {
case PktConfig: case PktConfig:
sigs, err := ParseConfig(complete) sigs, err := ParseConfig(complete)
if err != nil { if err != nil {
log.Printf("udp: parse config: %v", err) log.Printf("[%s] udp: parse config: %v", u.sourceID, err)
continue continue
} }
currentSigs = sigs currentSigs = sigs
log.Printf("udp: received CONFIG (%d signals)", len(sigs)) log.Printf("[%s] udp: received CONFIG (%d signals)", u.sourceID, len(sigs))
u.hub.UpdateConfig(sigs) u.hub.SetSourceState(u.sourceID, "connected")
u.hub.UpdateConfigForSource(u.sourceID, sigs)
case PktData: case PktData:
if len(currentSigs) == 0 { if len(currentSigs) == 0 {
// We haven't received a CONFIG yet ignore.
continue continue
} }
sample, err := ParseData(complete, currentSigs, arrivalTime) sample, err := ParseData(complete, currentSigs, arrivalTime)
if err != nil { if err != nil {
log.Printf("udp: parse data: %v", err) log.Printf("[%s] udp: parse data: %v", u.sourceID, err)
continue continue
} }
u.hub.PushData(sample) u.hub.PushDataForSource(u.sourceID, sample)
case PktACK: case PktACK:
log.Printf("udp: received ACK (counter=%d)", hdr.Counter) log.Printf("[%s] udp: received ACK (counter=%d)", u.sourceID, hdr.Counter)
case PktDisconnect: case PktDisconnect:
log.Printf("udp: server sent DISCONNECT") log.Printf("[%s] udp: server sent DISCONNECT", u.sourceID)
return nil return nil
default: default:
log.Printf("udp: unknown packet type %d", hdr.Type) log.Printf("[%s] udp: unknown packet type %d", u.sourceID, hdr.Type)
} }
// Check stop request.
select { select {
case <-u.stopCh: case <-u.stopCh:
conn.WriteToUDP(BuildDisconnectPacket(), serverAddr) conn.WriteToUDP(BuildDisconnectPacket(), serverAddr)
+82 -19
View File
@@ -46,6 +46,23 @@ $TestApp = {
} }
} }
} }
+FastTimerGAM = {
Class = IOGAM
InputSignals = {
Time = {
Frequency = 5000
DataSource = FastTimer
Type = uint32
}
}
OutputSignals = {
Time = {
DataSource = DDB2
Type = uint32
}
}
}
// ── 1 Hz sinusoidal signal ─────────────────────────────────────────── // ── 1 Hz sinusoidal signal ───────────────────────────────────────────
+SineGAM1 = { +SineGAM1 = {
@@ -99,7 +116,7 @@ $TestApp = {
SamplingRate = 1000000.0 SamplingRate = 1000000.0
OutputSignals = { OutputSignals = {
Ch1 = { Ch1 = {
DataSource = DDB DataSource = DDB2
Type = float32 Type = float32
NumberOfDimensions = 1 NumberOfDimensions = 1
NumberOfElements = 1000 NumberOfElements = 1000
@@ -117,7 +134,7 @@ $TestApp = {
SamplingRate = 1000000.0 SamplingRate = 1000000.0
OutputSignals = { OutputSignals = {
Ch2 = { Ch2 = {
DataSource = DDB DataSource = DDB2
Type = float32 Type = float32
NumberOfDimensions = 1 NumberOfDimensions = 1
NumberOfElements = 1000 NumberOfElements = 1000
@@ -145,18 +162,6 @@ $TestApp = {
DataSource = DDB DataSource = DDB
Type = float32 Type = float32
} }
Ch1 = {
DataSource = DDB
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000
}
Ch2 = {
DataSource = DDB
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000
}
} }
OutputSignals = { OutputSignals = {
Counter = { Counter = {
@@ -175,14 +180,41 @@ $TestApp = {
DataSource = Streamer DataSource = Streamer
Type = float32 Type = float32
} }
}
}
+FastStreamerGAM = {
Class = IOGAM
InputSignals = {
Time = {
DataSource = DDB2
Type = uint32
}
Ch1 = { Ch1 = {
DataSource = Streamer DataSource = DDB2
Type = float32 Type = float32
NumberOfDimensions = 1 NumberOfDimensions = 1
NumberOfElements = 1000 NumberOfElements = 1000
} }
Ch2 = { Ch2 = {
DataSource = Streamer DataSource = DDB2
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000
}
}
OutputSignals = {
Time = {
DataSource = FastStreamer
Type = uint32
}
Ch1 = {
DataSource = FastStreamer
Type = float32
NumberOfDimensions = 1
NumberOfElements = 1000
}
Ch2 = {
DataSource = FastStreamer
Type = float32 Type = float32
NumberOfDimensions = 1 NumberOfDimensions = 1
NumberOfElements = 1000 NumberOfElements = 1000
@@ -199,6 +231,9 @@ $TestApp = {
+DDB = { +DDB = {
Class = GAMDataSource Class = GAMDataSource
} }
+DDB2 = {
Class = GAMDataSource
}
// ── Real-time clock / trigger source ───────────────────────────────── // ── Real-time clock / trigger source ─────────────────────────────────
+Timer = { +Timer = {
@@ -213,6 +248,18 @@ $TestApp = {
} }
} }
} }
+FastTimer = {
Class = LinuxTimer
SleepNature = "Default"
Signals = {
Counter = {
Type = uint32
}
Time = {
Type = uint32
}
}
}
// ── UDP Streamer DataSource ────────────────────────────────────────── // ── UDP Streamer DataSource ──────────────────────────────────────────
+Streamer = { +Streamer = {
@@ -240,6 +287,17 @@ $TestApp = {
RangeMin = -5.0 RangeMin = -5.0
RangeMax = 5.0 RangeMax = 5.0
} }
}
}
+FastStreamer = {
Class = UDPStreamer
Port = 44501
MaxPayloadSize = 1400
Signals = {
Time = {
Type = uint32
Unit = "us"
}
Ch1 = { Ch1 = {
Type = float32 Type = float32
Unit = "V" Unit = "V"
@@ -247,7 +305,7 @@ $TestApp = {
NumberOfElements = 1000 NumberOfElements = 1000
TimeMode = FirstSample TimeMode = FirstSample
TimeSignal = Time TimeSignal = Time
SamplingRate = 1000000.0 SamplingRate = 5000000.0
} }
Ch2 = { Ch2 = {
Type = float32 Type = float32
@@ -256,7 +314,7 @@ $TestApp = {
NumberOfElements = 1000 NumberOfElements = 1000
TimeMode = FirstSample TimeMode = FirstSample
TimeSignal = Time TimeSignal = Time
SamplingRate = 1000000.0 SamplingRate = 5000000.0
} }
} }
} }
@@ -276,7 +334,12 @@ $TestApp = {
+Thread1 = { +Thread1 = {
Class = RealTimeThread Class = RealTimeThread
CPUs = 0x1 CPUs = 0x1
Functions = {TimerGAM SineGAM1 SineGAM2 SineGAM3 SineGAM4 StreamerGAM} Functions = {TimerGAM SineGAM1 SineGAM2 StreamerGAM}
}
+Thread2 = {
Class = RealTimeThread
CPUs = 0x2
Functions = {FastTimerGAM SineGAM3 SineGAM4 FastStreamerGAM}
} }
} }
} }
+8 -4
View File
@@ -6,6 +6,10 @@
# ./run.sh --webui # also start the WebUI Go client in the background # ./run.sh --webui # also start the WebUI Go client in the background
# ./run.sh --help # show this message # ./run.sh --help # show this message
# #
# The WebUI is started with --source "TestApp@127.0.0.1:44500".
# Additional sources and a persistent source list file (--sources-file) can
# be configured directly in the WebUI or by editing this script.
#
# Prerequisites: # Prerequisites:
# - marte_env.sh must be present in the repo root (sets MARTe2_DIR, etc.) # - marte_env.sh must be present in the repo root (sets MARTe2_DIR, etc.)
# - MARTe2 and MARTe2-components must already be built # - MARTe2 and MARTe2-components must already be built
@@ -101,11 +105,11 @@ fi
# ── Optionally start WebUI ──────────────────────────────────────────────────── # ── Optionally start WebUI ────────────────────────────────────────────────────
if [ "${START_WEBUI}" -eq 1 ]; then if [ "${START_WEBUI}" -eq 1 ]; then
echo "==> Starting WebUI on http://localhost:8080 (streamer at 127.0.0.1:44500)..." echo "==> Starting WebUI on http://localhost:8080 (source: 127.0.0.1:44500)..."
"${WEBUI_BIN}" \ "${WEBUI_BIN}" \
--streamer 127.0.0.1:44500 \ --source "Streamer@127.0.0.1:44500" \
--listen :8080 \ --source "FastStreamer@127.0.0.1:44501" \
--clientport 44900 & --listen :8080 &
WEBUI_PID=$! WEBUI_PID=$!
echo "==> WebUI PID ${WEBUI_PID}" echo "==> WebUI PID ${WEBUI_PID}"
fi fi