Implemented new C++ logic

This commit is contained in:
Martino Ferrari
2026-06-12 15:25:13 +02:00
parent 617b5bd712
commit f25bd7f08e
220 changed files with 39185 additions and 850 deletions
+499
View File
@@ -0,0 +1,499 @@
// Command streamhub-e2e is an end-to-end test client for the C++ StreamHub.
//
// It connects to a running StreamHub WebSocket endpoint (with at least one
// connected UDPStreamer source, e.g. the stack launched by run_e2e_test.sh)
// and verifies the full protocol:
//
// 1. "sources" event with at least one connected source
// 2. "config" event per source with at least one signal
// 3. binary v1 data pushes: parseable, per-signal monotonic time,
// timestamps within a few seconds of wall clock (Unix time base)
// 4. "stats" event with a positive receive rate
// 5. WS zoom round-trip: reqId echoed, points returned in [t0,t1]
// 6. hub-side trigger: setTrigger+arm → triggerState(armed) → binary v2
// capture frame with the latched pre/post window
//
// Exit code 0 on success; 1 with a FAIL message otherwise.
package main
import (
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"log"
"math"
"os"
"time"
"github.com/gorilla/websocket"
)
var hub = flag.String("hub", "127.0.0.1:8090", "StreamHub host:port")
var timeout = flag.Duration("timeout", 30*time.Second, "overall test timeout")
var verbose = flag.Bool("v", false, "log every received event")
// ---------------------------------------------------------------------------
// Wire types (subset of the StreamHub JSON protocol)
// ---------------------------------------------------------------------------
type sourceInfo struct {
ID string `json:"id"`
Label string `json:"label"`
Addr string `json:"addr"`
State string `json:"state"`
}
type signalInfo struct {
Name string `json:"name"`
TypeCode uint32 `json:"typeCode"`
NumRows uint32 `json:"numRows"`
NumCols uint32 `json:"numCols"`
TimeMode int `json:"timeMode"`
Rate float64 `json:"samplingRate"`
}
type statInfo struct {
State string `json:"state"`
TotalReceived uint64 `json:"totalReceived"`
RateHz float64 `json:"rateHz"`
CycleHist []f64 `json:"cycleHist"`
}
type f64 = float64
type zoomPoints struct {
T []float64 `json:"t"`
V []float64 `json:"v"`
}
type event struct {
Type string `json:"type"`
Sources json.RawMessage `json:"sources"`
SourceID string `json:"sourceId"`
Signals json.RawMessage `json:"signals"`
ReqID uint32 `json:"reqId"`
State string `json:"state"`
TrigTime float64 `json:"trigTime"`
}
// Parsed binary v1 push frame: sourceId → signal → samples.
type pushFrame struct {
sourceID string
signals map[string]zoomPoints
}
// Parsed binary v2 capture frame.
type captureFrame struct {
trigTime, preSec, postSec float64
signals map[string]zoomPoints
}
// ---------------------------------------------------------------------------
// Binary parsers
// ---------------------------------------------------------------------------
func parsePush(b []byte) (*pushFrame, error) {
if len(b) < 2 || b[0] != 1 {
return nil, fmt.Errorf("not a v1 frame")
}
idLen := int(b[1])
off := 2
if len(b) < off+idLen+4 {
return nil, fmt.Errorf("truncated header")
}
f := &pushFrame{sourceID: string(b[off : off+idLen]),
signals: map[string]zoomPoints{}}
off += idLen
nSig := int(binary.LittleEndian.Uint32(b[off:]))
off += 4
for s := 0; s < nSig; s++ {
if len(b) < off+2 {
return nil, fmt.Errorf("truncated keyLen (sig %d)", s)
}
keyLen := int(binary.LittleEndian.Uint16(b[off:]))
off += 2
if len(b) < off+keyLen+4 {
return nil, fmt.Errorf("truncated key (sig %d)", s)
}
key := string(b[off : off+keyLen])
off += keyLen
n := int(binary.LittleEndian.Uint32(b[off:]))
off += 4
if len(b) < off+16*n {
return nil, fmt.Errorf("truncated data (sig %s n=%d)", key, n)
}
pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)}
for i := 0; i < n; i++ {
pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
}
off += 8 * n
for i := 0; i < n; i++ {
pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
}
off += 8 * n
f.signals[key] = pts
}
return f, nil
}
func parseCapture(b []byte) (*captureFrame, error) {
if len(b) < 1+24+4 || b[0] != 2 {
return nil, fmt.Errorf("not a v2 frame")
}
rdF64 := func(off int) float64 {
return math.Float64frombits(binary.LittleEndian.Uint64(b[off:]))
}
f := &captureFrame{
trigTime: rdF64(1), preSec: rdF64(9), postSec: rdF64(17),
signals: map[string]zoomPoints{},
}
off := 25
nSig := int(binary.LittleEndian.Uint32(b[off:]))
off += 4
for s := 0; s < nSig; s++ {
keyLen := int(binary.LittleEndian.Uint16(b[off:]))
off += 2
key := string(b[off : off+keyLen])
off += keyLen
n := int(binary.LittleEndian.Uint32(b[off:]))
off += 4
if len(b) < off+16*n {
return nil, fmt.Errorf("truncated capture (sig %s n=%d)", key, n)
}
pts := zoomPoints{T: make([]float64, n), V: make([]float64, n)}
for i := 0; i < n; i++ {
pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
}
off += 8 * n
for i := 0; i < n; i++ {
pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:]))
}
off += 8 * n
f.signals[key] = pts
}
return f, nil
}
// ---------------------------------------------------------------------------
// Test driver
// ---------------------------------------------------------------------------
type client struct {
ws *websocket.Conn
deadline time.Time
sources []sourceInfo
configs map[string][]signalInfo // sourceId → signals
pushes []*pushFrame
stats map[string]statInfo
zooms map[uint32]map[string]zoomPoints
trigSt []string // observed triggerState sequence
captures []*captureFrame
}
func (c *client) send(v interface{}) {
b, _ := json.Marshal(v)
if err := c.ws.WriteMessage(websocket.TextMessage, b); err != nil {
fail("ws write: %v", err)
}
}
// pump reads one WS message (with a short read deadline) and dispatches it.
func (c *client) pump() {
c.ws.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
mt, data, err := c.ws.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err) {
fail("ws closed: %v", err)
}
return // read timeout — fine
}
switch mt {
case websocket.BinaryMessage:
if len(data) == 0 {
return
}
switch data[0] {
case 1:
if f, err := parsePush(data); err == nil {
c.pushes = append(c.pushes, f)
} else {
fail("bad v1 frame: %v", err)
}
case 2:
if f, err := parseCapture(data); err == nil {
c.captures = append(c.captures, f)
} else {
fail("bad v2 frame: %v", err)
}
default:
fail("unknown binary frame version %d", data[0])
}
case websocket.TextMessage:
var ev event
if err := json.Unmarshal(data, &ev); err != nil {
fail("bad JSON event: %v (%.120s)", err, data)
}
if *verbose {
log.Printf("event %-12s %.160s", ev.Type, data)
}
switch ev.Type {
case "sources":
var srcs []sourceInfo
if err := json.Unmarshal(ev.Sources, &srcs); err == nil {
c.sources = srcs
}
case "config":
var sigs []signalInfo
if err := json.Unmarshal(ev.Signals, &sigs); err == nil {
c.configs[ev.SourceID] = sigs
} else {
log.Printf("config parse error: %v (%.200s)", err, data)
}
case "stats":
var st map[string]statInfo
if err := json.Unmarshal(ev.Sources, &st); err == nil {
c.stats = st
}
case "zoom":
var body struct {
Signals map[string]zoomPoints `json:"signals"`
}
if err := json.Unmarshal(data, &body); err == nil {
c.zooms[ev.ReqID] = body.Signals
}
case "triggerState":
c.trigSt = append(c.trigSt, ev.State)
}
}
}
// waitFor pumps messages until cond() or the step deadline expires.
func (c *client) waitFor(what string, d time.Duration, cond func() bool) {
end := time.Now().Add(d)
if end.After(c.deadline) {
end = c.deadline
}
for time.Now().Before(end) {
if cond() {
log.Printf("OK %s", what)
return
}
c.pump()
}
fail("timeout waiting for %s", what)
}
func fail(format string, args ...interface{}) {
fmt.Printf("FAIL "+format+"\n", args...)
os.Exit(1)
}
func main() {
flag.Parse()
url := "ws://" + *hub + "/ws"
log.Printf("connecting to %s", url)
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
fail("dial %s: %v", url, err)
}
defer ws.Close()
c := &client{
ws: ws,
deadline: time.Now().Add(*timeout),
configs: map[string][]signalInfo{},
zooms: map[uint32]map[string]zoomPoints{},
}
// ── 1. sources ────────────────────────────────────────────────────────
c.send(map[string]interface{}{"type": "getSources"})
c.waitFor("sources event with a connected source", 10*time.Second, func() bool {
for _, s := range c.sources {
if s.State == "connected" {
return true
}
}
return false
})
// ── 2. config per connected source ───────────────────────────────────
for _, s := range c.sources {
log.Printf("source %s (%s): state=%s", s.ID, s.Label, s.State)
c.send(map[string]interface{}{"type": "getConfig", "sourceId": s.ID})
}
c.waitFor("config with signals for every connected source", 10*time.Second, func() bool {
for _, s := range c.sources {
if s.State != "connected" {
continue
}
if len(c.configs[s.ID]) == 0 {
return false
}
}
return len(c.sources) > 0
})
// ── 3. binary pushes: wall-clock time base + monotonicity ────────────
c.waitFor("binary v1 data pushes (>=10 frames)", 10*time.Second, func() bool {
return len(c.pushes) >= 10
})
now := float64(time.Now().UnixNano()) / 1e9
seen := map[string][]float64{} // last times per src:sig
for _, f := range c.pushes {
for key, pts := range f.signals {
full := f.sourceID + ":" + key
for i, t := range pts.T {
if math.Abs(t-now) > 30.0 {
fail("timestamp not wall-clock: %s t=%.3f now=%.3f", full, t, now)
}
prev := seen[full]
if len(prev) > 0 && t < prev[len(prev)-1]-1e-9 {
fail("non-monotonic time on %s: %.9f after %.9f (i=%d)",
full, t, prev[len(prev)-1], i)
}
seen[full] = append(seen[full], t)
}
}
}
if len(seen) == 0 {
fail("pushes contained no signal data")
}
log.Printf("OK wall-clock & monotonic time on %d signal streams", len(seen))
// ── 4. stats ──────────────────────────────────────────────────────────
c.send(map[string]interface{}{"type": "getStats"})
c.waitFor("stats with positive rate", 10*time.Second, func() bool {
for _, st := range c.stats {
if st.State == "connected" && st.RateHz > 0 && st.TotalReceived > 0 {
return true
}
}
return false
})
// ── 5. zoom round-trip ───────────────────────────────────────────────
// Use the busiest streamed signal and the time range we actually saw.
var zoomKey string
var zMax int
for k, ts := range seen {
if len(ts) > zMax {
zMax, zoomKey = len(ts), k
}
}
ts := seen[zoomKey]
t1 := ts[len(ts)-1]
t0 := t1 - 0.5
const reqID = 4242
c.send(map[string]interface{}{
"type": "zoom", "reqId": reqID, "t0": t0, "t1": t1, "n": 200,
"signals": zoomKey,
})
c.waitFor(fmt.Sprintf("zoom reply (reqId=%d, %s)", reqID, zoomKey),
10*time.Second, func() bool {
sigs, ok := c.zooms[reqID]
if !ok {
return false
}
pts, ok := sigs[zoomKey]
if !ok || len(pts.T) < 2 {
fail("zoom reply missing %s (got %d signals)", zoomKey, len(sigs))
}
for _, t := range pts.T {
if t < t0-1e-6 || t > t1+1e-6 {
fail("zoom point outside range: t=%.9f not in [%.9f,%.9f]", t, t0, t1)
}
}
return true
})
// ── 6. trigger: arm → capture ────────────────────────────────────────
// Trigger on an *oscillating* signal at its mean observed value: a
// monotonic ramp (counter, time array) crosses its past mean only once,
// before arming, so a rising edge would never fire on it. Pick the
// busiest signal whose last push frame is non-monotonic (a sine).
lastVals := map[string][]float64{}
for _, f := range c.pushes {
for name, pts := range f.signals {
if len(pts.V) >= 4 {
lastVals[f.sourceID+":"+name] = pts.V
}
}
}
trigKey := ""
tMaxPts := 0
for k, vs := range lastVals {
monotonic := true
for i := 1; i < len(vs); i++ {
if vs[i] < vs[i-1] {
monotonic = false
break
}
}
if !monotonic && len(seen[k]) > tMaxPts {
tMaxPts, trigKey = len(seen[k]), k
}
}
if trigKey == "" {
fail("no oscillating signal found for trigger test")
}
vals := lastVals[trigKey]
mean := 0.0
for _, v := range vals {
mean += v
}
mean /= float64(len(vals))
log.Printf(" trigger signal %s, threshold %.6g", trigKey, mean)
c.send(map[string]interface{}{
"type": "setTrigger", "signal": trigKey, "edge": "rising",
"threshold": mean, "windowSec": 0.1, "prePercent": 20.0,
"mode": "single",
})
c.send(map[string]interface{}{"type": "arm"})
// The trigger can fire within microseconds of arming (5 MS/s sine), so
// the broadcast emitted by the arm command may already say "collecting"
// or even "triggered" — any of these proves the arm was accepted.
c.waitFor("triggerState: armed/collecting/triggered", 5*time.Second, func() bool {
for _, s := range c.trigSt {
if s == "armed" || s == "collecting" || s == "triggered" {
return true
}
}
return false
})
c.waitFor("binary v2 capture frame", 15*time.Second, func() bool {
return len(c.captures) > 0
})
cap0 := c.captures[0]
if math.Abs(cap0.preSec-0.02) > 1e-9 || math.Abs(cap0.postSec-0.08) > 1e-9 {
fail("capture window mismatch: pre=%.6f post=%.6f (want 0.02/0.08)",
cap0.preSec, cap0.postSec)
}
pts, ok := cap0.signals[trigKey]
if !ok || len(pts.T) == 0 {
fail("capture missing trigger signal %s (%d signals)", trigKey, len(cap0.signals))
}
for _, t := range pts.T {
if t < cap0.trigTime-cap0.preSec-1e-3 || t > cap0.trigTime+cap0.postSec+1e-3 {
fail("capture point outside window: t=%.9f trig=%.9f", t, cap0.trigTime)
}
}
log.Printf("OK capture: trig=%.6f pre=%.3fs post=%.3fs %d signals",
cap0.trigTime, cap0.preSec, cap0.postSec, len(cap0.signals))
c.waitFor("triggerState: triggered", 5*time.Second, func() bool {
for _, s := range c.trigSt {
if s == "triggered" {
return true
}
}
return false
})
c.send(map[string]interface{}{"type": "disarm"})
fmt.Println("PASS streamhub-e2e: all checks passed")
}