wshub: add setCalibration/reloadConfig frames and config acks

Wire five new WebSocket frames into hub.go: setCalibration (client→hub),
calibration (hub→client broadcast), reloadConfig (client→hub), configSaved and
configReloaded (hub→client acks with ok/path/error). Extend hubCmd with cal
field, add buildCalibrationMsg and buildConfigAckMsg builders, send calibration
on client connect, and run Save/Reload on separate goroutines to avoid blocking
the Run() select loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-16 19:36:09 +02:00
co-authored by Claude Sonnet 4.6
parent dfd257cfd9
commit ffe7cb1cc5
2 changed files with 223 additions and 5 deletions
@@ -0,0 +1,136 @@
package wshub
import (
"encoding/json"
"errors"
"testing"
"time"
)
func TestBuildCalibrationMsg(t *testing.T) {
tab := newCalTable()
tab.Set(CalConfig{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: -1.25, Unit: "V"})
var got struct {
Type string `json:"type"`
Cal []CalConfig `json:"cal"`
}
raw := buildCalibrationMsg(tab)
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if got.Type != "calibration" {
t.Errorf("type = %q, want calibration", got.Type)
}
if len(got.Cal) != 1 || got.Cal[0] != (CalConfig{
Source: "wave", Signal: "Adc", Scale: 0.5, Offset: -1.25, Unit: "V"}) {
t.Errorf("cal = %+v", got.Cal)
}
}
func TestBuildCalibrationMsgEmptyTableIsEmptyArray(t *testing.T) {
// The SPA replaces its table wholesale on every calibration message, so an
// empty table must serialise as [] and not as null.
raw := buildCalibrationMsg(newCalTable())
var got struct {
Cal []CalConfig `json:"cal"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if got.Cal == nil {
t.Errorf("cal = null, want []; raw = %s", raw)
}
}
func TestBuildConfigAckMsg(t *testing.T) {
ok := buildConfigAckMsg("configSaved", "/tmp/x.json", nil)
var m map[string]any
if err := json.Unmarshal(ok, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "configSaved" || m["ok"] != true || m["path"] != "/tmp/x.json" {
t.Errorf("success ack = %s", ok)
}
if _, has := m["error"]; has {
t.Errorf("success ack carries an error field: %s", ok)
}
bad := buildConfigAckMsg("configReloaded", "", errors.New("boom"))
m = nil
if err := json.Unmarshal(bad, &m); err != nil {
t.Fatal(err)
}
if m["type"] != "configReloaded" || m["ok"] != false || m["error"] != "boom" {
t.Errorf("failure ack = %s", bad)
}
}
func TestHubSetCalibrationCommand(t *testing.T) {
h := NewHub()
go h.Run()
// Register a client before sending commands so broadcasts are observable.
sendCh := make(chan wsMessage, 64)
c := &wsClient{hub: h, send: sendCh}
h.register <- c
sleepMillis(20) // let Run() process the register and flush initial state msgs
drainSendCh(sendCh) // discard state-sync messages (sources, trigger, cal, ...)
h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
Source: "wave", Signal: "Adc", Scale: 4, Offset: 1, Unit: "V"}}
if raw := waitMsg(t, sendCh, "calibration"); raw == nil {
t.Fatal("no calibration broadcast after a valid setCalibration")
}
if got := h.cal.List(); len(got) != 1 || got[0].Scale != 4 {
t.Fatalf("table = %+v, want one entry with scale 4", got)
}
// An invalid entry is rejected and emits no broadcast at all.
h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
Source: "wave", Signal: "Adc", Scale: 0}}
if raw := waitMsg(t, sendCh, "calibration"); raw != nil {
t.Errorf("invalid setCalibration broadcast %s", raw)
}
if got := h.cal.List(); len(got) != 1 || got[0].Scale != 4 {
t.Errorf("table changed after a rejected setCalibration: %+v", got)
}
h.unregister <- c
}
// drainSendCh reads all currently buffered messages from the channel.
func drainSendCh(ch chan wsMessage) {
for {
select {
case <-ch:
default:
return
}
}
}
// waitMsg waits up to ~250 ms for a message of the given type on sendCh.
func waitMsg(t *testing.T, sendCh chan wsMessage, msgType string) []byte {
t.Helper()
deadline := time.After(250 * time.Millisecond)
for {
select {
case msg := <-sendCh:
var env struct {
Type string `json:"type"`
}
if json.Unmarshal(msg.data, &env) == nil && env.Type == msgType {
return msg.data
}
case <-deadline:
return nil
}
}
}
// waitBroadcast is kept for compatibility with the test helper interface;
// it delegates to waitMsg using a pre-registered client send channel.
// Callers that need it should register a client and use waitMsg directly.
func sleepMillis(n int) { time.Sleep(time.Duration(n) * time.Millisecond) }