package wshub import ( "encoding/binary" "encoding/json" "log" "math" "net/http" "strconv" "strings" "sync" "time" "unsafe" "github.com/gorilla/websocket" "marte2/common/udpsprotocol" ) // ─── 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) mcastGroup, _ := env["multicastGroup"].(string) dataPortF, _ := env["dataPort"].(float64) if addr != "" { select { case c.hub.commandCh <- hubCmd{ op: "wsAddSource", label: label, addr: addr, multicastGroup: mcastGroup, dataPort: int(dataPortF), }: 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: } default: // Unrecognized message type — forward to DebugCh select { case c.hub.DebugCh <- msg: default: } } } } c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) } } // ─── Hub ───────────────────────────────────────────────────────────────────── // allowedOrigins is the set of Origin values (scheme://host[:port]) that are // accepted for WebSocket upgrades. If empty, same-origin is enforced by // comparing the Origin's host to the HTTP Host header. var allowedOrigins []string // SetAllowedOrigins configures the WebSocket Origin allowlist. Pass an empty // slice to enforce same-origin only (the default). func SetAllowedOrigins(origins []string) { allowedOrigins = origins } // checkOrigin validates the Origin header against the allowlist, falling back // to a same-origin check (Origin host == Host header) when no allowlist is // configured. Requests with no Origin header (non-browser clients) are allowed. func checkOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true // non-browser client } // Check explicit allowlist first. for _, allowed := range allowedOrigins { if origin == allowed { return true } } // Fall back to same-origin: compare the Origin's host to the Host header. // Origin format: "scheme://host[:port]" — strip scheme. host := origin if idx := strings.Index(host, "://"); idx >= 0 { host = host[idx+3:] } // Strip path if present. if idx := strings.Index(host, "/"); idx >= 0 { host = host[:idx] } return host == r.Host } var upgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 64 * 1024, CheckOrigin: checkOrigin, } // sourceHubState holds all data for one active data source. // Only accessed from the Run() goroutine. type sourceHubState struct { id, label, addr, connState string signals []udpsprotocol.SignalInfo configJS []byte // Time-signal calibration — only accessed from Run() goroutine. timeSigCalib map[string]float64 configSeq uint64 configSeqAtCalib uint64 // lastPktNs tracks the wall-clock time (UnixNano) of the last received packet // per signal name. Used by the default (TimeModePacket, n>1) path to estimate // per-element dt when only one packet arrives in a 30 Hz tick. lastPktNs map[string]int64 } // taggedSample is a DataSample annotated with its source ID. type taggedSample struct { sourceID string sample udpsprotocol.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 []udpsprotocol.SignalInfo multicastGroup string dataPort int } // 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 // DebugCh receives raw browser messages whose type is not handled by the hub. DebugCh chan []byte 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 // onClientConnect, if set, is called each time a new WebSocket client // registers. The callback receives a send function that delivers a message // directly to that client. It is invoked synchronously from Run(), so it // must not block. onClientConnectMu sync.RWMutex onClientConnect func(send func([]byte)) } // 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), DebugCh: make(chan []byte, 256), rings: make(map[string]*sigRing), statsMap: make(map[string]*SourceStat), } } // SetOnClientConnect registers a callback invoked synchronously (from Run()) // each time a new WebSocket client connects. The callback receives a send // function that enqueues one message to that specific client. func (h *Hub) SetOnClientConnect(fn func(send func([]byte))) { h.onClientConnectMu.Lock() h.onClientConnect = fn h.onClientConnectMu.Unlock() } // SetSourceManager sets the SourceManager associated with the Hub. func (h *Hub) SetSourceManager(sm *SourceManager) { h.sm = sm } // 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. 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 } 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 } } if n > 0 { h.zoomAtMu.Lock() h.lastZoomAt = time.Now() h.zoomAtMu.Unlock() } keys := strings.Split(q.Get("signals"), ",") 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 []udpsprotocol.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 udpsprotocol.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: } } // Broadcast is the exported wrapper for broadcast. func (h *Hub) Broadcast(msg []byte) { h.broadcast(msg) } // 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][]udpsprotocol.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: } } } // Notify the application layer so it can replay any persistent state // (e.g., MARTe2 connection status, forced/traced signals). h.onClientConnectMu.RLock() fn := h.onClientConnect h.onClientConnectMu.RUnlock() if fn != nil { fn(func(msg []byte) { select { case c.send <- wsMessage{websocket.TextMessage, msg}: 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), lastPktNs: make(map[string]int64), } 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 != udpsprotocol.TimeModePacket if isTemporal { h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal) } else if ne == 1 { h.rings[pfxUpd+sig.Name] = newSigRing(ringCapScalar) } else { // n>1, TimeModePacket snapshot-waveform: each packet contributes n // elements, so use the temporal capacity to hold enough history. h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal) } } h.ringsMu.Unlock() case "wsAddSource": if h.sm != nil { go func(label, addr, mcastGroup string, dataPort int) { h.sm.Add(label, addr, mcastGroup, dataPort) }(cmd.label, cmd.addr, cmd.multicastGroup, cmd.dataPort) } 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. 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. func writeFloat64s(buf []byte, off int, f []float64) int { copy(buf[off:], float64ToBytes(f)) return off + len(f)*8 } // ─── Data serialisation ─────────────────────────────────────────────────────── const maxPushPoints = 50 const maxRingPoints = 20_000 const ringCapTemporal = 6_000_000 const ringCapScalar = 100_000 // lttbDecimate reduces (tIn, vIn) to at most threshold representative points // using the Largest-Triangle-Three-Buckets algorithm. 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++ { 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) } 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 } type sigData struct { T []float64 `json:"t"` V []float64 `json:"v"` } type dataMsg struct { Type string `json:"type"` SourceID string `json:"sourceId"` Signals map[string]sigData `json:"signals"` } // buildBinaryDataMessageForSource encodes a batch of samples as a compact binary frame. func (h *Hub) buildBinaryDataMessageForSource(src *sourceHubState, batch []udpsprotocol.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() 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 == udpsprotocol.TimeModeFirstSample || sig.TimeMode == udpsprotocol.TimeModeLastSample): hasTimeSig := sig.TimeSignalIdx != udpsprotocol.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 == udpsprotocol.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]) } } if writeRing { 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 sig.TimeMode == udpsprotocol.TimeModeFullArray: hasTimeSig := sig.TimeSignalIdx != udpsprotocol.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]) } } if writeRing { 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: // n > 1, TimeModePacket: C++ sends samplingRate=0 so we interpolate // per-element timestamps from wall-clock differences between packets. // // Three fixes vs the naïve approach: // 1. Use src.lastPktNs[name] for the single-packet case so dt is // estimated from the actual inter-packet gap, not 1/n. // 2. Send all n elements to the browser without LTTB so sinusoidal // waveforms are not degraded (packets arrive at ≤30 Hz, bandwidth // is trivially acceptable). // 3. Always write the ring buffer regardless of shouldWriteRing() so // the first zoom request immediately returns full-resolution data. allT := make([]float64, 0, len(batch)*n) allV := make([]float64, 0, len(batch)*n) for bi, s := range batch { vals, ok := s.Values[sig.Name] if !ok || len(vals) < n { continue } wallNs := s.WallTime.UnixNano() wallSec := float64(wallNs) / 1e9 var dtSec float64 if bi+1 < len(batch) { // Two consecutive packets in this tick → exact dt. dtSec = (float64(batch[bi+1].WallTime.UnixNano())-float64(wallNs))/1e9/float64(n) } else if bi > 0 { // Last of multiple packets → use diff from previous. dtSec = (float64(wallNs)-float64(batch[bi-1].WallTime.UnixNano()))/1e9/float64(n) } else if prevNs, ok2 := src.lastPktNs[sig.Name]; ok2 && prevNs > 0 && wallNs > prevNs { // Single packet this tick → gap from the previous tick's packet. dtSec = (float64(wallNs)-float64(prevNs))/1e9/float64(n) } else { // Truly first packet ever — inter-packet timing unknown. // Skip to avoid poisoning the ring with wrongly-spaced timestamps; // lastPktNs will be recorded below so the next packet uses correct dt. continue } for j := 0; j < n; j++ { allT = append(allT, wallSec+float64(j)*dtSec) allV = append(allV, vals[j]) } } if len(batch) > 0 { src.lastPktNs[sig.Name] = batch[len(batch)-1].WallTime.UnixNano() } if len(allT) > 0 { // Ring: always populate (fix 3), LTTB only if it actually reduces size. ringT, ringV := lttbDecimate(allT, allV, maxRingPoints) if rb := h.getRing(pfx + sig.Name); rb != nil { rb.write(ringT, ringV) } // Live push: send all points without LTTB (fix 2). pairs[sig.Name] = pairBuf{t: allT, v: allV} } } } // Compute total size and serialize totalSize := 1 + 1 + len(src.id) + 4 for key, p := range pairs { totalSize += 2 + len(key) + 4 totalSize += len(p.t)*8 + len(p.v)*8 } 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:]) }