f85ab8652c
UDPStreamer: - Add PublishingMode = "Strict" | "Auto" config parameter - Add MinRefreshRate (Hz) for Auto mode; uses HRT phase-locked tick counting to rate-limit sends without accumulation buffers - Fix high-frequency integration test configs to use newline-separated key-value pairs (MARTe2 StandardParser does not treat ';' as delimiter) - Add 3 new unit tests (AutoMode_Valid, AutoMode_MissingRefreshRate, UnknownPublishingMode); all 33 tests passing WebUI hub: - Skip ring-buffer LTTB writes when zoom has not been accessed in 10 s, reducing idle CPU usage - Use unsafe float64→bytes reinterpretation to eliminate per-element encoding overhead in the hot broadcast path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1065 lines
30 KiB
Go
1065 lines
30 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unsafe"
|
||
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
// ─── WebSocket client ─────────────────────────────────────────────────────────
|
||
|
||
type wsMessage struct {
|
||
msgType int
|
||
data []byte
|
||
}
|
||
|
||
type wsClient struct {
|
||
hub *Hub
|
||
conn *websocket.Conn
|
||
send chan wsMessage
|
||
}
|
||
|
||
func (c *wsClient) writePump() {
|
||
pingTicker := time.NewTicker(30 * time.Second)
|
||
defer func() {
|
||
pingTicker.Stop()
|
||
c.conn.Close()
|
||
}()
|
||
for {
|
||
select {
|
||
case msg, ok := <-c.send:
|
||
if !ok {
|
||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||
return
|
||
}
|
||
if err := c.conn.WriteMessage(msg.msgType, msg.data); err != nil {
|
||
return
|
||
}
|
||
case <-pingTicker.C:
|
||
if err := c.conn.WriteControl(websocket.PingMessage, []byte{},
|
||
time.Now().Add(10*time.Second)); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *wsClient) readPump() {
|
||
defer func() {
|
||
c.hub.unregister <- c
|
||
c.conn.Close()
|
||
}()
|
||
c.conn.SetReadLimit(64 * 1024)
|
||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||
c.conn.SetPongHandler(func(string) error {
|
||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||
return nil
|
||
})
|
||
for {
|
||
_, msg, err := c.conn.ReadMessage()
|
||
if err != nil {
|
||
break
|
||
}
|
||
var env map[string]interface{}
|
||
if json.Unmarshal(msg, &env) == nil {
|
||
if t, ok := env["type"].(string); ok {
|
||
switch t {
|
||
case "ping":
|
||
resp, _ := json.Marshal(map[string]string{"type": "pong"})
|
||
select {
|
||
case c.send <- wsMessage{websocket.TextMessage, resp}:
|
||
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:
|
||
}
|
||
}
|
||
}
|
||
}
|
||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||
}
|
||
}
|
||
|
||
// ─── Hub ─────────────────────────────────────────────────────────────────────
|
||
|
||
var upgrader = websocket.Upgrader{
|
||
ReadBufferSize: 4096,
|
||
WriteBufferSize: 64 * 1024,
|
||
CheckOrigin: func(r *http.Request) bool { return true },
|
||
}
|
||
|
||
// sourceHubState holds all data for one active data source.
|
||
// Only accessed from the Run() goroutine.
|
||
type sourceHubState struct {
|
||
id, label, addr, connState string
|
||
signals []SignalInfo
|
||
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, except
|
||
// ringsMu/rings which are also read by HTTP handler goroutines.
|
||
type Hub struct {
|
||
clients map[*wsClient]bool
|
||
register chan *wsClient
|
||
unregister chan *wsClient
|
||
broadcastCh chan []byte
|
||
dataCh chan taggedSample
|
||
commandCh chan hubCmd
|
||
|
||
sm *SourceManager // set after construction; used for WS-initiated source changes
|
||
|
||
// Ring buffers for hi-res zoom data.
|
||
// ringsMu protects the map structure; each sigRing has its own RWMutex for data.
|
||
ringsMu sync.RWMutex
|
||
rings map[string]*sigRing // "sourceId:signalKey" → ring
|
||
|
||
// lastZoomAt tracks the last time a zoom request was served.
|
||
// Ring buffer writes are skipped when no zoom has been requested
|
||
// in the last 10 s, saving substantial CPU on LTTB + ring writes.
|
||
lastZoomAt time.Time
|
||
zoomAtMu sync.Mutex
|
||
|
||
statsMu sync.RWMutex
|
||
statsMap map[string]*SourceStat
|
||
}
|
||
|
||
// NewHub creates an initialised Hub.
|
||
func NewHub() *Hub {
|
||
return &Hub{
|
||
clients: make(map[*wsClient]bool),
|
||
register: make(chan *wsClient, 8),
|
||
unregister: make(chan *wsClient, 8),
|
||
broadcastCh: make(chan []byte, 256),
|
||
dataCh: make(chan taggedSample, 65536), // large buffer: absorbs bursts at high sample rates
|
||
commandCh: make(chan hubCmd, 64),
|
||
rings: make(map[string]*sigRing),
|
||
statsMap: make(map[string]*SourceStat),
|
||
}
|
||
}
|
||
|
||
// getRing returns the ring buffer for a fully-prefixed signal key, or nil.
|
||
func (h *Hub) getRing(key string) *sigRing {
|
||
h.ringsMu.RLock()
|
||
rb := h.rings[key]
|
||
h.ringsMu.RUnlock()
|
||
return rb
|
||
}
|
||
|
||
// shouldWriteRing returns true if zoom was requested within the last 10 seconds.
|
||
// When false, the hot path can skip LTTB decimation for the ring buffer entirely.
|
||
func (h *Hub) shouldWriteRing() bool {
|
||
h.zoomAtMu.Lock()
|
||
ok := time.Since(h.lastZoomAt) < 10*time.Second
|
||
h.zoomAtMu.Unlock()
|
||
return ok
|
||
}
|
||
|
||
// HandleZoom serves GET /api/zoom?... It also records the access time
|
||
// so the ring buffer knows zoom is active and worth populating.
|
||
func (h *Hub) HandleZoom(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
t0, err0 := strconv.ParseFloat(q.Get("t0"), 64)
|
||
t1, err1 := strconv.ParseFloat(q.Get("t1"), 64)
|
||
if err0 != nil || err1 != nil || t1 <= t0 {
|
||
http.Error(w, "invalid t0/t1", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// n=0 (or explicit "0") means no LTTB decimation — return all ring data in range.
|
||
// n omitted / invalid → default 2400 (display quality).
|
||
var n int
|
||
if nStr := q.Get("n"); nStr == "" {
|
||
n = 2400
|
||
} else {
|
||
n, _ = strconv.Atoi(nStr)
|
||
if n <= 0 {
|
||
n = 1 << 30 // no decimation
|
||
} else if n < 10 {
|
||
n = 2400
|
||
}
|
||
}
|
||
|
||
// Record zoom access time so ring writes stay active.
|
||
// Skip n=0 requests (full-data exports / trigger snapshots) — only
|
||
// interactive zooms should keep the ring buffer populated.
|
||
if n > 0 {
|
||
h.zoomAtMu.Lock()
|
||
h.lastZoomAt = time.Now()
|
||
h.zoomAtMu.Unlock()
|
||
}
|
||
|
||
keys := strings.Split(q.Get("signals"), ",")
|
||
|
||
// Collect ring references under a brief RLock.
|
||
h.ringsMu.RLock()
|
||
refs := make(map[string]*sigRing, len(keys))
|
||
for _, k := range keys {
|
||
k = strings.TrimSpace(k)
|
||
if k == "" {
|
||
continue
|
||
}
|
||
if rb, ok := h.rings[k]; ok {
|
||
refs[k] = rb
|
||
}
|
||
}
|
||
h.ringsMu.RUnlock()
|
||
|
||
result := make(map[string]sigData, len(refs))
|
||
for k, rb := range refs {
|
||
rt, rv := rb.slice(t0, t1)
|
||
if len(rt) == 0 {
|
||
continue
|
||
}
|
||
dt, dv := lttbDecimate(rt, rv, n)
|
||
result[k] = sigData{T: dt, V: dv}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||
"type": "zoom",
|
||
"signals": result,
|
||
}); err != nil {
|
||
log.Printf("hub: zoom encode: %v", err)
|
||
}
|
||
}
|
||
|
||
// AddSource notifies the Hub that a new source has been registered.
|
||
func (h *Hub) AddSource(id, label, addr string) {
|
||
select {
|
||
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:
|
||
}
|
||
}
|
||
|
||
// broadcast enqueues a message for delivery to all WebSocket clients.
|
||
func (h *Hub) broadcast(msg []byte) {
|
||
select {
|
||
case h.broadcastCh <- msg:
|
||
default:
|
||
}
|
||
}
|
||
|
||
// HandleWebSocket upgrades an HTTP request to a WebSocket connection.
|
||
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||
conn, err := upgrader.Upgrade(w, r, nil)
|
||
if err != nil {
|
||
log.Printf("ws upgrade: %v", err)
|
||
return
|
||
}
|
||
c := &wsClient{hub: h, conn: conn, send: make(chan wsMessage, 64)}
|
||
h.register <- c
|
||
go c.writePump()
|
||
go c.readPump()
|
||
}
|
||
|
||
// 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() {
|
||
ticker := time.NewTicker(time.Second / 30)
|
||
defer ticker.Stop()
|
||
|
||
statsTicker := time.NewTicker(time.Second)
|
||
defer statsTicker.Stop()
|
||
|
||
sourcesMap := make(map[string]*sourceHubState)
|
||
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 {
|
||
select {
|
||
case c := <-h.register:
|
||
h.clients[c] = true
|
||
// Send current state to the new client.
|
||
if sourcesMsg != nil {
|
||
select { case c.send <- wsMessage{websocket.TextMessage, sourcesMsg}: default: }
|
||
}
|
||
for _, src := range sourcesMap {
|
||
if src.configJS != nil {
|
||
select { case c.send <- wsMessage{websocket.TextMessage, src.configJS}: default: }
|
||
}
|
||
}
|
||
|
||
case c := <-h.unregister:
|
||
if _, ok := h.clients[c]; ok {
|
||
delete(h.clients, c)
|
||
close(c.send)
|
||
}
|
||
|
||
case msg := <-h.broadcastCh:
|
||
for c := range h.clients {
|
||
select { case c.send <- wsMessage{websocket.TextMessage, 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),
|
||
}
|
||
h.statsMu.Lock()
|
||
h.statsMap[cmd.sourceID] = &SourceStat{}
|
||
h.statsMu.Unlock()
|
||
rebuildSources()
|
||
|
||
case "removeSource":
|
||
delete(sourcesMap, cmd.sourceID)
|
||
delete(pending, cmd.sourceID)
|
||
pfxDel := cmd.sourceID + ":"
|
||
h.ringsMu.Lock()
|
||
for k := range h.rings {
|
||
if strings.HasPrefix(k, pfxDel) {
|
||
delete(h.rings, k)
|
||
}
|
||
}
|
||
h.ringsMu.Unlock()
|
||
h.statsMu.Lock()
|
||
delete(h.statsMap, cmd.sourceID)
|
||
h.statsMu.Unlock()
|
||
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]any{
|
||
"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)
|
||
// Rebuild ring buffers for this source.
|
||
pfxUpd := cmd.sourceID + ":"
|
||
h.ringsMu.Lock()
|
||
for k := range h.rings {
|
||
if strings.HasPrefix(k, pfxUpd) {
|
||
delete(h.rings, k)
|
||
}
|
||
}
|
||
for _, sig := range cmd.sigs {
|
||
ne := sig.NumElements()
|
||
isTemporal := ne > 1 && sig.TimeMode != TimeModePacket
|
||
if isTemporal {
|
||
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal)
|
||
} else if ne == 1 {
|
||
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar)
|
||
} else {
|
||
for i := 0; i < ne; i++ {
|
||
h.rings[pfxUpd+arrayKey(sig.Name, i)] = newSigRing(ringCapScalar)
|
||
}
|
||
}
|
||
}
|
||
h.ringsMu.Unlock()
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
case ts := <-h.dataCh:
|
||
pending[ts.sourceID] = append(pending[ts.sourceID], ts.sample)
|
||
|
||
case <-ticker.C:
|
||
for srcID, samples := range pending {
|
||
if len(samples) == 0 {
|
||
continue
|
||
}
|
||
src, ok := sourcesMap[srcID]
|
||
if !ok || len(src.signals) == 0 || len(h.clients) == 0 {
|
||
pending[srcID] = pending[srcID][:0]
|
||
continue
|
||
}
|
||
msg := h.buildBinaryDataMessageForSource(src, samples)
|
||
pending[srcID] = pending[srcID][:0]
|
||
if msg != nil {
|
||
for c := range h.clients {
|
||
select {
|
||
case c.send <- wsMessage{websocket.BinaryMessage, msg}:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
case <-statsTicker.C:
|
||
h.statsMu.RLock()
|
||
snap := make(map[string]StatInfo, len(h.statsMap))
|
||
for id, st := range h.statsMap {
|
||
snap[id] = st.Snapshot()
|
||
}
|
||
h.statsMu.RUnlock()
|
||
if len(snap) > 0 {
|
||
msg, _ := json.Marshal(map[string]any{"type": "stats", "sources": snap})
|
||
h.broadcast(msg)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// float64ToBytes reinterprets a []float64 as []byte without copying.
|
||
// The caller must ensure the slice is not modified while the byte view is in use.
|
||
func float64ToBytes(f []float64) []byte {
|
||
if len(f) == 0 {
|
||
return nil
|
||
}
|
||
return unsafe.Slice((*byte)(unsafe.Pointer(&f[0])), len(f)*8)
|
||
}
|
||
|
||
// writeFloat64s encodes a []float64 as little-endian bytes into buf at offset
|
||
// and returns the new offset. Uses unsafe to avoid per-element PutUint64 calls.
|
||
func writeFloat64s(buf []byte, off int, f []float64) int {
|
||
copy(buf[off:], float64ToBytes(f))
|
||
return off + len(f)*8
|
||
}
|
||
|
||
// ─── Data serialisation ───────────────────────────────────────────────────────
|
||
|
||
// maxPushPoints is the LTTB target for data pushed over WebSocket per 30 Hz tick.
|
||
// With cascaded LTTB the server selects the most visually significant pts from
|
||
// each batch; the browser accumulates ticks × pts/tick for the rolling window.
|
||
// For a 1 200 px plot showing a 5 s window at 30 Hz: 2×1200/(5×30) ≈ 16 pts/tick
|
||
// needed. 50 gives 4× headroom: 50×30×5 = 7 500 pts → trivial 120 KB buffer.
|
||
// Zoom resolution is unaffected — the ring buffer (maxRingPoints) serves /api/zoom.
|
||
const maxPushPoints = 50
|
||
|
||
// maxRingPoints is the LTTB target written into the ring buffer per tick.
|
||
// At 5 Msps / 5 kHz packet rate ≈ 167 k raw samples/tick → LTTB to 20 k →
|
||
// min Δt ≈ 33 ms / 20 k ≈ 1.65 µs, sufficient for sub-10 µs zoom resolution.
|
||
const maxRingPoints = 20_000
|
||
|
||
// ringCapTemporal is the ring buffer capacity for temporal-array signals.
|
||
// At 20 k pts/tick × 30 Hz = 600 k pts/s → 6 M cap gives ~10 s of hi-res
|
||
// history — the same temporal coverage as the frontend push buffer.
|
||
const ringCapTemporal = 6_000_000
|
||
|
||
// ringCapScalar is the ring buffer capacity for scalar / spatial-array signals.
|
||
// At ≤10 kHz → ~333 pts/tick × 30 Hz ≈ 10 k pts/s → ~10 s of history.
|
||
const ringCapScalar = 100_000
|
||
|
||
// 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 {
|
||
T []float64 `json:"t"`
|
||
V []float64 `json:"v"`
|
||
}
|
||
|
||
// dataMsg is the JSON envelope sent to WebSocket clients.
|
||
type dataMsg struct {
|
||
Type string `json:"type"`
|
||
SourceID string `json:"sourceId"`
|
||
Signals map[string]sigData `json:"signals"`
|
||
}
|
||
|
||
// buildDataMessageForSource serialises a batch of samples for one source.
|
||
// Signal keys in the output are prefixed with "sourceId:" so the browser can
|
||
// store them in a single flat buffer map without collision.
|
||
func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample) []byte {
|
||
if len(batch) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Reset time-signal calibration whenever the signal config changed.
|
||
if src.configSeq != src.configSeqAtCalib {
|
||
src.configSeqAtCalib = src.configSeq
|
||
src.timeSigCalib = make(map[string]float64)
|
||
}
|
||
|
||
sigs := src.signals
|
||
pfx := src.id + ":"
|
||
out := make(map[string]sigData, len(sigs)*2)
|
||
|
||
for _, sig := range sigs {
|
||
n := sig.NumElements()
|
||
|
||
switch {
|
||
case n > 1 && (sig.TimeMode == TimeModeFirstSample || sig.TimeMode == TimeModeLastSample):
|
||
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
||
var timeSigName string
|
||
// uint64 signals carry nanoseconds (HRT); everything else is assumed microseconds.
|
||
timerToSec := 1e-6
|
||
if hasTimeSig {
|
||
ts := sigs[sig.TimeSignalIdx]
|
||
timeSigName = ts.Name
|
||
if ts.TypeCode == 6 { // uint64 → nanoseconds
|
||
timerToSec = 1e-9
|
||
}
|
||
}
|
||
dt := 0.0
|
||
if sig.SamplingRate > 0 {
|
||
dt = 1.0 / sig.SamplingRate
|
||
}
|
||
allT := make([]float64, 0, len(batch)*n)
|
||
allV := make([]float64, 0, len(batch)*n)
|
||
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) < n {
|
||
continue
|
||
}
|
||
var anchorTime float64
|
||
anchorIsFirstSample := sig.TimeMode == TimeModeFirstSample
|
||
if hasTimeSig {
|
||
tVals, tOk := s.Values[timeSigName]
|
||
if tOk && len(tVals) >= 1 {
|
||
timerS := tVals[0] * timerToSec
|
||
wallT := float64(s.WallTime.UnixNano()) / 1e9
|
||
if _, exists := src.timeSigCalib[timeSigName]; !exists {
|
||
src.timeSigCalib[timeSigName] = wallT - timerS
|
||
}
|
||
anchorTime = src.timeSigCalib[timeSigName] + timerS
|
||
} else {
|
||
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
|
||
anchorIsFirstSample = false
|
||
}
|
||
} else {
|
||
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
|
||
anchorIsFirstSample = false
|
||
}
|
||
for k := 0; k < n; k++ {
|
||
var t float64
|
||
if anchorIsFirstSample {
|
||
t = anchorTime + float64(k)*dt
|
||
} else {
|
||
t = anchorTime - float64(n-1-k)*dt
|
||
}
|
||
allT = append(allT, t)
|
||
allV = append(allV, vals[k])
|
||
}
|
||
}
|
||
|
||
// Write hi-res LTTB data to ring for on-demand zoom queries.
|
||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||
rb.write(ringT, ringV)
|
||
}
|
||
// Decimate further for WebSocket push (rolling window).
|
||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||
out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
|
||
|
||
case n > 1 && sig.TimeMode == TimeModeFullArray:
|
||
// The time signal has the same N elements as the data signal.
|
||
// Each element pair (timeSig[k], dataSig[k]) is one (t, v) sample.
|
||
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
||
var timeSigName string
|
||
timerToSec := 1e-6
|
||
if hasTimeSig {
|
||
ts := sigs[sig.TimeSignalIdx]
|
||
timeSigName = ts.Name
|
||
if ts.TypeCode == 6 { // uint64 → nanoseconds
|
||
timerToSec = 1e-9
|
||
}
|
||
}
|
||
allT := make([]float64, 0, len(batch)*n)
|
||
allV := make([]float64, 0, len(batch)*n)
|
||
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) < n {
|
||
continue
|
||
}
|
||
if hasTimeSig {
|
||
tVals, tOk := s.Values[timeSigName]
|
||
if tOk && len(tVals) >= n {
|
||
// Calibrate once: map timer ticks to wall-clock seconds.
|
||
if _, exists := src.timeSigCalib[timeSigName]; !exists {
|
||
wallT := float64(s.WallTime.UnixNano()) / 1e9
|
||
src.timeSigCalib[timeSigName] = wallT - tVals[0]*timerToSec
|
||
}
|
||
calib := src.timeSigCalib[timeSigName]
|
||
for k := 0; k < n; k++ {
|
||
allT = append(allT, calib+tVals[k]*timerToSec)
|
||
allV = append(allV, vals[k])
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
// Fallback: stamp all elements with packet arrival time.
|
||
wallT := float64(s.WallTime.UnixNano()) / 1e9
|
||
for k := 0; k < n; k++ {
|
||
allT = append(allT, wallT)
|
||
allV = append(allV, vals[k])
|
||
}
|
||
}
|
||
|
||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||
rb.write(ringT, ringV)
|
||
}
|
||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||
out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
|
||
|
||
case n == 1:
|
||
ts := make([]float64, 0, len(batch))
|
||
vs := make([]float64, 0, len(batch))
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) < 1 {
|
||
continue
|
||
}
|
||
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
|
||
vs = append(vs, vals[0])
|
||
}
|
||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||
rb.write(ts, vs)
|
||
}
|
||
out[pfx+sig.Name] = sigData{T: ts, V: vs}
|
||
|
||
default:
|
||
// Spatial / PacketTime array: one stream per element.
|
||
for i := 0; i < n; i++ {
|
||
key := pfx + arrayKey(sig.Name, i)
|
||
ts := make([]float64, 0, len(batch))
|
||
vs := make([]float64, 0, len(batch))
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) <= i {
|
||
continue
|
||
}
|
||
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
|
||
vs = append(vs, vals[i])
|
||
}
|
||
if rb := h.getRing(key); rb != nil {
|
||
rb.write(ts, vs)
|
||
}
|
||
out[key] = sigData{T: ts, V: vs}
|
||
}
|
||
}
|
||
}
|
||
|
||
result, err := json.Marshal(dataMsg{Type: "data", SourceID: src.id, Signals: out})
|
||
if err != nil {
|
||
log.Printf("hub: marshal data: %v", err)
|
||
return nil
|
||
}
|
||
return result
|
||
}
|
||
|
||
// buildBinaryDataMessageForSource encodes a batch of samples as a compact binary
|
||
// frame for WebSocket binary messages. Skips the JSON overhead entirely.
|
||
//
|
||
// Wire format (little-endian):
|
||
//
|
||
// uint8 version (1)
|
||
// uint8 source ID length
|
||
// UTF-8 source ID
|
||
// uint32 number of signals
|
||
// for each signal:
|
||
// uint16 key length
|
||
// UTF-8 key (relative to source, e.g. "sigName" not "s1:sigName")
|
||
// uint32 pair count N
|
||
// float64[N] t values
|
||
// float64[N] v values
|
||
func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []DataSample) []byte {
|
||
if len(batch) == 0 {
|
||
return nil
|
||
}
|
||
if src.configSeq != src.configSeqAtCalib {
|
||
src.configSeqAtCalib = src.configSeq
|
||
src.timeSigCalib = make(map[string]float64)
|
||
}
|
||
|
||
sigs := src.signals
|
||
pfx := src.id + ":"
|
||
writeRing := h.shouldWriteRing()
|
||
|
||
// ---- Phase 1: collect (t,v) for each signal (same logic as JSON path) ----
|
||
|
||
type pairBuf struct {
|
||
t, v []float64
|
||
}
|
||
pairs := make(map[string]pairBuf, len(sigs)*2)
|
||
|
||
for _, sig := range sigs {
|
||
n := sig.NumElements()
|
||
|
||
switch {
|
||
case n > 1 && (sig.TimeMode == TimeModeFirstSample || sig.TimeMode == TimeModeLastSample):
|
||
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
||
var timeSigName string
|
||
timerToSec := 1e-6
|
||
if hasTimeSig {
|
||
ts := sigs[sig.TimeSignalIdx]
|
||
timeSigName = ts.Name
|
||
if ts.TypeCode == 6 {
|
||
timerToSec = 1e-9
|
||
}
|
||
}
|
||
dt := 0.0
|
||
if sig.SamplingRate > 0 {
|
||
dt = 1.0 / sig.SamplingRate
|
||
}
|
||
allT := make([]float64, 0, len(batch)*n)
|
||
allV := make([]float64, 0, len(batch)*n)
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) < n {
|
||
continue
|
||
}
|
||
var anchorTime float64
|
||
anchorIsFirstSample := sig.TimeMode == TimeModeFirstSample
|
||
if hasTimeSig {
|
||
tVals, tOk := s.Values[timeSigName]
|
||
if tOk && len(tVals) >= 1 {
|
||
timerS := tVals[0] * timerToSec
|
||
wallT := float64(s.WallTime.UnixNano()) / 1e9
|
||
if _, exists := src.timeSigCalib[timeSigName]; !exists {
|
||
src.timeSigCalib[timeSigName] = wallT - timerS
|
||
}
|
||
anchorTime = src.timeSigCalib[timeSigName] + timerS
|
||
} else {
|
||
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
|
||
anchorIsFirstSample = false
|
||
}
|
||
} else {
|
||
anchorTime = float64(s.WallTime.UnixNano()) / 1e9
|
||
anchorIsFirstSample = false
|
||
}
|
||
for k := 0; k < n; k++ {
|
||
var t float64
|
||
if anchorIsFirstSample {
|
||
t = anchorTime + float64(k)*dt
|
||
} else {
|
||
t = anchorTime - float64(n-1-k)*dt
|
||
}
|
||
allT = append(allT, t)
|
||
allV = append(allV, vals[k])
|
||
}
|
||
}
|
||
// Write hi-res LTTB data to ring (only if zoom is active).
|
||
if writeRing {
|
||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||
rb.write(ringT, ringV)
|
||
}
|
||
}
|
||
// Decimate for push.
|
||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||
|
||
case n > 1 && sig.TimeMode == TimeModeFullArray:
|
||
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
|
||
var timeSigName string
|
||
timerToSec := 1e-6
|
||
if hasTimeSig {
|
||
ts := sigs[sig.TimeSignalIdx]
|
||
timeSigName = ts.Name
|
||
if ts.TypeCode == 6 {
|
||
timerToSec = 1e-9
|
||
}
|
||
}
|
||
allT := make([]float64, 0, len(batch)*n)
|
||
allV := make([]float64, 0, len(batch)*n)
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) < n {
|
||
continue
|
||
}
|
||
if hasTimeSig {
|
||
tVals, tOk := s.Values[timeSigName]
|
||
if tOk && len(tVals) >= n {
|
||
if _, exists := src.timeSigCalib[timeSigName]; !exists {
|
||
wallT := float64(s.WallTime.UnixNano()) / 1e9
|
||
src.timeSigCalib[timeSigName] = wallT - tVals[0]*timerToSec
|
||
}
|
||
calib := src.timeSigCalib[timeSigName]
|
||
for k := 0; k < n; k++ {
|
||
allT = append(allT, calib+tVals[k]*timerToSec)
|
||
allV = append(allV, vals[k])
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
wallT := float64(s.WallTime.UnixNano()) / 1e9
|
||
for k := 0; k < n; k++ {
|
||
allT = append(allT, wallT)
|
||
allV = append(allV, vals[k])
|
||
}
|
||
}
|
||
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
|
||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||
rb.write(ringT, ringV)
|
||
}
|
||
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
|
||
pairs[sig.Name] = pairBuf{t: decimT, v: decimV}
|
||
|
||
case n == 1:
|
||
ts := make([]float64, 0, len(batch))
|
||
vs := make([]float64, 0, len(batch))
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) < 1 {
|
||
continue
|
||
}
|
||
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
|
||
vs = append(vs, vals[0])
|
||
}
|
||
if writeRing {
|
||
if rb := h.getRing(pfx + sig.Name); rb != nil {
|
||
rb.write(ts, vs)
|
||
}
|
||
}
|
||
pairs[sig.Name] = pairBuf{t: ts, v: vs}
|
||
|
||
default:
|
||
for i := 0; i < n; i++ {
|
||
key := arrayKey(sig.Name, i)
|
||
ts := make([]float64, 0, len(batch))
|
||
vs := make([]float64, 0, len(batch))
|
||
for _, s := range batch {
|
||
vals, ok := s.Values[sig.Name]
|
||
if !ok || len(vals) <= i {
|
||
continue
|
||
}
|
||
ts = append(ts, float64(s.WallTime.UnixNano())/1e9)
|
||
vs = append(vs, vals[i])
|
||
}
|
||
if writeRing {
|
||
if rb := h.getRing(pfx + key); rb != nil {
|
||
rb.write(ts, vs)
|
||
}
|
||
}
|
||
pairs[key] = pairBuf{t: ts, v: vs}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Phase 2: compute total size and serialize ----
|
||
totalSize := 1 + 1 + len(src.id) + 4 // version + srcIdLen + srcId + numSigs
|
||
for key, p := range pairs {
|
||
totalSize += 2 + len(key) + 4 // keyLen + key + pairCount
|
||
totalSize += len(p.t)*8 + len(p.v)*8 // t + v float64 data
|
||
}
|
||
|
||
buf := make([]byte, totalSize)
|
||
buf[0] = 1 // version
|
||
buf[1] = byte(len(src.id))
|
||
copy(buf[2:], src.id)
|
||
off := 2 + len(src.id)
|
||
binary.LittleEndian.PutUint32(buf[off:], uint32(len(pairs)))
|
||
off += 4
|
||
|
||
for key, p := range pairs {
|
||
binary.LittleEndian.PutUint16(buf[off:], uint16(len(key)))
|
||
off += 2
|
||
copy(buf[off:], key)
|
||
off += len(key)
|
||
binary.LittleEndian.PutUint32(buf[off:], uint32(len(p.t)))
|
||
off += 4
|
||
off = writeFloat64s(buf, off, p.t)
|
||
off = writeFloat64s(buf, off, p.v)
|
||
}
|
||
|
||
return buf
|
||
}
|
||
|
||
// RecordDataFragment is called by UDPClient for every incoming DATA datagram.
|
||
func (h *Hub) RecordDataFragment(sourceID string, counter uint32, nBytes int, arrivalNs int64, complete bool) {
|
||
h.statsMu.RLock()
|
||
st := h.statsMap[sourceID]
|
||
h.statsMu.RUnlock()
|
||
if st != nil {
|
||
st.RecordFragment(counter, nBytes, arrivalNs, complete)
|
||
}
|
||
}
|
||
|
||
// arrayKey returns the buffer key for element i of an array signal.
|
||
func arrayKey(name string, i int) string {
|
||
return name + "[" + itoa(i) + "]"
|
||
}
|
||
|
||
func itoa(n int) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
buf := [20]byte{}
|
||
pos := len(buf)
|
||
for n > 0 {
|
||
pos--
|
||
buf[pos] = byte('0' + n%10)
|
||
n /= 10
|
||
}
|
||
return string(buf[pos:])
|
||
}
|