Files
MARTe-Integrated-Components/docs/superpowers/plans/2026-08-16-udpstreamer-signal-calibration-and-config.md

133 KiB
Raw Permalink Blame History

Per-Signal Calibration and Persistent Hub Config Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give every streamed signal an affine calibration y = raw * scale + offset with an optional unit override, stored in the hub's config file alongside the source list, editable from the web oscilloscope and shared between browsers.

Architecture: The calibration is metadata only — raw samples stay raw in the ring buffers, in recorded history and in the trigger comparator. Both hubs (the Go wshub and the C++ StreamHub) own a calibration table keyed by (source label, base signal name), persist it into the existing -sources-file / SourcesFile as extra elements of the same flat JSON array, and exchange it over five new WebSocket frames. The browser SPA applies the calibration at the single point where raw values enter the display transform, so the hover readout, cursors, rulers and Y-axis ticks all follow for free.

Tech Stack: Go 1.x (marte2/common module, gorilla/websocket), C++ (MARTe2 style — StreamString, FastPollingMutexSem, fixed arrays, no STL), vanilla ES2020 browser JS + uPlot, node --test for JS unit tests, go test for Go.

Global Constraints

Every task's requirements implicitly include this section.

  • Calibration data model. Key is (source, signal) where source is the source label (never the runtime id s1/s2) and signal is the base signal name with any [i] array-element suffix stripped. Fields and validation:

    • source: string, must be non-empty after trimming.
    • signal: string, must be non-empty after trimming, no [i] suffix.
    • scale: float64, default 1, must be finite and non-zero.
    • offset: float64, default 0, must be finite.
    • unit: string, default "", trimmed, truncated to 16 characters. Empty means "use the streamer's unit".
  • Identity entries are deleted, not stored. An entry with scale == 1 && offset == 0 && unit == "" carries no information; storing it removes any existing entry for that key so it is never written to the config file.

  • The config file is a flat JSON array of flat objects. No nested objects, ever. StreamHub::LoadSourcesFile is a hand-rolled scanner that takes each { up to the next } as one object; a nested object would truncate the parse. A block containing addr is a source; a block containing signal is a calibration; anything else is skipped with a warning.

  • Five new WebSocket frames, implemented identically in both hubs:

    Direction Frame
    hub → client {"type":"calibration","cal":[{"source","signal","scale","offset","unit"}, …]}
    client → hub {"type":"setCalibration","source","signal","scale","offset","unit"}
    hub → client {"type":"configSaved","ok":bool,"path":string,"error":string}
    client → hub {"type":"reloadConfig"}
    hub → client {"type":"configReloaded","ok":bool,"path":string,"error":string}

    calibration is broadcast when a client connects and after every accepted setCalibration and every successful reloadConfig. An invalid setCalibration is rejected and emits no broadcast, so the offending client reverts to the last broadcast value.

  • Reload semantics. reloadConfig re-reads the config file, then: replaces the calibration table wholesale, adds any source in the file that is not already running, and never removes, restarts or reconnects a live source.

  • No STL in Source/Components/. Source/Applications/StreamHub/ follows MARTe2 style: MARTe::StreamString, FastPollingMutexSem, fixed-size arrays, new[]/delete[]. kMaxCalibration = 256.

  • Build environment. source env.sh before any C++ build or run. It sets MARTe2_DIR, MARTe2_Components_DIR, TARGET=x86-linux and LD_LIBRARY_PATH.

  • Existing config files must keep loading unchanged in both hubs. A file containing only source blocks parses exactly as before.


File Structure

Go hub — Common/Client/go/wshub/

  • calibration.go (new) — the CalConfig type, its validation, the concurrency-safe calTable store, and the flat config-file codec (parseConfigFile / encodeConfigFile). Self-contained and free of hub/network dependencies so it is trivially testable.
  • calibration_test.go (new) — table tests for validation, the heterogeneous-array parse, and the encode→parse round-trip.
  • sources.go (modify)SourceManager.Save / Load switch to the new codec; new Reload and Path methods.
  • sources_test.go (new) — save→parse round-trip asserting sources and calibration both survive.
  • hub.go (modify)cal *calTable on Hub, cal CalConfig on hubCmd, the two new readPump cases, the three new/changed commandCh cases, the two message builders, and the calibration send in the register case.

C++ hub — Source/Applications/StreamHub/

  • StreamHub.h (modify)kMaxCalibration, the CalibrationEntry struct, five new method declarations, three new members.
  • StreamHub.cpp (modify) — the whitespace-tolerant JSON helpers (a pre-existing bug: HandleSaveSources writes "label": "x" with a space, which the old JsonGetString could not read back), the calibration store, the two new command handlers, the two new broadcasters, and the load/save discriminator branches.

Parity check — Test/E2E/suite/client/

  • configcheck/main.go (new) — a standalone WebSocket client, in the existing client module, that drives the five new frames against either hub and exits non-zero on mismatch. Deliberately independent of the scenario framework: it needs no live UDP source.

Browser SPA — Client/udpstreamer/static/ and Client/udpstreamer/test/

  • calibration.js (new) — pure calibration helpers with a module.exports guard so the same file is a browser <script> and a node module. No DOM, no globals from app.js.
  • ../test/calibration.test.js (new)node --test unit tests for those helpers.
  • index.html (modify) — load calibration.js before app.js; add the Cal row to #vscale-menu.
  • style.css (modify) — styles for the Cal row and the config status line.
  • app.js (modify) — the calibration table and its localStorage mirror, the WebSocket wiring, the display-path integration, the Cal row handlers, the CSV/trigger/unit sites, and the "Sources & Config" sidebar section.

Documentation

  • Docs/StreamHub-API.md, Docs/WebUI.md, ARCHITECTURE.md (modify).

Task 1: Go calibration store and flat config-file codec

Pure data layer: the CalConfig type, its validation rules, a mutex-guarded table, and the codec that reads/writes the heterogeneous flat JSON array. No hub or network dependencies, so it can be tested exhaustively in isolation.

Files:

  • Create: Common/Client/go/wshub/calibration.go
  • Test: Common/Client/go/wshub/calibration_test.go

Interfaces:

  • Consumes: SourceConfig from Common/Client/go/wshub/sources.go (fields Label, Addr string; MulticastGroup string; DataPort int).

  • Produces, for Tasks 2 and 3:

    • type CalConfig struct { Source, Signal string; Scale, Offset float64; Unit string }
    • func (c *CalConfig) Normalise() bool
    • func (c CalConfig) IsIdentity() bool
    • type calTable with newCalTable() *calTable, (*calTable).Set(CalConfig) bool, (*calTable).Replace([]CalConfig), (*calTable).List() []CalConfig
    • func parseConfigFile(data []byte) ([]SourceConfig, []CalConfig, error)
    • func encodeConfigFile(srcs []SourceConfig, cals []CalConfig) ([]byte, error)
  • Step 1: Write the failing test

Create Common/Client/go/wshub/calibration_test.go:

package wshub

import (
	"math"
	"testing"
)

func TestCalConfigNormalise(t *testing.T) {
	cases := []struct {
		name     string
		in       CalConfig
		want     bool
		wantUnit string
	}{
		{"plain", CalConfig{Source: "wave", Signal: "Adc", Scale: 2, Offset: -1, Unit: "V"}, true, "V"},
		{"trims", CalConfig{Source: " wave ", Signal: " Adc ", Scale: 1, Unit: "  V  "}, true, "V"},
		{"emptySource", CalConfig{Signal: "Adc", Scale: 1}, false, ""},
		{"emptySignal", CalConfig{Source: "wave", Scale: 1}, false, ""},
		{"zeroScale", CalConfig{Source: "wave", Signal: "Adc", Scale: 0}, false, ""},
		{"nanScale", CalConfig{Source: "wave", Signal: "Adc", Scale: math.NaN()}, false, ""},
		{"infScale", CalConfig{Source: "wave", Signal: "Adc", Scale: math.Inf(1)}, false, ""},
		{"nanOffset", CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Offset: math.NaN()}, false, ""},
		{"infOffset", CalConfig{Source: "wave", Signal: "Adc", Scale: 1, Offset: math.Inf(-1)}, false, ""},
		{"negScaleOK", CalConfig{Source: "wave", Signal: "Adc", Scale: -1}, true, ""},
		{"longUnit", CalConfig{Source: "wave", Signal: "Adc", Scale: 1,
			Unit: "0123456789abcdefGHIJ"}, true, "0123456789abcdef"},
	}
	for _, c := range cases {
		got := c.in
		if ok := got.Normalise(); ok != c.want {
			t.Errorf("%s: Normalise() = %v, want %v", c.name, ok, c.want)
			continue
		}
		if c.want && got.Unit != c.wantUnit {
			t.Errorf("%s: Unit = %q, want %q", c.name, got.Unit, c.wantUnit)
		}
	}
	if len("0123456789abcdef") != maxUnitLen {
		t.Fatalf("test assumes maxUnitLen == 16, got %d", maxUnitLen)
	}
}

func TestCalTableSetListAndIdentityRemoval(t *testing.T) {
	tab := newCalTable()
	if !tab.Set(CalConfig{Source: "b", Signal: "Y", Scale: 3, Offset: 1, Unit: "A"}) {
		t.Fatal("Set(b/Y) rejected")
	}
	if !tab.Set(CalConfig{Source: "a", Signal: "X", Scale: 2}) {
		t.Fatal("Set(a/X) rejected")
	}
	if tab.Set(CalConfig{Source: "a", Signal: "Z", Scale: 0}) {
		t.Error("Set with scale=0 accepted, want rejected")
	}
	got := tab.List()
	if len(got) != 2 {
		t.Fatalf("List() = %d entries, want 2", len(got))
	}
	// Sorted by source then signal.
	if got[0].Source != "a" || got[1].Source != "b" {
		t.Errorf("List() order = %q,%q, want a,b", got[0].Source, got[1].Source)
	}
	// An identity entry removes the stored one.
	if !tab.Set(CalConfig{Source: "a", Signal: "X", Scale: 1, Offset: 0, Unit: ""}) {
		t.Fatal("identity Set rejected")
	}
	if got := tab.List(); len(got) != 1 || got[0].Source != "b" {
		t.Errorf("after identity Set, List() = %+v, want only b/Y", got)
	}
}

func TestCalTableReplace(t *testing.T) {
	tab := newCalTable()
	tab.Set(CalConfig{Source: "old", Signal: "X", Scale: 5})
	tab.Replace([]CalConfig{
		{Source: "new", Signal: "Y", Scale: 2},
		{Source: "bad", Signal: "Z", Scale: 0},                     // invalid → dropped
		{Source: "id", Signal: "W", Scale: 1, Offset: 0, Unit: ""}, // identity → dropped
	})
	got := tab.List()
	if len(got) != 1 || got[0].Source != "new" {
		t.Fatalf("List() = %+v, want only new/Y", got)
	}
}

func TestParseConfigFileCurrentFormat(t *testing.T) {
	// A file written by the current binaries — sources only, spaces after colons.
	data := []byte(`[
  {
    "label": "wave",
    "addr": "127.0.0.1:44500"
  },
  {
    "label": "mc",
    "addr": "127.0.0.1:44501",
    "multicastGroup": "239.0.0.1",
    "dataPort": 44502
  }
]`)
	srcs, cals, err := parseConfigFile(data)
	if err != nil {
		t.Fatalf("parseConfigFile: %v", err)
	}
	if len(srcs) != 2 || len(cals) != 0 {
		t.Fatalf("got %d sources / %d cals, want 2 / 0", len(srcs), len(cals))
	}
	if srcs[1].MulticastGroup != "239.0.0.1" || srcs[1].DataPort != 44502 {
		t.Errorf("multicast source = %+v", srcs[1])
	}
}

func TestParseConfigFileMixed(t *testing.T) {
	data := []byte(`[
  {"label":"wave","addr":"127.0.0.1:44500"},
  {"source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"},
  {"source":"wave","signal":"Bare"},
  {"source":"wave","signal":"Bad","scale":0},
  {"nonsense":true}
]`)
	srcs, cals, err := parseConfigFile(data)
	if err != nil {
		t.Fatalf("parseConfigFile: %v", err)
	}
	if len(srcs) != 1 {
		t.Fatalf("got %d sources, want 1", len(srcs))
	}
	if len(cals) != 2 {
		t.Fatalf("got %d cals, want 2 (Adc and Bare; Bad is invalid)", len(cals))
	}
	if cals[0].Scale != 0.00030518 || cals[0].Offset != -1.25 || cals[0].Unit != "V" {
		t.Errorf("Adc = %+v", cals[0])
	}
	// Absent scale/offset default to the identity values, not to zero.
	if cals[1].Signal != "Bare" || cals[1].Scale != 1 || cals[1].Offset != 0 {
		t.Errorf("Bare = %+v, want scale 1 / offset 0", cals[1])
	}
}

func TestParseConfigFileMalformed(t *testing.T) {
	if _, _, err := parseConfigFile([]byte("not json")); err == nil {
		t.Error("parseConfigFile(garbage) = nil error, want error")
	}
}

func TestEncodeConfigFileRoundTrip(t *testing.T) {
	srcs := []SourceConfig{
		{Label: "wave", Addr: "127.0.0.1:44500"},
		{Label: "mc", Addr: "127.0.0.1:44501", MulticastGroup: "239.0.0.1", DataPort: 44502},
	}
	cals := []CalConfig{
		{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: 0, Unit: "V"},
	}
	data, err := encodeConfigFile(srcs, cals)
	if err != nil {
		t.Fatalf("encodeConfigFile: %v", err)
	}
	gotSrcs, gotCals, err := parseConfigFile(data)
	if err != nil {
		t.Fatalf("parseConfigFile(encoded): %v\n%s", err, data)
	}
	if len(gotSrcs) != 2 || len(gotCals) != 1 {
		t.Fatalf("round-trip gave %d sources / %d cals, want 2 / 1\n%s",
			len(gotSrcs), len(gotCals), data)
	}
	if gotSrcs[1] != srcs[1] {
		t.Errorf("source round-trip: got %+v, want %+v", gotSrcs[1], srcs[1])
	}
	if gotCals[0] != cals[0] {
		t.Errorf("cal round-trip: got %+v, want %+v", gotCals[0], cals[0])
	}
	// offset 0 must survive as an explicit field, not be dropped by omitempty.
	if !bytesContains(data, []byte(`"offset": 0`)) {
		t.Errorf("encoded file lost the zero offset:\n%s", data)
	}
}

func bytesContains(hay, needle []byte) bool {
	for i := 0; i+len(needle) <= len(hay); i++ {
		if string(hay[i:i+len(needle)]) == string(needle) {
			return true
		}
	}
	return false
}
  • Step 2: Run the test to verify it fails

Run: cd Common/Client/go && go test ./wshub/ -run 'Cal|ConfigFile' -v Expected: FAIL — compile errors, undefined: CalConfig, undefined: newCalTable, undefined: parseConfigFile, undefined: encodeConfigFile, undefined: maxUnitLen.

  • Step 3: Write the implementation

Create Common/Client/go/wshub/calibration.go:

package wshub

import (
	"encoding/json"
	"log"
	"math"
	"sort"
	"strings"
	"sync"
)

// maxUnitLen bounds the calibration unit override.  Mirrored by kMaxUnitLen in
// the C++ StreamHub and MAX_UNIT_LEN in the SPA's calibration.js.
const maxUnitLen = 16

// CalConfig is one per-signal affine calibration: y = raw*Scale + Offset.
//
// The key is (Source label, base Signal name).  It is deliberately the source
// *label* and not the runtime id ("s1", "s2"): ids are assigned in add-order at
// startup, so a calibration keyed by id would rebind to a different source
// whenever the source list order changed.
type CalConfig struct {
	Source string  `json:"source"`
	Signal string  `json:"signal"`
	Scale  float64 `json:"scale"`
	Offset float64 `json:"offset"`
	Unit   string  `json:"unit,omitempty"`
}

// calKey builds the calTable map key.  NUL cannot occur in either component,
// so the concatenation is unambiguous.
func calKey(source, signal string) string { return source + "\x00" + signal }

// Normalise trims and validates the entry in place, reporting whether it is
// usable.  A zero or non-finite Scale is rejected because it makes the
// calibration non-invertible, which the trigger threshold path depends on.
func (c *CalConfig) Normalise() bool {
	c.Source = strings.TrimSpace(c.Source)
	c.Signal = strings.TrimSpace(c.Signal)
	if c.Source == "" || c.Signal == "" {
		return false
	}
	if math.IsNaN(c.Scale) || math.IsInf(c.Scale, 0) || c.Scale == 0 {
		return false
	}
	if math.IsNaN(c.Offset) || math.IsInf(c.Offset, 0) {
		return false
	}
	c.Unit = strings.TrimSpace(c.Unit)
	if len(c.Unit) > maxUnitLen {
		c.Unit = c.Unit[:maxUnitLen]
	}
	return true
}

// IsIdentity reports whether the entry carries no information and can be
// dropped rather than stored and persisted.
func (c CalConfig) IsIdentity() bool {
	return c.Scale == 1 && c.Offset == 0 && c.Unit == ""
}

// calTable is the hub's calibration store, safe for concurrent use.
type calTable struct {
	mu      sync.RWMutex
	entries map[string]CalConfig
}

func newCalTable() *calTable {
	return &calTable{entries: make(map[string]CalConfig)}
}

// Set validates and stores one entry, reporting whether it was accepted.
// Storing an identity entry removes any existing one for that key.
func (t *calTable) Set(c CalConfig) bool {
	if !c.Normalise() {
		return false
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	if c.IsIdentity() {
		delete(t.entries, calKey(c.Source, c.Signal))
	} else {
		t.entries[calKey(c.Source, c.Signal)] = c
	}
	return true
}

// Replace swaps the whole table for the given entries, silently dropping the
// invalid and identity ones.  Used by config load and reload.
func (t *calTable) Replace(list []CalConfig) {
	next := make(map[string]CalConfig, len(list))
	for _, c := range list {
		if !c.Normalise() || c.IsIdentity() {
			continue
		}
		next[calKey(c.Source, c.Signal)] = c
	}
	t.mu.Lock()
	t.entries = next
	t.mu.Unlock()
}

// List returns the entries sorted by source then signal, so both the wire
// message and the config file have a stable order.
func (t *calTable) List() []CalConfig {
	t.mu.RLock()
	out := make([]CalConfig, 0, len(t.entries))
	for _, c := range t.entries {
		out = append(out, c)
	}
	t.mu.RUnlock()
	sort.Slice(out, func(i, j int) bool {
		if out[i].Source != out[j].Source {
			return out[i].Source < out[j].Source
		}
		return out[i].Signal < out[j].Signal
	})
	return out
}

// ─── Config file codec ────────────────────────────────────────────────────────

// configFileEntry is the union of a source block and a calibration block.
//
// The file is one FLAT array of FLAT objects — never a nested one.  The C++
// StreamHub's LoadSourcesFile is a hand-rolled scanner that takes each "{" up
// to the next "}" as one object, so a nested block would truncate the parse.
// Scale and Offset are pointers so that an absent field can be told apart from
// an explicit zero and defaulted to the identity values.
type configFileEntry struct {
	// Source fields.
	Label          string `json:"label,omitempty"`
	Addr           string `json:"addr,omitempty"`
	MulticastGroup string `json:"multicastGroup,omitempty"`
	DataPort       int    `json:"dataPort,omitempty"`
	// Calibration fields.
	Source string   `json:"source,omitempty"`
	Signal string   `json:"signal,omitempty"`
	Scale  *float64 `json:"scale,omitempty"`
	Offset *float64 `json:"offset,omitempty"`
	Unit   string   `json:"unit,omitempty"`
}

// parseConfigFile splits the flat array into sources and calibration entries.
// A block with "addr" is a source, one with "signal" is a calibration; anything
// else is skipped with a warning.
func parseConfigFile(data []byte) ([]SourceConfig, []CalConfig, error) {
	var raw []configFileEntry
	if err := json.Unmarshal(data, &raw); err != nil {
		return nil, nil, err
	}
	srcs := make([]SourceConfig, 0, len(raw))
	cals := make([]CalConfig, 0, len(raw))
	for _, e := range raw {
		switch {
		case e.Addr != "":
			srcs = append(srcs, SourceConfig{
				Label:          e.Label,
				Addr:           e.Addr,
				MulticastGroup: e.MulticastGroup,
				DataPort:       e.DataPort,
			})
		case e.Signal != "":
			c := CalConfig{Source: e.Source, Signal: e.Signal, Scale: 1, Offset: 0, Unit: e.Unit}
			if e.Scale != nil {
				c.Scale = *e.Scale
			}
			if e.Offset != nil {
				c.Offset = *e.Offset
			}
			if !c.Normalise() {
				log.Printf("wshub: skipping invalid calibration entry %q/%q", e.Source, e.Signal)
				continue
			}
			cals = append(cals, c)
		default:
			log.Printf("wshub: skipping unrecognised config block")
		}
	}
	return srcs, cals, nil
}

// encodeConfigFile renders the sources followed by the calibration entries as
// one flat array, in the indented shape the existing files already use.
func encodeConfigFile(srcs []SourceConfig, cals []CalConfig) ([]byte, error) {
	out := make([]configFileEntry, 0, len(srcs)+len(cals))
	for _, s := range srcs {
		out = append(out, configFileEntry{
			Label:          s.Label,
			Addr:           s.Addr,
			MulticastGroup: s.MulticastGroup,
			DataPort:       s.DataPort,
		})
	}
	for _, c := range cals {
		scale, offset := c.Scale, c.Offset
		out = append(out, configFileEntry{
			Source: c.Source,
			Signal: c.Signal,
			Scale:  &scale,
			Offset: &offset,
			Unit:   c.Unit,
		})
	}
	return json.MarshalIndent(out, "", "  ")
}
  • Step 4: Run the tests to verify they pass

Run: cd Common/Client/go && go vet ./wshub/ && go test ./wshub/ -run 'Cal|ConfigFile' -v Expected: PASS for TestCalConfigNormalise, TestCalTableSetListAndIdentityRemoval, TestCalTableReplace, TestParseConfigFileCurrentFormat, TestParseConfigFileMixed, TestParseConfigFileMalformed, TestEncodeConfigFileRoundTrip.

  • Step 5: Run the whole package to check nothing regressed

Run: cd Common/Client/go && go test ./... Expected: ok marte2/common/wshub and no failures elsewhere.

  • Step 6: Commit
git add Common/Client/go/wshub/calibration.go Common/Client/go/wshub/calibration_test.go
git commit -m "wshub: add per-signal calibration store and flat config-file codec"

Task 2: Go SourceManager reads and writes the combined config

Save now writes sources and calibration into one file, Load seeds the calibration table from it, and a new Reload re-reads it without disturbing any live source.

Files:

  • Modify: Common/Client/go/wshub/sources.go:99-137 (Save, Load)
  • Test: Common/Client/go/wshub/sources_test.go (create)

Interfaces:

  • Consumes from Task 1: parseConfigFile, encodeConfigFile, CalConfig, (*calTable).Replace, (*calTable).List.

  • Consumes from Task 3 (declared there, used here): the Hub.cal *calTable field. Task 3 adds the field; this task's code reads sm.hub.cal, so do Step 3 of Task 3 (the Hub struct field and NewHub initialiser) first if the package does not compile.

  • Produces, for Task 3: func (sm *SourceManager) Reload() error and func (sm *SourceManager) Path() string.

  • Step 1: Add the cal field the SourceManager depends on

In Common/Client/go/wshub/hub.go, add the field to the Hub struct, right after the sm *SourceManager line (hub.go:240):

	sm *SourceManager // set after construction; used for WS-initiated source changes

	// cal holds the per-signal calibration table.  It is metadata only: the
	// rings, the history and the trigger comparator all keep raw samples.
	cal *calTable

and in NewHub() (hub.go:261-274) add the initialiser after trigger::

		trigger:     newTriggerEngine(),
		cal:         newCalTable(),
  • Step 2: Write the failing test

Create Common/Client/go/wshub/sources_test.go:

package wshub

import (
	"os"
	"path/filepath"
	"testing"
)

// newTestManager builds a hub + manager pair with no goroutines running.
func newTestManager(t *testing.T) (*Hub, *SourceManager, string) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "sources.json")
	h := NewHub()
	sm := NewSourceManager(h, path)
	h.SetSourceManager(sm)
	return h, sm, path
}

func TestSaveWritesSourcesAndCalibration(t *testing.T) {
	h, sm, path := newTestManager(t)

	// Register two sources without starting any UDP client.
	sm.mu.Lock()
	sm.sources["s1"] = &managedSource{id: "s1", label: "wave", addr: "127.0.0.1:44500"}
	sm.sources["s2"] = &managedSource{
		id: "s2", label: "mc", addr: "127.0.0.1:44501",
		multicastGroup: "239.0.0.1", dataPort: 44502,
	}
	sm.mu.Unlock()

	if !h.cal.Set(CalConfig{Source: "wave", Signal: "Adc", Scale: 0.5, Offset: -1.25, Unit: "V"}) {
		t.Fatal("calibration rejected")
	}
	if err := sm.Save(); err != nil {
		t.Fatalf("Save: %v", err)
	}

	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("ReadFile: %v", err)
	}
	srcs, cals, err := parseConfigFile(data)
	if err != nil {
		t.Fatalf("parseConfigFile: %v\n%s", err, data)
	}
	if len(srcs) != 2 {
		t.Fatalf("got %d sources, want 2\n%s", len(srcs), data)
	}
	// Save sorts by label so the file is byte-stable across runs.
	if srcs[0].Label != "mc" || srcs[1].Label != "wave" {
		t.Errorf("source order = %q,%q, want mc,wave", srcs[0].Label, srcs[1].Label)
	}
	if len(cals) != 1 || cals[0].Signal != "Adc" || cals[0].Scale != 0.5 {
		t.Fatalf("calibration round-trip failed: %+v\n%s", cals, data)
	}
}

func TestSaveWithoutFilePathFails(t *testing.T) {
	h := NewHub()
	sm := NewSourceManager(h, "")
	h.SetSourceManager(sm)
	if err := sm.Save(); err == nil {
		t.Error("Save() with no path = nil error, want error")
	}
}

func TestLoadSeedsCalibrationTable(t *testing.T) {
	h, sm, path := newTestManager(t)
	// No "addr" blocks: Load must not start any UDP client during the test.
	if err := os.WriteFile(path, []byte(`[
  {"source":"wave","signal":"Adc","scale":0.25,"offset":2,"unit":"mV"},
  {"source":"wave","signal":"Dac","scale":2}
]`), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := sm.Load(path); err != nil {
		t.Fatalf("Load: %v", err)
	}
	got := h.cal.List()
	if len(got) != 2 {
		t.Fatalf("List() = %d entries, want 2", len(got))
	}
	if got[0].Signal != "Adc" || got[0].Unit != "mV" || got[0].Offset != 2 {
		t.Errorf("Adc = %+v", got[0])
	}
	if sm.Path() != path {
		t.Errorf("Path() = %q, want %q", sm.Path(), path)
	}
}

func TestReloadReplacesCalibrationAndKeepsLiveSources(t *testing.T) {
	h, sm, path := newTestManager(t)

	// A live source that the file does not mention must survive the reload.
	sm.mu.Lock()
	sm.sources["s1"] = &managedSource{id: "s1", label: "live", addr: "127.0.0.1:44999"}
	sm.mu.Unlock()

	// A stale calibration that the file does not mention must be dropped.
	h.cal.Set(CalConfig{Source: "stale", Signal: "Old", Scale: 9})

	if err := os.WriteFile(path, []byte(`[
  {"source":"wave","signal":"Adc","scale":0.5}
]`), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := sm.Reload(); err != nil {
		t.Fatalf("Reload: %v", err)
	}

	got := h.cal.List()
	if len(got) != 1 || got[0].Source != "wave" {
		t.Fatalf("after Reload, calibration = %+v, want only wave/Adc", got)
	}
	sm.mu.RLock()
	_, alive := sm.sources["s1"]
	n := len(sm.sources)
	sm.mu.RUnlock()
	if !alive || n != 1 {
		t.Errorf("live source count = %d (s1 alive=%v), want 1 / true", n, alive)
	}
}

func TestReloadWithoutFilePathFails(t *testing.T) {
	h := NewHub()
	sm := NewSourceManager(h, "")
	h.SetSourceManager(sm)
	if err := sm.Reload(); err == nil {
		t.Error("Reload() with no path = nil error, want error")
	}
}
  • Step 3: Run the test to verify it fails

Run: cd Common/Client/go && go test ./wshub/ -run 'Save|Load|Reload' -v Expected: FAIL — sm.Reload undefined, sm.Path undefined, and TestSaveWritesSourcesAndCalibration failing on the source order and the missing calibration block.

  • Step 4: Rewrite Save and Load, and add Reload and Path

In Common/Client/go/wshub/sources.go, add "sort" to the import block, then replace the whole Save + Load region (lines 98-137) with:

// Path returns the configured config-file path ("" when none).
func (sm *SourceManager) Path() string {
	sm.mu.RLock()
	defer sm.mu.RUnlock()
	return sm.filePath
}

// snapshotSources returns the current sources sorted by label, so the written
// file is byte-stable across runs (the map iteration order is not).
func (sm *SourceManager) snapshotSources() []SourceConfig {
	sm.mu.RLock()
	cfgs := make([]SourceConfig, 0, len(sm.sources))
	for _, ms := range sm.sources {
		cfgs = append(cfgs, SourceConfig{
			Label:          ms.label,
			Addr:           ms.addr,
			MulticastGroup: ms.multicastGroup,
			DataPort:       ms.dataPort,
		})
	}
	sm.mu.RUnlock()
	sort.Slice(cfgs, func(i, j int) bool {
		if cfgs[i].Label != cfgs[j].Label {
			return cfgs[i].Label < cfgs[j].Label
		}
		return cfgs[i].Addr < cfgs[j].Addr
	})
	return cfgs
}

// Save writes the current source list and calibration table to filePath as one
// flat JSON array.
func (sm *SourceManager) Save() error {
	path := sm.Path()
	if path == "" {
		return fmt.Errorf("no sources-file configured")
	}
	data, err := encodeConfigFile(sm.snapshotSources(), sm.hub.cal.List())
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}

// Load reads the config file at path, replaces the calibration table with its
// contents and starts every source it lists.
func (sm *SourceManager) Load(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	srcs, cals, err := parseConfigFile(data)
	if err != nil {
		return err
	}
	sm.mu.Lock()
	sm.filePath = path
	sm.mu.Unlock()

	sm.hub.cal.Replace(cals)
	for _, cfg := range srcs {
		sm.Add(cfg.Label, cfg.Addr, cfg.MulticastGroup, cfg.DataPort)
	}
	return nil
}

// Reload re-reads the config file.  The calibration table is replaced wholesale
// and sources listed in the file that are not already running are started; no
// live source is ever stopped, restarted or reconnected, because a reload must
// not interrupt streaming.  The asymmetry is deliberate: calibration is cheap
// to reapply, a source is a live UDP session.
func (sm *SourceManager) Reload() error {
	path := sm.Path()
	if path == "" {
		return fmt.Errorf("no sources-file configured")
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	srcs, cals, err := parseConfigFile(data)
	if err != nil {
		return err
	}
	sm.hub.cal.Replace(cals)

	sm.mu.RLock()
	live := make(map[string]bool, len(sm.sources))
	for _, ms := range sm.sources {
		live[ms.label+"\x00"+ms.addr] = true
	}
	sm.mu.RUnlock()

	for _, cfg := range srcs {
		label := cfg.Label
		if label == "" {
			label = cfg.Addr // Add() applies the same default
		}
		if live[label+"\x00"+cfg.Addr] {
			continue
		}
		sm.Add(cfg.Label, cfg.Addr, cfg.MulticastGroup, cfg.DataPort)
	}
	return nil
}

Note: encoding/json is no longer referenced by sources.go. If go vet reports it as unused, remove "encoding/json" from the import block.

  • Step 5: Run the tests to verify they pass

Run: cd Common/Client/go && go vet ./wshub/ && go test ./wshub/ -v Expected: PASS for all five new sources_test.go tests plus every pre-existing test in the package (TestZoomPoints, TestZoomSliceReturnsFullResolution, TestZoomSliceUnknownSignal, and the Task 1 tests).

  • Step 6: Confirm the SPA server still builds

Run: cd Client/udpstreamer && go build ./... Expected: no output.

  • Step 7: Commit
git add Common/Client/go/wshub/sources.go Common/Client/go/wshub/sources_test.go Common/Client/go/wshub/hub.go
git commit -m "wshub: persist calibration alongside sources; add config reload"

Task 3: Go hub WebSocket frames

Wire the five frames into hub.go. The two disk-touching commands run on their own goroutines, not on the Run() goroutine: Reload calls sm.Add, which sends on commandCh, and that send is a non-blocking select/default — from inside Run() it would be silently dropped. The existing wsAddSource and wsRemoveSource cases already spawn goroutines for exactly this reason.

Files:

  • Modify: Common/Client/go/wshub/hub.goreadPump switch (76-127), hubCmd (212-224), register case (494-532), commandCh switch (548-653), and a new builder next to buildSourcesMsg (457-471)
  • Test: Common/Client/go/wshub/hub_calibration_test.go (create)

Interfaces:

  • Consumes from Tasks 1-2: CalConfig, calTable, (*SourceManager).Save, (*SourceManager).Reload, (*SourceManager).Path.

  • Produces, for Task 5 and the SPA tasks: the wire frames listed in Global Constraints, plus func buildCalibrationMsg(t *calTable) []byte and func buildConfigAckMsg(msgType, path string, err error) []byte.

  • Step 1: Write the failing test

Create Common/Client/go/wshub/hub_calibration_test.go:

package wshub

import (
	"encoding/json"
	"errors"
	"testing"
)

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()

	h.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
		Source: "wave", Signal: "Adc", Scale: 4, Offset: 1, Unit: "V"}}
	if raw := waitBroadcast(t, h, "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 := waitBroadcast(t, h, "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)
	}
}

// waitBroadcast drains broadcastCh for up to ~250 ms looking for a message of
// the given type; it returns nil if none arrives.
func waitBroadcast(t *testing.T, h *Hub, msgType string) []byte {
	t.Helper()
	for i := 0; i < 25; i++ {
		select {
		case raw := <-h.broadcastCh:
			var env struct {
				Type string `json:"type"`
			}
			if json.Unmarshal(raw, &env) == nil && env.Type == msgType {
				return raw
			}
		default:
			sleepMillis(10)
		}
	}
	return nil
}

Add the tiny sleep helper at the end of the same file:

func sleepMillis(n int) { time.Sleep(time.Duration(n) * time.Millisecond) }

and add "time" to that file's import block.

  • Step 2: Run the test to verify it fails

Run: cd Common/Client/go && go test ./wshub/ -run 'Calibration|ConfigAck' -v Expected: FAIL — undefined: buildCalibrationMsg, undefined: buildConfigAckMsg, and cmd.cal undefined (type hubCmd has no field or method cal).

  • Step 3: Extend hubCmd

In Common/Client/go/wshub/hub.go, replace the hubCmd struct (lines 212-224) with:

// hubCmd carries a command to the Run() goroutine.
type hubCmd struct {
	op string // "addSource","removeSource","setSourceState","updateConfig",
	//                "wsAddSource","wsRemoveSource","wsSaveSources",
	//                "wsSetCalibration","wsReloadConfig"
	sourceID       string
	label          string
	addr           string
	state          string
	sigs           []udpsprotocol.SignalInfo
	multicastGroup string
	dataPort       int
	enabled        bool      // "setMonotonic" toggle
	cal            CalConfig // "wsSetCalibration" payload
}
  • Step 4: Add the two message builders

In hub.go, immediately after buildSourcesMsg (which ends at line 471), add:

// buildCalibrationMsg serialises the calibration table as a "calibration"
// message.  It is its own frame rather than a field on "sources" because the
// C++ BroadcastSources serialises into a fixed 4096-byte buffer that a
// calibration table would overflow.
func buildCalibrationMsg(t *calTable) []byte {
	list := t.List() // never nil: the SPA replaces its table wholesale on receipt
	msg, _ := json.Marshal(map[string]any{"type": "calibration", "cal": list})
	return msg
}

// buildConfigAckMsg serialises a configSaved / configReloaded acknowledgement.
func buildConfigAckMsg(msgType, path string, err error) []byte {
	m := map[string]any{"type": msgType, "ok": err == nil, "path": path}
	if err != nil {
		m["error"] = err.Error()
	}
	msg, _ := json.Marshal(m)
	return msg
}
  • Step 5: Add the two readPump cases

In hub.go, inside the readPump type switch, after the case "saveSources": block (lines 105-109) insert:

				case "setCalibration":
					source, _ := env["source"].(string)
					signal, _ := env["signal"].(string)
					scale, hasScale := env["scale"].(float64)
					if !hasScale {
						scale = 1
					}
					offset, _ := env["offset"].(float64)
					unit, _ := env["unit"].(string)
					select {
					case c.hub.commandCh <- hubCmd{op: "wsSetCalibration", cal: CalConfig{
						Source: source, Signal: signal,
						Scale: scale, Offset: offset, Unit: unit,
					}}:
					default:
					}
				case "reloadConfig":
					select {
					case c.hub.commandCh <- hubCmd{op: "wsReloadConfig"}:
					default:
					}
  • Step 6: Send the calibration to newly connected clients

In hub.go, in the case c := <-h.register: block, after the monoMsg send (lines 515-519) and before the onClientConnect callback, insert:

			calMsg := buildCalibrationMsg(h.cal)
			select {
			case c.send <- wsMessage{websocket.TextMessage, calMsg}:
			default:
			}
  • Step 7: Add and change the commandCh cases

In hub.go, replace the existing case "wsSaveSources": block (lines 643-648) with:

			case "wsSaveSources":
				if h.sm != nil {
					// Save writes to disk; run it off the Run() goroutine so a
					// slow filesystem can never stall the hub loop.
					go func(sm *SourceManager) {
						err := sm.Save()
						if err != nil {
							log.Printf("hub: save config: %v", err)
						}
						h.broadcast(buildConfigAckMsg("configSaved", sm.Path(), err))
					}(h.sm)
				}

			case "wsSetCalibration":
				if h.cal.Set(cmd.cal) {
					h.broadcast(buildCalibrationMsg(h.cal))
				} else {
					// No broadcast: the offending client reverts to the last
					// value it was sent.
					log.Printf("hub: rejected calibration %q/%q (scale=%v offset=%v)",
						cmd.cal.Source, cmd.cal.Signal, cmd.cal.Scale, cmd.cal.Offset)
				}

			case "wsReloadConfig":
				if h.sm != nil {
					// Reload calls sm.Add(), which sends on commandCh; from the
					// Run() goroutine that send would hit the non-blocking
					// default and be dropped, so it must run elsewhere.
					go func(sm *SourceManager) {
						err := sm.Reload()
						if err != nil {
							log.Printf("hub: reload config: %v", err)
						}
						h.broadcast(buildConfigAckMsg("configReloaded", sm.Path(), err))
						if err == nil {
							h.broadcast(buildCalibrationMsg(h.cal))
						}
					}(h.sm)
				}
  • Step 8: Run the tests to verify they pass

Run: cd Common/Client/go && go vet ./wshub/ && go test ./wshub/ -race -v Expected: PASS for TestBuildCalibrationMsg, TestBuildCalibrationMsgEmptyTableIsEmptyArray, TestBuildConfigAckMsg, TestHubSetCalibrationCommand and every pre-existing test. No race warnings.

  • Step 9: Confirm both Go binaries still build

Run: cd Client/udpstreamer && go build ./... && cd ../debugger && go build ./... Expected: no output.

  • Step 10: Commit
git add Common/Client/go/wshub/hub.go Common/Client/go/wshub/hub_calibration_test.go
git commit -m "wshub: add setCalibration/reloadConfig frames and config acks"

Task 4: C++ StreamHub parity

Mirror the Go hub. This task also fixes a pre-existing bug: JsonGetString matched the literal pattern "key":" while HandleSaveSources writes "label": "wave" with a space after the colon, so the C++ hub could never load back a sources file it had written itself — label and addr both came out empty and AddSourceInternal returned false. JsonGetFloat was unaffected because it already skipped spaces, which is why dataPort worked. Both helpers are rewritten on a shared, whitespace-tolerant value locator.

Files:

  • Modify: Source/Applications/StreamHub/StreamHub.h — after kMaxSessions (line 45); handler declarations (132-148); LoadSourcesFile (178); JSON helpers (180-195); members (197-221)
  • Modify: Source/Applications/StreamHub/StreamHub.cpp — constructor (55-76); LoadSourcesFile call site (252); OnWSClientConnected (694-730); OnWSCommand (738-762); LoadSourcesFile (854-907); HandleSaveSources (909-947); JSON helpers (1576-1628)

Interfaces:

  • Consumes: nothing from earlier tasks — the C++ hub is independent code that must produce byte-compatible wire frames and config files.

  • Produces, for Task 5: the same five frames as the Go hub, and a config file the Go hub's parseConfigFile accepts unchanged.

  • Step 1: Reproduce the JSON round-trip bug

Build and run the current binary against a config file in the shape HandleSaveSources writes:

source env.sh
make -f Makefile.gcc apps
mkdir -p /tmp/shcal
cat > /tmp/shcal/sources.json <<'EOF'
[
  {
    "label": "wave",
    "addr": "127.0.0.1:44500"
  }
]
EOF
cat > /tmp/shcal/hub.cfg <<'EOF'
Hub = {
    WSPort        = 8099
    MaxPoints     = 20000
    PushRate      = 30
    MaxPushPoints = 200
    SourcesFile   = "/tmp/shcal/sources.json"
}
EOF
timeout 3 ./Build/x86-linux/Applications/StreamHub/StreamHub.ex -cfg /tmp/shcal/hub.cfg 2>&1 | grep -E "loaded|initialised"

Expected (the bug): initialised with 0 session(s) and no "loaded 1 source(s)" line. Record this output — Step 8 asserts it changes.

If the binary path differs, find it with find Build/x86-linux -name 'StreamHub.ex' and use that path throughout.

  • Step 2: Declare the calibration store in the header

In Source/Applications/StreamHub/StreamHub.h, after static const uint32 kMaxSessions = 32u; (line 45) add:

/** Maximum number of stored per-signal calibration entries. */
static const uint32 kMaxCalibration = 256u;

/** Maximum length of a calibration unit override (mirrors the Go maxUnitLen). */
static const uint32 kMaxUnitLen = 16u;

/**
 * @brief One per-signal affine calibration: y = raw*scale + offset.
 *
 * Keyed by the source LABEL (not the runtime "sN" id, which is assigned in
 * add-order and would rebind if the source list were reordered) and by the
 * BASE signal name (no "[i]" suffix: one entry covers a whole array signal).
 */
struct CalibrationEntry {
    MARTe::StreamString source;
    MARTe::StreamString signal;
    MARTe::StreamString unit;   ///< empty = use the streamer's unit
    MARTe::float64      scale;
    MARTe::float64      offset;
};

In the command-handler block, after void HandlePing(uint32 slotIdx); (line 148) add:

    void HandleSetCalibration(const char *json);
    void HandleReloadConfig();

In the broadcast block, after void BroadcastConfig(uint32 sessionIdx); (line 109) add:

    /** Broadcast {"type":"calibration","cal":[...]} to all clients. */
    void BroadcastCalibration();

    /** Broadcast {"type":"configSaved"|"configReloaded","ok":...} to all clients. */
    void BroadcastConfigAck(const char *msgType, bool ok, const char *errText);

Replace the LoadSourcesFile declaration (lines 174-178) with:

    /**
     * @brief Load sources and calibration from sourcesFile_ (a flat JSON array
     * of {"label","addr","multicastGroup","dataPort"} source blocks and
     * {"source","signal","scale","offset","unit"} calibration blocks).
     * @param skipActive when true, a source whose "host:port" is already
     * streaming is left alone instead of being started a second time.
     * @return true if the file was read.
     */
    bool LoadSourcesFile(bool skipActive);

    /** @return true if a session for this "host:port" is already active. */
    bool SourceIsActive(const char *addrPort);

    /**
     * @brief Store or replace one calibration entry.  An identity entry
     * (scale 1, offset 0, empty unit) removes any stored one instead.
     * @return true if the entry was valid (and therefore stored or removed).
     */
    bool SetCalibrationEntry(const char *source, const char *signal,
                             MARTe::float64 scale, MARTe::float64 offset,
                             const char *unit);

    /** Drop every calibration entry (used by reload, which replaces wholesale). */
    void ClearCalibration();

In the members block, after uint32 nextSourceId_; (line 221) add:

    CalibrationEntry    calibration_[kMaxCalibration];
    uint32              numCalibration_;
    FastPollingMutexSem calibrationMutex_; ///< Serializes calibration reads/writes
  • Step 3: Initialise the new members

In Source/Applications/StreamHub/StreamHub.cpp, in the constructor initialiser list, change nextSourceId_(1u), to:

      nextSourceId_(1u),
      numCalibration_(0u),
  • Step 4: Rewrite the JSON helpers

In StreamHub.cpp, replace the whole helper block — JsonGetString, JsonGetFloat, JsonGetUint32, JsonGetBool (lines 1576-1628) — with:

/**
 * Locate the value text for "key" in a flat JSON object, tolerating whitespace
 * around the colon.  Occurrences of the token that are NOT followed by a colon
 * are skipped, so a value that happens to equal a key name (for example
 * {"label": "addr", "addr": "..."}) does not shadow the real key.
 * @return pointer to the first character of the value, or 0 if not found.
 */
static const char *JsonFindValue(const char *json, const char *key) {
    char pattern[128];
    (void) snprintf(pattern, sizeof(pattern), "\"%s\"", key);
    const size_t plen = strlen(pattern);
    const char *p = json;
    while ((p = strstr(p, pattern)) != static_cast<const char *>(0)) {
        const char *q = p + plen;
        while ((*q == ' ') || (*q == '\t') || (*q == '\n') || (*q == '\r')) { q++; }
        if (*q == ':') {
            q++;
            while ((*q == ' ') || (*q == '\t') || (*q == '\n') || (*q == '\r')) { q++; }
            return q;
        }
        p += plen;
    }
    return static_cast<const char *>(0);
}

/**
 * Finite check without <cmath>: NaN fails self-comparison, and both infinities
 * fall outside the largest representable finite double.
 */
static bool JsonIsFinite(MARTe::float64 v) {
    return (v == v) && (v < 1.0e308) && (v > -1.0e308);
}

bool StreamHub::JsonGetString(const char *json, const char *key,
                               char *out, uint32 outSize) {
    const char *p = JsonFindValue(json, key);
    if (p == static_cast<const char *>(0)) { return false; }
    if (*p != '"') { return false; }
    p++;
    uint32 i = 0u;
    while ((*p != '\0') && (*p != '"') && (i < (outSize - 1u))) {
        out[i++] = *p++;
    }
    out[i] = '\0';
    return true;
}

bool StreamHub::JsonGetFloat(const char *json, const char *key, float64 &out) {
    const char *p = JsonFindValue(json, key);
    if (p == static_cast<const char *>(0)) { return false; }
    if (*p == '\0') { return false; }
    out = strtod(p, static_cast<char **>(0));
    return true;
}

bool StreamHub::JsonGetUint32(const char *json, const char *key, uint32 &out) {
    float64 v = 0.0;
    if (!JsonGetFloat(json, key, v)) { return false; }
    out = static_cast<uint32>(v);
    return true;
}

bool StreamHub::JsonGetBool(const char *json, const char *key, bool &out) {
    const char *p = JsonFindValue(json, key);
    if (p == static_cast<const char *>(0)) { return false; }
    if (strncmp(p, "true", 4u) == 0) {
        out = true;
        return true;
    }
    if (strncmp(p, "false", 5u) == 0) {
        out = false;
        return true;
    }
    return false;
}
  • Step 5: Implement the calibration store and its broadcasts

In StreamHub.cpp, immediately after BroadcastConfig (which ends at line 688), insert:

/*---------------------------------------------------------------------------*/
/* Calibration store                                                          */
/*---------------------------------------------------------------------------*/

bool StreamHub::SetCalibrationEntry(const char *source, const char *signal,
                                    float64 scale, float64 offset,
                                    const char *unit) {
    if ((source == static_cast<const char *>(0)) || (source[0] == '\0')) { return false; }
    if ((signal == static_cast<const char *>(0)) || (signal[0] == '\0')) { return false; }
    /* A zero or non-finite scale makes the calibration non-invertible, which
     * the SPA's trigger-threshold conversion depends on. */
    if (!JsonIsFinite(scale) || (scale == 0.0)) { return false; }
    if (!JsonIsFinite(offset)) { return false; }

    char u[kMaxUnitLen + 1u];
    u[0] = '\0';
    if (unit != static_cast<const char *>(0)) {
        strncpy(u, unit, kMaxUnitLen);
        u[kMaxUnitLen] = '\0';
    }

    /* An identity entry carries no information: drop it rather than store and
     * persist it. */
    const bool identity = (scale == 1.0) && (offset == 0.0) && (u[0] == '\0');

    (void) calibrationMutex_.FastLock();
    uint32 found = kMaxCalibration;
    for (uint32 i = 0u; i < numCalibration_; i++) {
        if ((strcmp(calibration_[i].source.Buffer(), source) == 0) &&
            (strcmp(calibration_[i].signal.Buffer(), signal) == 0)) {
            found = i;
            break;
        }
    }
    if (identity) {
        if (found < numCalibration_) {
            /* Compact by moving the last entry into the freed slot. */
            calibration_[found] = calibration_[numCalibration_ - 1u];
            numCalibration_--;
        }
        calibrationMutex_.FastUnLock();
        return true;
    }
    if (found == kMaxCalibration) {
        if (numCalibration_ >= kMaxCalibration) {
            calibrationMutex_.FastUnLock();
            return false;
        }
        found = numCalibration_;
        numCalibration_++;
    }
    calibration_[found].source  = source;
    calibration_[found].signal  = signal;
    calibration_[found].unit    = u;
    calibration_[found].scale   = scale;
    calibration_[found].offset  = offset;
    calibrationMutex_.FastUnLock();
    return true;
}

void StreamHub::ClearCalibration() {
    (void) calibrationMutex_.FastLock();
    for (uint32 i = 0u; i < numCalibration_; i++) {
        calibration_[i].source = "";
        calibration_[i].signal = "";
        calibration_[i].unit   = "";
    }
    numCalibration_ = 0u;
    calibrationMutex_.FastUnLock();
}

void StreamHub::BroadcastCalibration() {
    /* Own growable buffer, like BroadcastConfig: the fixed 4096-byte buffer
     * BroadcastSources uses would overflow on a full calibration table. */
    uint32 cap = 16384u;
    char *buf = new char[cap];
    uint32 off = 0u;
    JsonAppendf(buf, off, cap, "{\"type\":\"calibration\",\"cal\":[");

    (void) calibrationMutex_.FastLock();
    for (uint32 i = 0u; i < numCalibration_; i++) {
        JsonAppendf(buf, off, cap,
            "%s{\"source\":\"%s\",\"signal\":\"%s\","
            "\"scale\":%.17g,\"offset\":%.17g,\"unit\":\"%s\"}",
            (i > 0u) ? "," : "",
            calibration_[i].source.Buffer(),
            calibration_[i].signal.Buffer(),
            calibration_[i].scale,
            calibration_[i].offset,
            calibration_[i].unit.Buffer());
    }
    calibrationMutex_.FastUnLock();

    JsonAppendf(buf, off, cap, "]}");
    wsServer_.BroadcastText(buf, off);
    delete[] buf;
}

void StreamHub::BroadcastConfigAck(const char *msgType, bool ok,
                                   const char *errText) {
    char msg[512];
    int n;
    if (ok) {
        n = snprintf(msg, sizeof(msg),
            "{\"type\":\"%s\",\"ok\":true,\"path\":\"%s\"}",
            msgType, sourcesFile_.Buffer());
    }
    else {
        n = snprintf(msg, sizeof(msg),
            "{\"type\":\"%s\",\"ok\":false,\"path\":\"%s\",\"error\":\"%s\"}",
            msgType, sourcesFile_.Buffer(),
            (errText != static_cast<const char *>(0)) ? errText : "");
    }
    if (n > 0) { wsServer_.BroadcastText(msg, static_cast<uint32>(n)); }
}
  • Step 6: Add the two command handlers

In StreamHub.cpp, immediately after HandleGetStats() (which ends at line 1002), insert:

void StreamHub::HandleSetCalibration(const char *json) {
    char    source[128] = "";
    char    signal[128] = "";
    char    unit[64]    = "";
    float64 scale       = 1.0;
    float64 offset      = 0.0;

    (void) JsonGetString(json, "source", source, sizeof(source));
    (void) JsonGetString(json, "signal", signal, sizeof(signal));
    (void) JsonGetString(json, "unit",   unit,   sizeof(unit));
    (void) JsonGetFloat(json,  "scale",  scale);
    (void) JsonGetFloat(json,  "offset", offset);

    /* One entry covers a whole array signal: strip any "[i]" element suffix. */
    char *br = strchr(signal, '[');
    if (br != static_cast<char *>(0)) { *br = '\0'; }

    if (SetCalibrationEntry(source, signal, scale, offset, unit)) {
        BroadcastCalibration();
    }
    else {
        /* No broadcast: the offending client reverts to its last known value. */
        REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
            "StreamHub: rejected calibration for '%s'/'%s'.", source, signal);
    }
}

void StreamHub::HandleReloadConfig() {
    if (sourcesFile_.Size() == 0u) {
        BroadcastConfigAck("configReloaded", false, "no sources file configured");
        return;
    }
    /* Calibration is replaced wholesale; sources are only added.  A reload must
     * never interrupt a live UDP session. */
    ClearCalibration();
    if (!LoadSourcesFile(true)) {
        BroadcastConfigAck("configReloaded", false, "cannot read sources file");
        return;
    }
    BroadcastConfigAck("configReloaded", true, "");
    BroadcastCalibration();
    BroadcastSources();
}
  • Step 7: Wire the dispatch, the connect handshake and the file I/O

7a. In OnWSCommand (line 744), after the saveSources line add:

    else if (strcmp(type, "setCalibration") == 0) { HandleSetCalibration(json); }
    else if (strcmp(type, "reloadConfig")   == 0) { HandleReloadConfig();       }

7b. In OnWSClientConnected, after the BroadcastTriggerState(); call (line 708) add:

    /* Let the new client apply the stored per-signal calibration. */
    BroadcastCalibration();

7c. At line 252 change LoadSourcesFile(); to (void) LoadSourcesFile(false);.

7d. Replace LoadSourcesFile (lines 854-907) with:

bool StreamHub::SourceIsActive(const char *addrPort) {
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        StreamString adr = sessions_[i].GetAddr();
        char cur[96];
        (void) snprintf(cur, sizeof(cur), "%s:%u", adr.Buffer(),
                        static_cast<uint32>(sessions_[i].GetPort()));
        if (strcmp(cur, addrPort) == 0) { return true; }
    }
    return false;
}

bool StreamHub::LoadSourcesFile(bool skipActive) {
    if (sourcesFile_.Size() == 0u) { return false; }

    FILE *f = fopen(sourcesFile_.Buffer(), "rb");
    if (f == static_cast<FILE *>(0)) { return false; } /* missing file is fine */

    (void) fseek(f, 0, SEEK_END);
    const long fsz = ftell(f);
    (void) fseek(f, 0, SEEK_SET);
    if ((fsz <= 0) || (fsz > (1L << 20))) {
        (void) fclose(f);
        return false;
    }
    char *data = new char[static_cast<uint32>(fsz) + 1u];
    const MARTe::osulong nRead = fread(data, 1u, static_cast<MARTe::osulong>(fsz), f);
    data[nRead] = '\0';
    (void) fclose(f);

    /* Flat JSON array of flat objects — iterate over each {...} block.  A block
     * with "addr" is a source, one with "signal" is a calibration.  The array
     * must stay flat: this scanner takes each "{" up to the next "}". */
    uint32 nLoaded = 0u;
    uint32 nCal    = 0u;
    const char *p = data;
    while ((p = strchr(p, '{')) != static_cast<const char *>(0)) {
        const char *end = strchr(p, '}');
        if (end == static_cast<const char *>(0)) { break; }

        uint32 objLen = static_cast<uint32>(end - p) + 1u;
        if (objLen > 1023u) { objLen = 1023u; }
        char obj[1024];
        memcpy(obj, p, objLen);
        obj[objLen] = '\0';

        char addr[80] = "";
        (void) JsonGetString(obj, "addr", addr, sizeof(addr));

        if (addr[0] != '\0') {
            char    label[128]  = "";
            char    mcGroup[64] = "";
            float64 dataPortF   = 0.0;
            (void) JsonGetString(obj, "label",          label,   sizeof(label));
            (void) JsonGetString(obj, "multicastGroup", mcGroup, sizeof(mcGroup));
            (void) JsonGetFloat(obj,  "dataPort",       dataPortF);
            if (skipActive && SourceIsActive(addr)) {
                /* Already streaming — leave the live session untouched. */
            }
            else if (AddSourceInternal(label, addr, mcGroup,
                                       static_cast<uint16>(dataPortF))) {
                nLoaded++;
            }
        }
        else {
            char calSignal[128] = "";
            (void) JsonGetString(obj, "signal", calSignal, sizeof(calSignal));
            if (calSignal[0] != '\0') {
                char    calSource[128] = "";
                char    calUnit[64]    = "";
                float64 calScale       = 1.0;
                float64 calOffset      = 0.0;
                (void) JsonGetString(obj, "source", calSource, sizeof(calSource));
                (void) JsonGetString(obj, "unit",   calUnit,   sizeof(calUnit));
                (void) JsonGetFloat(obj,  "scale",  calScale);
                (void) JsonGetFloat(obj,  "offset", calOffset);
                if (SetCalibrationEntry(calSource, calSignal,
                                        calScale, calOffset, calUnit)) {
                    nCal++;
                }
                else {
                    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
                        "StreamHub: skipping invalid calibration '%s'/'%s'.",
                        calSource, calSignal);
                }
            }
            else {
                REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
                    "StreamHub: skipping unrecognised config block.");
            }
        }
        p = end + 1;
    }
    delete[] data;

    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
        "StreamHub: loaded %u source(s) and %u calibration entr(y/ies) from '%s'.",
        nLoaded, nCal, sourcesFile_.Buffer());
    return true;
}

7e. Replace HandleSaveSources (lines 909-947) with:

void StreamHub::HandleSaveSources() {
    if (sourcesFile_.Size() == 0u) {
        BroadcastConfigAck("configSaved", false, "no sources file configured");
        return;
    }

    FILE *f = fopen(sourcesFile_.Buffer(), "wb");
    if (f == static_cast<FILE *>(0)) {
        REPORT_ERROR_STATIC(MARTe::ErrorManagement::Warning,
            "StreamHub: cannot write sources file '%s'.", sourcesFile_.Buffer());
        BroadcastConfigAck("configSaved", false, "cannot open file for writing");
        return;
    }

    uint32 nSaved = 0u;
    (void) fprintf(f, "[\n");
    for (uint32 i = 0u; i < kMaxSessions; i++) {
        if (!sessionActive_[i]) { continue; }
        StreamString lbl = sessions_[i].GetLabel();
        StreamString adr = sessions_[i].GetAddr();
        StreamString mcg = sessions_[i].GetMulticastGroup();
        const uint16 prt = sessions_[i].GetPort();
        const uint16 dpt = sessions_[i].GetDataPort();

        (void) fprintf(f, "%s  {\n    \"label\": \"%s\",\n    \"addr\": \"%s:%u\"",
                       (nSaved > 0u) ? ",\n" : "",
                       lbl.Buffer(), adr.Buffer(), static_cast<uint32>(prt));
        if (mcg.Size() > 0u) {
            (void) fprintf(f, ",\n    \"multicastGroup\": \"%s\"", mcg.Buffer());
            if (dpt > 0u) {
                (void) fprintf(f, ",\n    \"dataPort\": %u",
                               static_cast<uint32>(dpt));
            }
        }
        (void) fprintf(f, "\n  }");
        nSaved++;
    }

    /* Calibration entries are further elements of the SAME flat array. */
    uint32 nCal = 0u;
    (void) calibrationMutex_.FastLock();
    for (uint32 i = 0u; i < numCalibration_; i++) {
        (void) fprintf(f,
            "%s  {\n    \"source\": \"%s\",\n    \"signal\": \"%s\",\n"
            "    \"scale\": %.17g,\n    \"offset\": %.17g",
            ((nSaved + nCal) > 0u) ? ",\n" : "",
            calibration_[i].source.Buffer(),
            calibration_[i].signal.Buffer(),
            calibration_[i].scale,
            calibration_[i].offset);
        if (calibration_[i].unit.Size() > 0u) {
            (void) fprintf(f, ",\n    \"unit\": \"%s\"",
                           calibration_[i].unit.Buffer());
        }
        (void) fprintf(f, "\n  }");
        nCal++;
    }
    calibrationMutex_.FastUnLock();

    (void) fprintf(f, "\n]\n");
    (void) fclose(f);

    REPORT_ERROR_STATIC(MARTe::ErrorManagement::Information,
        "StreamHub: saved %u source(s) and %u calibration entr(y/ies) to '%s'.",
        nSaved, nCal, sourcesFile_.Buffer());
    BroadcastConfigAck("configSaved", true, "");
}
  • Step 8: Rebuild and verify the load path now works
source env.sh
make -f Makefile.gcc apps
timeout 3 ./Build/x86-linux/Applications/StreamHub/StreamHub.ex -cfg /tmp/shcal/hub.cfg 2>&1 | grep -E "loaded|initialised"

Expected: loaded 1 source(s) and 0 calibration entr(y/ies) and initialised with 1 session(s) — the opposite of Step 1.

  • Step 9: Verify a calibration block loads
cat > /tmp/shcal/sources.json <<'EOF'
[
  {
    "label": "wave",
    "addr": "127.0.0.1:44500"
  },
  {
    "source": "wave",
    "signal": "Adc",
    "scale": 0.00030518,
    "offset": -1.25,
    "unit": "V"
  }
]
EOF
timeout 3 ./Build/x86-linux/Applications/StreamHub/StreamHub.ex -cfg /tmp/shcal/hub.cfg 2>&1 | grep loaded

Expected: loaded 1 source(s) and 1 calibration entr(y/ies).

  • Step 10: Verify the whole C++ build and unit tests still pass
source env.sh
make -f Makefile.gcc core && make -f Makefile.gcc apps && make -f Makefile.gcc test
./Build/x86-linux/GTest/MainGTest.ex

Expected: build succeeds with no new warnings; all GTest cases pass.

  • Step 11: Commit
git add Source/Applications/StreamHub/StreamHub.h Source/Applications/StreamHub/StreamHub.cpp
git commit -m "StreamHub: per-signal calibration, config reload, whitespace-tolerant JSON"

Task 5: Cross-hub parity checker

The Go hub (Task 3) and the C++ StreamHub (Task 4) must now answer the five new frames identically. This task builds one small WebSocket client that exercises all five and runs it against both binaries, so any divergence fails loudly instead of being discovered later from the browser.

It is a new main package inside the existing chain-client Go module (Test/E2E/suite/client/go.mod, module name chain-client, gorilla/websocket already vendored in go.sum). It is deliberately not added to Test/E2E/suite/client/main.go: that program's checks require a live UDP source and are wired into scenarios.py / report_data.json. The parity checker needs no source at all — it only talks to the hub — so it stays standalone.

Files:

  • Create: Test/E2E/suite/client/configcheck/main.go

Interfaces:

  • Consumes: the five WebSocket frames from Task 3 (Go hub) and Task 4 (C++ StreamHub): setCalibration, calibration, saveSources, configSaved, reloadConfig, configReloaded.

  • Produces: an executable chain-client/configcheck. Exit code 0 on success, 1 on any mismatch or timeout, with a one-line reason on stderr.

  • Step 1: Write the checker

Create Test/E2E/suite/client/configcheck/main.go:

// configcheck exercises the calibration and config-persistence WebSocket frames
// against a StreamHub (either the Go hub or the C++ StreamHub) and exits
// non-zero if the hub's replies do not match the protocol.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"time"

	"github.com/gorilla/websocket"
)

type calEntry struct {
	Source string  `json:"source"`
	Signal string  `json:"signal"`
	Scale  float64 `json:"scale"`
	Offset float64 `json:"offset"`
	Unit   string  `json:"unit"`
}

type frame struct {
	Type  string     `json:"type"`
	Cal   []calEntry `json:"cal"`
	OK    bool       `json:"ok"`
	Path  string     `json:"path"`
	Error string     `json:"error"`
}

type conn struct {
	ws      *websocket.Conn
	timeout time.Duration
}

// next reads text frames until one has the wanted type, or the deadline passes.
// Binary frames (live data) and unrelated text frames are skipped.
func (c *conn) next(want string) (frame, error) {
	deadline := time.Now().Add(c.timeout)
	for {
		if time.Now().After(deadline) {
			return frame{}, fmt.Errorf("timeout waiting for %q", want)
		}
		_ = c.ws.SetReadDeadline(deadline)
		mt, data, err := c.ws.ReadMessage()
		if err != nil {
			return frame{}, fmt.Errorf("read while waiting for %q: %w", want, err)
		}
		if mt != websocket.TextMessage {
			continue
		}
		var f frame
		if err := json.Unmarshal(data, &f); err != nil {
			continue
		}
		if f.Type == want {
			return f, nil
		}
	}
}

func (c *conn) send(v interface{}) error {
	data, err := json.Marshal(v)
	if err != nil {
		return err
	}
	return c.ws.WriteMessage(websocket.TextMessage, data)
}

func findCal(list []calEntry, source, signal string) (calEntry, bool) {
	for _, e := range list {
		if e.Source == source && e.Signal == signal {
			return e, true
		}
	}
	return calEntry{}, false
}

func run(url, source, signal string, timeout time.Duration) error {
	ws, _, err := websocket.DefaultDialer.Dial(url, nil)
	if err != nil {
		return fmt.Errorf("dial %s: %w", url, err)
	}
	defer ws.Close()
	c := &conn{ws: ws, timeout: timeout}

	// 1. The hub sends a calibration frame on connect, even when empty.
	if _, err := c.next("calibration"); err != nil {
		return fmt.Errorf("on connect: %w", err)
	}

	// 2. setCalibration is accepted and echoed back to every client.
	want := calEntry{Source: source, Signal: signal, Scale: 0.5, Offset: -1.25, Unit: "V"}
	if err := c.send(map[string]interface{}{
		"type":   "setCalibration",
		"source": want.Source,
		"signal": want.Signal,
		"scale":  want.Scale,
		"offset": want.Offset,
		"unit":   want.Unit,
	}); err != nil {
		return err
	}
	f, err := c.next("calibration")
	if err != nil {
		return fmt.Errorf("after setCalibration: %w", err)
	}
	got, ok := findCal(f.Cal, source, signal)
	if !ok {
		return fmt.Errorf("setCalibration: entry %s/%s missing from broadcast", source, signal)
	}
	if got != want {
		return fmt.Errorf("setCalibration: got %+v, want %+v", got, want)
	}

	// 3. An invalid entry (scale = 0) must be rejected: no broadcast follows.
	if err := c.send(map[string]interface{}{
		"type": "setCalibration", "source": source, "signal": signal,
		"scale": 0.0, "offset": 0.0, "unit": "",
	}); err != nil {
		return err
	}
	short := &conn{ws: ws, timeout: 500 * time.Millisecond}
	if _, err := short.next("calibration"); err == nil {
		return fmt.Errorf("setCalibration with scale=0 was accepted, must be rejected")
	}

	// 4. saveSources acknowledges with configSaved.
	if err := c.send(map[string]string{"type": "saveSources"}); err != nil {
		return err
	}
	f, err = c.next("configSaved")
	if err != nil {
		return err
	}
	if !f.OK {
		return fmt.Errorf("configSaved: ok=false, error=%q", f.Error)
	}
	if f.Path == "" {
		return fmt.Errorf("configSaved: ok=true but path is empty")
	}

	// 5. reloadConfig acknowledges and re-broadcasts the saved calibration.
	if err := c.send(map[string]string{"type": "reloadConfig"}); err != nil {
		return err
	}
	f, err = c.next("configReloaded")
	if err != nil {
		return err
	}
	if !f.OK {
		return fmt.Errorf("configReloaded: ok=false, error=%q", f.Error)
	}
	f, err = c.next("calibration")
	if err != nil {
		return fmt.Errorf("after reloadConfig: %w", err)
	}
	got, ok = findCal(f.Cal, source, signal)
	if !ok {
		return fmt.Errorf("reloadConfig: entry %s/%s did not survive the round-trip", source, signal)
	}
	if got != want {
		return fmt.Errorf("reloadConfig: got %+v, want %+v", got, want)
	}
	return nil
}

func main() {
	url := flag.String("url", "ws://127.0.0.1:8090/ws", "hub WebSocket URL")
	source := flag.String("source", "cfgcheck", "calibration source label to use")
	signal := flag.String("signal", "Probe", "calibration signal name to use")
	timeout := flag.Duration("timeout", 5*time.Second, "per-frame timeout")
	flag.Parse()

	if err := run(*url, *source, *signal, *timeout); err != nil {
		fmt.Fprintf(os.Stderr, "configcheck FAIL: %v\n", err)
		os.Exit(1)
	}
	fmt.Println("configcheck OK")
}
  • Step 2: Build it
cd Test/E2E/suite/client && go vet ./configcheck && go build -o configcheck/configcheck ./configcheck

Expected: no output from either command; the binary Test/E2E/suite/client/configcheck/configcheck exists.

  • Step 3: Run it against the Go hub — expect PASS
cd /tmp && rm -rf gohubcheck && mkdir gohubcheck
cd "$OLDPWD"
go run ./Client/udpstreamer -port 8099 -sources-file /tmp/gohubcheck/cfg.json &
GOHUB=$!
sleep 1
./Test/E2E/suite/client/configcheck/configcheck -url ws://127.0.0.1:8099/ws
RC=$?
kill $GOHUB
cat /tmp/gohubcheck/cfg.json
exit $RC

Expected: configcheck OK, exit 0, and cfg.json containing exactly one calibration object with "source": "cfgcheck", "signal": "Probe", "scale": 0.5, "offset": -1.25, "unit": "V" and no source objects.

  • Step 4: Run it against the C++ StreamHub — expect PASS
source env.sh
rm -rf /tmp/cpphubcheck && mkdir -p /tmp/cpphubcheck
cat > /tmp/cpphubcheck/hub.cfg <<'EOF'
+Hub = {
    Class = StreamHub
    WSPort = 8098
    MaxPoints = 2000
    SourcesFile = "/tmp/cpphubcheck/cfg.json"
}
EOF
./Build/x86-linux/Applications/StreamHub/StreamHub.ex -cfg /tmp/cpphubcheck/hub.cfg &
CPPHUB=$!
sleep 1
./Test/E2E/suite/client/configcheck/configcheck -url ws://127.0.0.1:8098/ws
RC=$?
kill $CPPHUB
cat /tmp/cpphubcheck/cfg.json
exit $RC

Expected: configcheck OK, exit 0, and a cfg.json with the same single calibration object as Step 3. Byte-for-byte equality between the two hubs' files is not required (indentation may differ); the object's field values must match.

  • Step 5: Cross-load the two files to prove format compatibility
source env.sh
cp /tmp/gohubcheck/cfg.json /tmp/cpphubcheck/fromgo.json
sed -i 's#/tmp/cpphubcheck/cfg.json#/tmp/cpphubcheck/fromgo.json#' /tmp/cpphubcheck/hub.cfg
timeout 3 ./Build/x86-linux/Applications/StreamHub/StreamHub.ex -cfg /tmp/cpphubcheck/hub.cfg 2>&1 | grep -i 'calibration'
go run ./Client/udpstreamer -port 8097 -sources-file /tmp/cpphubcheck/cfg.json &
GOHUB=$!
sleep 1
./Test/E2E/suite/client/configcheck/configcheck -url ws://127.0.0.1:8097/ws
kill $GOHUB

Expected: the C++ hub logs loaded 0 source(s) and 1 calibration entr(y/ies) from the Go-written file, and the Go hub started on the C++-written file reports configcheck OK.

  • Step 6: Add the built binary to .gitignore and commit

Append to Test/E2E/suite/client/.gitignore (create the file if it does not exist):

chain-client
configcheck/configcheck
git add Test/E2E/suite/client/configcheck/main.go Test/E2E/suite/client/.gitignore
git commit -m "test: add cross-hub calibration and config-persistence parity checker"

Task 6: SPA calibration primitives (pure, unit-tested)

app.js is a 3600-line browser-only file with no test harness. Rather than grow it further with untested arithmetic, the calibration maths goes into a small pure module that runs both in the browser (as a plain <script> defining a global) and under node --test. Tasks 7-10 consume it; they contain only wiring.

Client/udpstreamer/main.go embeds the whole static directory with //go:embed static, so a new file there is served automatically — no registration needed. The test/ directory sits outside static/ so it is not embedded into the binary.

Files:

  • Create: Client/udpstreamer/static/calibration.js
  • Create: Client/udpstreamer/test/calibration.test.js
  • Modify: Client/udpstreamer/static/index.html:219 (load the new script before app.js)

Interfaces:

  • Consumes: nothing. This module has no dependencies.

  • Produces: a global Calib in the browser and a CommonJS export under Node, with exactly these members:

    • Calib.MAX_UNIT_LEN16
    • Calib.IDENTITY{scale: 1, offset: 0, unit: ''} (frozen)
    • Calib.calKey(source, signal)string
    • Calib.baseSignalName(name)string — strips a trailing [i]
    • Calib.normaliseCal(obj){source, signal, scale, offset, unit} or null
    • Calib.applyCal(raw, cal)number
    • Calib.invertCal(value, cal)number
    • Calib.calRange(min, max, cal)[number, number]
    • new Calib.CalTable() with .get(source, signal), .set(entry), .replaceAll(list), .list()
  • Step 1: Write the failing tests

Create Client/udpstreamer/test/calibration.test.js:

const test = require('node:test');
const assert = require('node:assert');
const C = require('../static/calibration.js');

test('baseSignalName strips an element suffix', () => {
  assert.strictEqual(C.baseSignalName('Adc'), 'Adc');
  assert.strictEqual(C.baseSignalName('Adc[3]'), 'Adc');
  assert.strictEqual(C.baseSignalName('Adc[12]'), 'Adc');
  assert.strictEqual(C.baseSignalName('A[1]B'), 'A[1]B');
  assert.strictEqual(C.baseSignalName(''), '');
});

test('calKey is stable and separates the two fields', () => {
  assert.strictEqual(C.calKey('a', 'b'), C.calKey('a', 'b'));
  assert.notStrictEqual(C.calKey('ab', 'c'), C.calKey('a', 'bc'));
});

test('normaliseCal accepts a valid entry and fills defaults', () => {
  assert.deepStrictEqual(
    C.normaliseCal({source: ' wave ', signal: ' Adc ', scale: 2, offset: -1, unit: ' V '}),
    {source: 'wave', signal: 'Adc', scale: 2, offset: -1, unit: 'V'});
  assert.deepStrictEqual(
    C.normaliseCal({source: 'wave', signal: 'Adc'}),
    {source: 'wave', signal: 'Adc', scale: 1, offset: 0, unit: ''});
});

test('normaliseCal strips an element suffix from the signal name', () => {
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 'Adc[3]'}).signal, 'Adc');
});

test('normaliseCal truncates an over-long unit', () => {
  const long = 'abcdefghijklmnopqrstuvwxyz';
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: long}).unit,
                     long.slice(0, C.MAX_UNIT_LEN));
});

test('normaliseCal rejects invalid entries', () => {
  assert.strictEqual(C.normaliseCal(null), null);
  assert.strictEqual(C.normaliseCal({signal: 's'}), null);
  assert.strictEqual(C.normaliseCal({source: 'w'}), null);
  assert.strictEqual(C.normaliseCal({source: '  ', signal: 's'}), null);
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: 0}), null);
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: NaN}), null);
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: Infinity}), null);
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', offset: NaN}), null);
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', scale: '2'}), null);
});

test('applyCal and invertCal round-trip', () => {
  const cal = {scale: 0.5, offset: -1.25, unit: 'V'};
  assert.strictEqual(C.applyCal(10, cal), 3.75);
  assert.strictEqual(C.invertCal(3.75, cal), 10);
  assert.strictEqual(C.applyCal(7, C.IDENTITY), 7);
  assert.strictEqual(C.invertCal(7, C.IDENTITY), 7);
});

test('applyCal passes non-finite samples through untouched', () => {
  assert.ok(Number.isNaN(C.applyCal(NaN, {scale: 2, offset: 1, unit: ''})));
});

test('calRange re-orders when the scale is negative', () => {
  assert.deepStrictEqual(C.calRange(0, 10, {scale: 2, offset: 1, unit: ''}), [1, 21]);
  assert.deepStrictEqual(C.calRange(0, 10, {scale: -2, offset: 1, unit: ''}), [-19, 1]);
});

test('CalTable.get returns IDENTITY for an unknown signal', () => {
  const t = new C.CalTable();
  assert.deepStrictEqual(t.get('w', 'Adc'), C.IDENTITY);
});

test('CalTable.get resolves an element name to its base signal', () => {
  const t = new C.CalTable();
  t.set({source: 'w', signal: 'Adc', scale: 3, offset: 0, unit: ''});
  assert.strictEqual(t.get('w', 'Adc[7]').scale, 3);
});

test('CalTable.set stores, overwrites, and deletes identity entries', () => {
  const t = new C.CalTable();
  assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 2}), true);
  assert.strictEqual(t.get('w', 'Adc').scale, 2);
  t.set({source: 'w', signal: 'Adc', scale: 5});
  assert.strictEqual(t.get('w', 'Adc').scale, 5);
  assert.strictEqual(t.list().length, 1);
  // Resetting to identity removes the entry entirely.
  assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 1, offset: 0, unit: ''}), true);
  assert.strictEqual(t.list().length, 0);
  // An invalid entry is refused and changes nothing.
  assert.strictEqual(t.set({source: 'w', signal: 'Adc', scale: 0}), false);
  assert.strictEqual(t.list().length, 0);
});

test('CalTable.replaceAll drops the previous contents', () => {
  const t = new C.CalTable();
  t.set({source: 'w', signal: 'Old', scale: 2});
  t.replaceAll([
    {source: 'w', signal: 'B', scale: 2},
    {source: 'w', signal: 'A', scale: 3},
    {source: 'w', signal: 'Bad', scale: 0},
    {source: 'w', signal: 'Ident', scale: 1, offset: 0, unit: ''},
  ]);
  assert.deepStrictEqual(t.list().map(e => e.signal), ['A', 'B']);
});

test('CalTable.list is sorted by source then signal', () => {
  const t = new C.CalTable();
  t.set({source: 'z', signal: 'a', scale: 2});
  t.set({source: 'a', signal: 'z', scale: 2});
  t.set({source: 'a', signal: 'b', scale: 2});
  assert.deepStrictEqual(t.list().map(e => e.source + '/' + e.signal),
                         ['a/b', 'a/z', 'z/a']);
});
  • Step 2: Run the tests to verify they fail
cd Client/udpstreamer && node --test test/

Expected: FAIL — Cannot find module '../static/calibration.js'.

  • Step 3: Write the implementation

Create Client/udpstreamer/static/calibration.js:

// Per-signal affine calibration: value = raw * scale + offset, with an optional
// unit override. Pure and dependency-free so it can be unit-tested under Node;
// in the browser it defines the global `Calib`.
(function (root) {
  'use strict';

  var MAX_UNIT_LEN = 16;
  var IDENTITY = Object.freeze({scale: 1, offset: 0, unit: ''});

  // The table is keyed by (source label, base signal name). U+0000 cannot occur
  // in either, so it is an unambiguous separator.
  function calKey(source, signal) {
    return source + '\u0000' + signal;
  }

  // 'Adc[3]' -> 'Adc'. One calibration covers every element of an array signal.
  function baseSignalName(name) {
    var s = String(name == null ? '' : name);
    var open = s.lastIndexOf('[');
    if (open > 0 && s.charAt(s.length - 1) === ']') {
      var idx = s.slice(open + 1, s.length - 1);
      if (idx.length > 0 && /^[0-9]+$/.test(idx)) return s.slice(0, open);
    }
    return s;
  }

  function isFiniteNum(v) {
    return typeof v === 'number' && isFinite(v);
  }

  // Mirrors the hub-side validation exactly (Go: CalConfig.Normalise,
  // C++: StreamHub::HandleSetCalibration). Returns null when the entry must be
  // rejected, so a caller can revert an input field to its last accepted value.
  function normaliseCal(obj) {
    if (obj === null || typeof obj !== 'object') return null;
    var source = String(obj.source == null ? '' : obj.source).trim();
    var signal = baseSignalName(String(obj.signal == null ? '' : obj.signal).trim());
    if (source === '' || signal === '') return null;
    var scale = obj.scale === undefined ? 1 : obj.scale;
    var offset = obj.offset === undefined ? 0 : obj.offset;
    if (!isFiniteNum(scale) || scale === 0) return null;
    if (!isFiniteNum(offset)) return null;
    var unit = String(obj.unit == null ? '' : obj.unit).trim();
    if (unit.length > MAX_UNIT_LEN) unit = unit.slice(0, MAX_UNIT_LEN);
    return {source: source, signal: signal, scale: scale, offset: offset, unit: unit};
  }

  function isIdentity(cal) {
    return cal.scale === 1 && cal.offset === 0 && cal.unit === '';
  }

  function applyCal(raw, cal) {
    return raw * cal.scale + cal.offset;
  }

  function invertCal(value, cal) {
    return (value - cal.offset) / cal.scale;
  }

  // A negative scale swaps the ends of a range, so re-order after calibrating.
  function calRange(min, max, cal) {
    var a = applyCal(min, cal), b = applyCal(max, cal);
    return a <= b ? [a, b] : [b, a];
  }

  function CalTable() {
    this._m = Object.create(null);
  }

  CalTable.prototype.get = function (source, signal) {
    var e = this._m[calKey(source, baseSignalName(signal))];
    return e === undefined ? IDENTITY : e;
  };

  // Returns false when the entry was rejected as invalid. An entry that reduces
  // to the identity is deleted rather than stored, so a Reset cleans the table
  // (and, once saved, the config file) instead of filling it with no-ops.
  CalTable.prototype.set = function (entry) {
    var c = normaliseCal(entry);
    if (c === null) return false;
    var k = calKey(c.source, c.signal);
    if (isIdentity(c)) delete this._m[k];
    else this._m[k] = c;
    return true;
  };

  CalTable.prototype.replaceAll = function (list) {
    this._m = Object.create(null);
    if (!list) return;
    for (var i = 0; i < list.length; i++) this.set(list[i]);
  };

  CalTable.prototype.list = function () {
    var out = [], k;
    for (k in this._m) out.push(this._m[k]);
    out.sort(function (a, b) {
      if (a.source !== b.source) return a.source < b.source ? -1 : 1;
      if (a.signal !== b.signal) return a.signal < b.signal ? -1 : 1;
      return 0;
    });
    return out;
  };

  var api = {
    MAX_UNIT_LEN: MAX_UNIT_LEN,
    IDENTITY: IDENTITY,
    calKey: calKey,
    baseSignalName: baseSignalName,
    normaliseCal: normaliseCal,
    isIdentity: isIdentity,
    applyCal: applyCal,
    invertCal: invertCal,
    calRange: calRange,
    CalTable: CalTable,
  };

  if (typeof module !== 'undefined' && module.exports) module.exports = api;
  else root.Calib = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
  • Step 4: Run the tests to verify they pass
cd Client/udpstreamer && node --test test/

Expected: PASS — # pass 14, # fail 0.

  • Step 5: Load the module in the page

In Client/udpstreamer/static/index.html, replace the single script tag near the end of <body>:

    <script src="/app.js"></script>

with:

    <script src="/calibration.js"></script>
    <script src="/app.js"></script>

calibration.js must load first: app.js reads the global Calib at top level in Task 7.

  • Step 6: Verify the page still loads
cd Client/udpstreamer && node --check static/calibration.js && node --check static/app.js
go run . -port 8099 &
sleep 1
curl -sf http://127.0.0.1:8099/calibration.js | head -3
kill %1

Expected: both node --check calls are silent; the curl prints the module's first three lines, proving //go:embed picked the new file up.

  • Step 7: Commit
git add Client/udpstreamer/static/calibration.js Client/udpstreamer/test/calibration.test.js Client/udpstreamer/static/index.html
git commit -m "webui: add pure calibration module with unit tests"

Task 7: SPA display path — calibrate every plotted value

This is the task that makes calibration visible. Because both calibration and the vertical scale are affine, applying calibration once, at the top of applyVScaleNorm, is sufficient for the whole display path:

y_cal  = raw * scale + offset          <- added here
y_norm = (y_cal - vsOffset) / divValue + screenPos   <- unchanged

Everything that converts a plotted value back to a number — rawFromNorm (app.js:2325), the cursor readout, the rulers, the Y-axis tick formatter, and the V-Scale menu's own V/div and Offset fields — reads vs._resolvedDiv / vs._resolvedOffset, which resolveVScale derives from the array it is handed. Calibrating that array therefore makes all of them report calibrated units with no further change.

Only one in-display site bypasses that: resolveVScale's range mode (app.js:113-125), which reads meta.rangeMin / meta.rangeMax straight from the streamer CONFIG rather than from the data.

Files:

  • Modify: Client/udpstreamer/static/app.js — new globals + helpers near findSignalMeta (app.js:101-107), resolveVScale (app.js:113-125), applyVScaleNorm (app.js:186-199), the ws.onmessage dispatch (app.js:364-375), and the source-management block (app.js:3339).

Interfaces:

  • Consumes: Calib from Task 6; the calibration, configSaved and configReloaded frames from Tasks 3 and 4.

  • Produces, for Tasks 8-10:

    • calTable — the module-level Calib.CalTable instance
    • srcLabelForKey(key)string — signal key "s1:Adc[3]" → source label
    • calForKey(key) → calibration object (never null; Calib.IDENTITY if unset)
    • unitForKey(key)string — the override if set, else the streamer's unit
    • setCalibrationWS(source, signal, scale, offset, unit) → void
    • saveConfigWS() / reloadConfigWS() → void
    • onConfigAck(msg) → void — assigned by Task 10; a no-op stub here
    • applyCalibrationChanged() → void — re-render everything after a change
  • Step 1: Add the calibration table, its localStorage mirror, and the key helpers

In Client/udpstreamer/static/app.js, immediately after findSignalMeta (which ends at app.js:107), insert:

/* ─── Calibration ────────────────────────────────────────────────────────── */
// Per-signal affine calibration, keyed by (source LABEL, base signal name).
// The label rather than the runtime id ('s1', 's2') is used because ids are
// assigned in add-order at startup, so an id-keyed entry would rebind to a
// different source whenever the source list order changed.
const CAL_LS_KEY = 'udpscope.calibration';
const calTable = new Calib.CalTable();

// Seeded from localStorage so calibration survives a reload against a hub that
// predates this feature (or one started without a config file). The first
// `calibration` frame from the hub overwrites it wholesale.
try {
  const saved = localStorage.getItem(CAL_LS_KEY);
  if (saved) calTable.replaceAll(JSON.parse(saved));
} catch { /* corrupt or unavailable storage: start empty */ }

function persistCalibration() {
  try { localStorage.setItem(CAL_LS_KEY, JSON.stringify(calTable.list())); }
  catch { /* quota or private mode: the hub copy is still authoritative */ }
}

// Signal key "s1:Adc[3]" → the source's label ("wave"), or '' if unknown.
function srcLabelForKey(key) {
  const colon = key.indexOf(':');
  if (colon < 0) return '';
  const src = sourcesMap[key.slice(0, colon)];
  return src ? (src.label || src.id) : '';
}

// Signal key "s1:Adc[3]" → base signal name ("Adc").
function baseSigForKey(key) {
  const colon = key.indexOf(':');
  return Calib.baseSignalName(colon < 0 ? key : key.slice(colon + 1));
}

// Never returns null — an uncalibrated signal yields Calib.IDENTITY.
function calForKey(key) {
  return calTable.get(srcLabelForKey(key), baseSigForKey(key));
}

// The unit to show: the calibration override when set, else the streamer's.
function unitForKey(key) {
  const cal = calForKey(key);
  if (cal.unit) return cal.unit;
  const meta = findSignalMeta(key);
  return (meta && meta.unit) || '';
}

// Allocate a calibrated copy of a raw array. Returns the input untouched when
// the signal is uncalibrated, so the common case costs nothing.
function calibrateArray(key, rawY) {
  const cal = calForKey(key);
  if (cal.scale === 1 && cal.offset === 0) return rawY;
  const out = new Float64Array(rawY.length);
  for (let i = 0; i < rawY.length; i++) {
    const v = rawY[i];
    out[i] = (v == null || !isFinite(v)) ? NaN : v * cal.scale + cal.offset;
  }
  return out;
}

findSignalMeta matches on the full signal name (s.name === key.slice(colon+1)), so for an element key like s1:Adc[3] it returns null and unitForKey falls back to ''. Fix that at the same time — replace the body of findSignalMeta (app.js:101-107) with:

function findSignalMeta(key) {
  const colon = key.indexOf(':');
  if (colon < 0) return null;
  const src = sourcesMap[key.slice(0, colon)];
  if (!src) return null;
  const name = key.slice(colon + 1);
  return src.signals.find(s => s.name === name)
      || src.signals.find(s => s.name === Calib.baseSignalName(name))
      || null;
}
  • Step 2: Verify the file still parses
cd Client/udpstreamer && node --check static/app.js

Expected: silent.

  • Step 3: Calibrate at the single display entry point

Replace applyVScaleNorm (app.js:186-199) with:

// Apply vscale normalization to a list of raw Y arrays (one per trace in p.traces).
// Calibration is applied first, so divValue/offset — and therefore the cursor,
// hover, ruler and Y-axis readouts derived from them — are all in calibrated
// units. Returns y_norm = (y_cal - offset) / divValue + screenPos.
function applyVScaleNorm(p, yArrays) {
  const calArrays = yArrays.map((rawY, ki) => calibrateArray(p.traces[ki], rawY));
  if (p.mode === 'digital') return applyDigitalNorm(p, calArrays);
  if (p.mode === 'mixed')   return applyMixedNorm(p, calArrays);
  return calArrays.map((y, ki) => {
    const key = p.traces[ki];
    const { divValue, offset, screenPos } = resolveVScale(p.id, key, y);
    const out = new Float64Array(y.length);
    for (let i = 0; i < y.length; i++) {
      const v = y[i];
      out[i] = (v == null || !isFinite(v)) ? NaN : (v - offset) / divValue + screenPos;
    }
    return out;
  });
}

applyDigitalNorm and applyMixedNorm now receive calibrated arrays. Both are relative — they derive their own min/max/threshold from the array they are given — so they need no other change; a negative scale correctly inverts a digital trace's polarity.

  • Step 4: Calibrate the range-mode bounds

In resolveVScale (app.js:113-125), replace the range branch:

  if (vs.mode === 'range') {
    const meta = findSignalMeta(key);
    if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
      const divValue = niceDiv((meta.rangeMax - meta.rangeMin) / 8);
      const offset = Math.round((meta.rangeMin + meta.rangeMax) / 2 / divValue) * divValue;
      vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
      return { divValue, offset, screenPos };
    }
    // Fall through to auto if no range
  }

with:

  if (vs.mode === 'range') {
    const meta = findSignalMeta(key);
    if (meta && meta.rangeMin != null && meta.rangeMax != null && meta.rangeMax > meta.rangeMin) {
      // rangeMin/rangeMax come from the streamer CONFIG in raw units and never
      // pass through applyVScaleNorm, so calibrate them here. calRange re-orders
      // the pair, which a negative scale would otherwise swap.
      const [lo, hi] = Calib.calRange(meta.rangeMin, meta.rangeMax, calForKey(key));
      const divValue = niceDiv((hi - lo) / 8);
      const offset = Math.round((lo + hi) / 2 / divValue) * divValue;
      vs._resolvedDiv = divValue; vs._resolvedOffset = offset;
      return { divValue, offset, screenPos };
    }
    // Fall through to auto if no range
  }
  • Step 5: Add the WebSocket senders and the change hook

In Client/udpstreamer/static/app.js, replace saveSourcesWS (app.js:3339-3343) with:

function saveConfigWS() {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'saveSources' }));
  }
}

function reloadConfigWS() {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'reloadConfig' }));
  }
}

function setCalibrationWS(source, signal, scale, offset, unit) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'setCalibration', source, signal, scale, offset, unit }));
  }
}

saveSourcesWS had one caller, saveBtn in makeAddSourceSection (app.js:3387); Task 10 replaces that whole section. Until then, point it at the new name so the build stays green — change

  saveBtn.addEventListener('click', saveSourcesWS);

to

  saveBtn.addEventListener('click', saveConfigWS);

Then add, immediately after setCalibrationWS:

// Called after the calibration table changes, from any source (local edit,
// hub broadcast, or reload). Mirrors to localStorage and re-renders everything
// that shows a value or a unit.
function applyCalibrationChanged() {
  persistCalibration();
  buildSidebar();                       // unit badges
  plots.forEach(p => { p.needsRedraw = true; });
  if (typeof refreshVScaleMenu === 'function') refreshVScaleMenu();  // Task 8
  if (typeof sendTrigConfig === 'function') sendTrigConfig();        // Task 9
}

// Replaced in Task 10 with the Sources & Config status renderer.
function onConfigAck(msg) { /* no-op until Task 10 */ }
  • Step 6: Dispatch the three new frames

In the ws.onmessage handler (app.js:364-375), add three cases after the monotonicState line:

    else if (msg.type === 'monotonicState') onMonotonicState(msg);
    else if (msg.type === 'calibration') onCalibration(msg);
    else if (msg.type === 'configSaved' || msg.type === 'configReloaded') onConfigAck(msg);

and add the handler next to onSources (app.js:3301):

// The hub's calibration table is authoritative: replace ours wholesale.
function onCalibration(msg) {
  calTable.replaceAll(msg.cal || []);
  applyCalibrationChanged();
}
  • Step 7: Verify and smoke-test
cd Client/udpstreamer && node --check static/app.js && node --check static/calibration.js

Expected: silent.

cd Client/udpstreamer && rm -rf /tmp/calsmoke && mkdir /tmp/calsmoke
cat > /tmp/calsmoke/cfg.json <<'EOF'
[
  {"source": "wave", "signal": "Adc", "scale": 2, "offset": 10, "unit": "kV"}
]
EOF
go run . -port 8099 -sources-file /tmp/calsmoke/cfg.json

Open http://127.0.0.1:8099, then in the browser console run:

calTable.list()

Expected: [{source: 'wave', signal: 'Adc', scale: 2, offset: 10, unit: 'kV'}] — proving the hub broadcast a calibration frame on connect and the SPA absorbed it. Then reload the page with the hub stopped and re-run calTable.list(): the same entry must come back from the localStorage mirror.

Stop the hub with Ctrl-C.

  • Step 8: Commit
git add Client/udpstreamer/static/app.js
git commit -m "webui: apply per-signal calibration to the whole display path"

Task 8: Calibration editor in the V-Scale toolbar

The V-Scale toolbar (#vscale-menu) is already opened by clicking a signal in a plot, and it is where V/div and Offset live. The calibration editor goes there too, as a separate group in the same header row, visually divided from the display-scale controls so the distinction stays legible:

V-Scale: Adc[3]  [Auto][Range][Manual]  │  Cal (Adc, 8 elem)  Scale [1]  Offset [0]  Unit [V]  [Reset]  ✕

The header names the base signal and its element count, because the toolbar can be opened on a single element (Adc[3]) while the edit affects all of them.

Files:

  • Modify: Client/udpstreamer/static/index.html:187-216 (the #vscale-menu block)
  • Modify: Client/udpstreamer/static/style.css:388 (after .plot-vscale-bar)
  • Modify: Client/udpstreamer/static/app.jsshowVScaleMenu (app.js:3412-3467), initVScaleMenu (app.js:3524-3585)

Interfaces:

  • Consumes: Calib, calTable, calForKey, srcLabelForKey, baseSigForKey, setCalibrationWS, applyCalibrationChanged from Tasks 6 and 7; _vsMenuKey / _vsMenuPlotId (app.js:3410), findSignalMeta, numElements (app.js:504), refreshPlotForKey (app.js:243).

  • Produces: refreshVScaleMenu() → void — re-reads the calibration fields from the table; already called speculatively by applyCalibrationChanged (Task 7).

  • Step 1: Add the markup

In Client/udpstreamer/static/index.html, inside .vstb-header, insert the calibration group between the #vscale-type-row block (ends line 213) and the close button (line 214):

        <div class="vstb-sep"></div>
        <div id="vscale-cal-row" style="display:flex;align-items:center;gap:4px">
          <label class="vstb-lbl" id="vscale-cal-lbl"
                 title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
          <label class="vstb-lbl">Scale</label>
          <input type="number" id="vscale-cal-scale" class="ctx-num ctx-num-sm" step="any" value="1">
          <label class="vstb-lbl">Offset</label>
          <input type="number" id="vscale-cal-offset" class="ctx-num ctx-num-sm" step="any" value="0">
          <label class="vstb-lbl">Unit</label>
          <input type="text" id="vscale-cal-unit" class="ctx-num ctx-num-xs" maxlength="16" placeholder="—">
          <button class="ctx-btn" id="btn-cal-reset" title="Clear this signal's calibration">Reset</button>
        </div>
  • Step 2: Add the styles

In Client/udpstreamer/static/style.css, after .plot-vscale-bar { display:none; } (line 388), add:

.vstb-sep { width:1px; height:16px; background:var(--surface1); flex-shrink:0; }
.ctx-num-sm { width:70px; }
.ctx-num-xs { width:46px; }
#vscale-cal-lbl { color:var(--mauve); font-weight:600; }
.cal-invalid { border-color:var(--red) !important; }
  • Step 3: Populate the fields when the toolbar opens

In Client/udpstreamer/static/app.js, add refreshVScaleMenu immediately before showVScaleMenu (app.js:3412):

// Re-read the calibration fields from calTable for the currently open toolbar.
// Safe to call when the toolbar is closed.
function refreshVScaleMenu() {
  if (!_vsMenuKey) return;
  const cal = calForKey(_vsMenuKey);
  const base = baseSigForKey(_vsMenuKey);
  const meta = findSignalMeta(_vsMenuKey);
  const n = meta ? numElements(meta) : 1;
  const lbl = document.getElementById('vscale-cal-lbl');
  lbl.textContent = n > 1 ? 'Cal (' + base + ', ' + n + ' elem)' : 'Cal (' + base + ')';
  const scaleEl = document.getElementById('vscale-cal-scale');
  const offsetEl = document.getElementById('vscale-cal-offset');
  const unitEl = document.getElementById('vscale-cal-unit');
  // Skip the field the user is currently typing in, so a hub broadcast does not
  // yank the caret out from under them.
  const focused = document.activeElement;
  if (focused !== scaleEl) scaleEl.value = cal.scale;
  if (focused !== offsetEl) offsetEl.value = cal.offset;
  if (focused !== unitEl) unitEl.value = cal.unit;
  [scaleEl, offsetEl, unitEl].forEach(el => el.classList.remove('cal-invalid'));
  const srcLabel = srcLabelForKey(_vsMenuKey);
  const usable = srcLabel !== '' && base !== '';
  [scaleEl, offsetEl, unitEl, document.getElementById('btn-cal-reset')]
    .forEach(el => { el.disabled = !usable; });
}

Then, in showVScaleMenu, add a call just before the "Move the toolbar div into this plot's vscale bar" comment (app.js:3461):

  refreshVScaleMenu();

  // Move the toolbar div into this plot's vscale bar.
  • Step 4: Wire the handlers

In initVScaleMenu (app.js:3524-3585), insert before the final line document.getElementById('btn-vscale-close').addEventListener('click', hideVScaleMenu);:

  // ── Calibration ───────────────────────────────────────────────────────
  // Commit the three fields as one entry. Validation mirrors the hub exactly
  // (Calib.normaliseCal); an invalid value marks the field and is not sent, so
  // the last accepted value stays in force.
  function commitCal() {
    if (!_vsMenuKey) return;
    const scaleEl = document.getElementById('vscale-cal-scale');
    const offsetEl = document.getElementById('vscale-cal-offset');
    const unitEl = document.getElementById('vscale-cal-unit');
    const source = srcLabelForKey(_vsMenuKey);
    const signal = baseSigForKey(_vsMenuKey);
    const entry = Calib.normaliseCal({
      source, signal,
      scale: parseFloat(scaleEl.value),
      offset: parseFloat(offsetEl.value),
      unit: unitEl.value,
    });
    const scaleBad = entry === null && !(isFinite(parseFloat(scaleEl.value)) && parseFloat(scaleEl.value) !== 0);
    scaleEl.classList.toggle('cal-invalid', scaleBad);
    offsetEl.classList.toggle('cal-invalid', entry === null && !isFinite(parseFloat(offsetEl.value)));
    if (entry === null) return;
    calTable.set(entry);
    setCalibrationWS(entry.source, entry.signal, entry.scale, entry.offset, entry.unit);
    applyCalibrationChanged();
  }

  document.getElementById('vscale-cal-scale').addEventListener('change', commitCal);
  document.getElementById('vscale-cal-offset').addEventListener('change', commitCal);
  document.getElementById('vscale-cal-unit').addEventListener('change', commitCal);
  document.getElementById('btn-cal-reset').addEventListener('click', () => {
    if (!_vsMenuKey) return;
    const source = srcLabelForKey(_vsMenuKey);
    const signal = baseSigForKey(_vsMenuKey);
    if (!source || !signal) return;
    calTable.set({ source, signal, scale: 1, offset: 0, unit: '' });
    setCalibrationWS(source, signal, 1, 0, '');
    applyCalibrationChanged();
    refreshVScaleMenu();
  });

change rather than input: a partially-typed number like - or 1e would otherwise be rejected on every keystroke and paint the field red while the user is still typing.

applyCalibrationChanged (Task 7) calls refreshVScaleMenu, so a manual call is needed only in the Reset handler, where the fields themselves must be rewritten while one of them may hold focus.

  • Step 5: Verify it parses
cd Client/udpstreamer && node --check static/app.js

Expected: silent.

  • Step 6: Manual test
cd Client/udpstreamer && rm -rf /tmp/caledit && mkdir /tmp/caledit
go run . -port 8099 -sources-file /tmp/caledit/cfg.json

In another terminal, start a streamer so there is live data:

source env.sh && ./run_streamhub.sh --no-hub 2>/dev/null || \
  ./Build/x86-linux/GTest/MainGTest.ex --gtest_filter='UDPStreamer*' >/dev/null

(Any producer sending UDPS to 127.0.0.1:44500 will do; add it in the browser's Add Source box as 127.0.0.1:44500.)

Then in the browser:

  1. Drag a signal onto a plot and click it to open the V-Scale toolbar.
  2. Set Scale = 2, Offset = 100. The trace must keep its shape while the Y-axis tick values double and shift by 100; the hover readout and the cursor readout must agree with the new axis.
  3. Set Unit = kV. The sidebar badge for that signal must change to kV (Task 9 adds the hover-readout unit).
  4. Open a second browser tab. It must show the same Scale/Offset/Unit — proving the hub broadcast reached it.
  5. Press Reset. Both tabs must return to 1 / 0 / —.
  6. Type 0 into Scale. The field must turn red and the plot must not change.
  7. Open the toolbar on an array element (Adc[3]). The Cal label must read Cal (Adc, N elem) and an edit must move every element of the array.
  • Step 7: Commit
git add Client/udpstreamer/static/index.html Client/udpstreamer/static/style.css Client/udpstreamer/static/app.js
git commit -m "webui: add calibration editor to the V-Scale toolbar"

Task 9: The three paths that bypass the display transform

Task 7 covered everything that flows through applyVScaleNorm. Three things do not, and each needs explicit handling:

  1. CSV export (app.js:2871) formats ring/history/snapshot values directly.
  2. Trigger threshold — the hub compares against raw samples, so the number the user types in calibrated units must be inverted before it is sent and re-applied when it is displayed. (The V2 capture frame arrives raw and flows through the normal display path, so it needs nothing.)
  3. Unit display — the sidebar badge and the hover readout.

Files:

  • Modify: Client/udpstreamer/static/app.jsexportAllCSV (app.js:2871-2970), sendTrigConfig (app.js:636-642), the #trig-threshold handler (app.js:2481), buildTrigSignalSelect (app.js:2517), showHoverReadout (app.js:2338-2360), the sidebar signal rendering (app.js:2586-2606).

Interfaces:

  • Consumes: Calib, calForKey, unitForKey, srcLabelForKey, baseSigForKey from Tasks 6 and 7.

  • Produces: refreshTrigThresholdField() → void — rewrites #trig-threshold from trig.threshold; called from applyCalibrationChanged.

  • Step 1: Calibrate the CSV export

In exportAllCSV, replace the header/rows block near the end (app.js:2957-2962):

  // Strip "sourceId:" prefix from column headers for readability.
  const displayKeys = keys.map(k => (k.includes(':') ? k.split(':').slice(1).join(':') : k));
  const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(',');
  const rows = sortedT.map(t =>
    [t.toFixed(9), ...lookups.map(lk => (lk.has(t) ? lk.get(t) : ''))].join(',')
  );

with:

  // Strip "sourceId:" prefix from column headers for readability, and append
  // the effective unit. These values come straight from the ring/history/
  // snapshot and never pass through applyVScaleNorm, so calibrate them here.
  const cals = keys.map(k => calForKey(k));
  const displayKeys = keys.map(k => {
    const name = k.includes(':') ? k.split(':').slice(1).join(':') : k;
    const u = unitForKey(k);
    return u ? name + ' [' + u + ']' : name;
  });
  const hdr = [(inTrigMode ? 'time_rel_s' : 'time_s'), ...displayKeys].join(',');
  const rows = sortedT.map(t =>
    [t.toFixed(9), ...lookups.map((lk, i) =>
      lk.has(t) ? Calib.applyCal(lk.get(t), cals[i]) : '')].join(',')
  );

A unit containing a comma would break the CSV. Calib.normaliseCal does not forbid one, so strip it at the source instead — in Client/udpstreamer/static/calibration.js, inside normaliseCal, change:

    var unit = String(obj.unit == null ? '' : obj.unit).trim();

to:

    // Commas and quotes would corrupt the CSV export header; drop them here so
    // every consumer sees an already-safe unit.
    var unit = String(obj.unit == null ? '' : obj.unit).replace(/[",]/g, '').trim();

and add a case to Client/udpstreamer/test/calibration.test.js:

test('normaliseCal strips characters that would corrupt a CSV header', () => {
  assert.strictEqual(C.normaliseCal({source: 'w', signal: 's', unit: 'k,V"'}).unit, 'kV');
});
cd Client/udpstreamer && node --test test/

Expected: PASS — # pass 15, # fail 0.

  • Step 2: Invert the trigger threshold

trig.threshold stays in calibrated units everywhere in the SPA; only the wire value is raw. Replace sendTrigConfig (app.js:636-642):

function sendTrigConfig() {
  wsSend({
    type: 'setTrigger', signal: trig.signal, edge: trig.edge,
    threshold: trig.threshold, windowSec: trig.windowSec,
    prePercent: trig.prePercent, mode: trig.mode,
  });
}

with:

// trig.threshold is held in calibrated units. The hub's comparator runs on raw
// samples, so invert on the way out: raw = (calibrated - offset) / scale.
function sendTrigConfig() {
  const cal = trig.signal ? calForKey(trig.signal) : Calib.IDENTITY;
  wsSend({
    type: 'setTrigger', signal: trig.signal, edge: trig.edge,
    threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec,
    prePercent: trig.prePercent, mode: trig.mode,
  });
}

// Rewrite the threshold input and its unit hint from trig.threshold.
function refreshTrigThresholdField() {
  const el = document.getElementById('trig-threshold');
  if (document.activeElement !== el) el.value = trig.threshold;
  const u = trig.signal ? unitForKey(trig.signal) : '';
  el.title = u ? 'Threshold in ' + u : 'Threshold in the signal\u2019s raw units';
}
  • Step 3: Keep the threshold field in sync

In Client/udpstreamer/static/app.js, in applyCalibrationChanged (added in Task 7 Step 5), replace:

  if (typeof sendTrigConfig === 'function') sendTrigConfig();        // Task 9

with:

  // The threshold is held in calibrated units, so a calibration change alters
  // the raw value the hub must compare against — resend it.
  if (trig.signal) { refreshTrigThresholdField(); sendTrigConfig(); }

And in buildTrigSignalSelect (app.js:2517), append before the closing brace, after the if (curBase && …) sel.value = curBase; line:

  refreshTrigThresholdField();

so selecting a different signal updates the unit hint.

  • Step 4: Show the unit in the hover readout

In showHoverReadout, replace the trace loop body (app.js:2350-2358):

  p.traces.forEach((key, idx) => {
    const vNorm = interpAtTime(p.uplot, idx + 1, t);
    const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
    const val = vNorm === null ? '—' : _fmtVal(rawFromNorm(p, key, vNorm));

with:

  p.traces.forEach((key, idx) => {
    const vNorm = interpAtTime(p.uplot, idx + 1, t);
    const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
    // rawFromNorm inverts the vscale transform, which Task 7 made operate on
    // calibrated values — so this is already in calibrated units.
    const unit = unitForKey(key);
    const val = vNorm === null ? '—'
      : (_fmtVal(rawFromNorm(p, key, vNorm)) + (unit ? ' ' + unit : ''));
  • Step 5: Show the override unit in the sidebar

In the sidebar rendering (app.js:2586-2606), the streamer's sig.unit is used in three places. Replace them with the effective unit. Inside the sigs.forEach(sig => { block, after const globalKey = prefix + sig.name;, add:

      const effUnit = unitForKey(globalKey);

Then change the three uses:

        grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, sig.unit || ''));

        grp.appendChild(makeDraggable(globalKey, sig.name, temporal ? '[' + n + '] ' + typeName : typeName, effUnit));
          + (sig.unit ? '<span class="sig-unit">' + escHtml(sig.unit) + '</span>' : '')

          + (effUnit ? '<span class="sig-unit">' + escHtml(effUnit) + '</span>' : '')
          const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, sig.unit || '');

          const child = makeDraggable(key, sig.name + '[' + i + ']', typeName, effUnit);

All array elements share one calibration, so effUnit computed once from globalKey is correct for every child.

  • Step 6: Verify
cd Client/udpstreamer && node --check static/app.js && node --check static/calibration.js && node --test test/

Expected: the two checks silent; # pass 15, # fail 0.

  • Step 7: Manual test

Start a hub with a live source (as in Task 8 Step 6), then:

  1. Set Scale = 2, Offset = 100, Unit = kV on a signal.
  2. Press ⬇ CSV. The downloaded file's header column must read Adc [kV] and its values must be 2 × raw + 100. Cross-check one row against the hover readout at the same timestamp — they must match.
  3. Select that signal as the trigger source, set a threshold inside the calibrated range (e.g. 100 when raw hovers around 0), and arm. The trigger must fire, and the captured trace must cross the threshold line at the level shown on the Y-axis.
  4. With the trigger armed, change Scale to 4. The trigger must keep firing at the same calibrated threshold — i.e. the hub's raw comparison point halves — confirming the resend in Step 3.
  5. Press Reset. The threshold field, the CSV header and the sidebar badge must all return to the streamer's own unit.
  • Step 8: Commit
git add Client/udpstreamer/static/app.js Client/udpstreamer/static/calibration.js Client/udpstreamer/test/calibration.test.js
git commit -m "webui: calibrate CSV export, trigger threshold and unit display"

Task 10: "Sources & Config" sidebar section

The collapsible "Add Source" section at the bottom of the sidebar becomes "Sources & Config". Its address/label/multicast inputs and Connect button are unchanged; the fire-and-forget "Save list" button is replaced by Save and Reload side by side, plus a one-line status area that renders the configSaved / configReloaded acknowledgements from Tasks 3 and 4.

Files:

  • Modify: Client/udpstreamer/static/app.jsmakeAddSourceSection (app.js:3345-3398), onConfigAck (the stub added in Task 7 Step 5)
  • Modify: Client/udpstreamer/static/style.css:475 (after .save-src-btn:hover)

Interfaces:

  • Consumes: saveConfigWS, reloadConfigWS, onConfigAck from Task 7.
  • Produces: nothing consumed by a later task.

The section is rebuilt by buildSidebar() on every sources broadcast, so the status line cannot live in a DOM node that buildSidebar discards — it is held in a module-level variable and re-rendered each time the section is built.

  • Step 1: Add the styles

In Client/udpstreamer/static/style.css, after .save-src-btn:hover { … } (line 475), add:

.cfg-btn-row { display:flex; gap:6px; }
.cfg-btn-row .add-src-btn { flex:1; }
.reload-src-btn { color:var(--peach); }
.reload-src-btn:hover { background:rgba(250,179,135,0.1); border-color:var(--peach); }
.cfg-status {
  font-size:10px; line-height:1.3; padding:2px 0; min-height:13px;
  overflow-wrap:anywhere;
}
.cfg-status.ok { color:var(--green); }
.cfg-status.err { color:var(--red); }
  • Step 2: Rewrite the section

In Client/udpstreamer/static/app.js, replace makeAddSourceSection (app.js:3345-3398) in full with:

// Last config acknowledgement, kept outside the DOM because buildSidebar()
// discards and recreates this whole section on every `sources` broadcast.
let _cfgStatus = null;   // {ok: bool, text: string} or null

function renderCfgStatus(el) {
  el.className = 'cfg-status';
  if (!_cfgStatus) { el.textContent = ''; return; }
  el.classList.add(_cfgStatus.ok ? 'ok' : 'err');
  el.textContent = _cfgStatus.text;
}

function onConfigAck(msg) {
  const what = msg.type === 'configSaved' ? 'Saved' : 'Reloaded';
  if (msg.ok) {
    _cfgStatus = { ok: true, text: what + ': ' + (msg.path || 'config file') };
  } else {
    _cfgStatus = { ok: false, text: what + ' failed: ' + (msg.error || 'unknown error') };
  }
  const el = document.getElementById('cfg-status');
  if (el) renderCfgStatus(el);
}

function makeSourcesConfigSection() {
  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> Sources &amp; Config';

  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 mcastInput = document.createElement('input');
  mcastInput.className = 'add-src-input'; mcastInput.type = 'text';
  mcastInput.placeholder = 'multicast group (e.g. 239.0.0.1, optional)';

  const dataPortInput = document.createElement('input');
  dataPortInput.className = 'add-src-input'; dataPortInput.type = 'number';
  dataPortInput.placeholder = 'data port (multicast only)';
  dataPortInput.min = '1'; dataPortInput.max = '65535';

  const addBtn = document.createElement('button');
  addBtn.className = 'add-src-btn'; addBtn.textContent = 'Connect';
  addBtn.addEventListener('click', () => {
    const addr = addrInput.value.trim(); if (!addr) return;
    const mcastGroup = mcastInput.value.trim();
    const dataPort = dataPortInput.value ? parseInt(dataPortInput.value, 10) : 0;
    addSourceWS(labelInput.value.trim(), addr, mcastGroup, dataPort);
    addrInput.value = ''; labelInput.value = ''; mcastInput.value = ''; dataPortInput.value = '';
  });
  addrInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });

  const btnRow = document.createElement('div');
  btnRow.className = 'cfg-btn-row';

  const saveBtn = document.createElement('button');
  saveBtn.className = 'add-src-btn save-src-btn';
  saveBtn.textContent = 'Save';
  saveBtn.title = 'Write the source list and all signal calibration to the hub\u2019s config file';
  saveBtn.addEventListener('click', () => {
    _cfgStatus = null;
    const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
    saveConfigWS();
  });

  const reloadBtn = document.createElement('button');
  reloadBtn.className = 'add-src-btn reload-src-btn';
  reloadBtn.textContent = 'Reload';
  reloadBtn.title = 'Re-read the config file: calibration is replaced wholesale, '
                  + 'missing sources are added, and no running source is stopped';
  reloadBtn.addEventListener('click', () => {
    _cfgStatus = null;
    const el = document.getElementById('cfg-status'); if (el) renderCfgStatus(el);
    reloadConfigWS();
  });

  btnRow.append(saveBtn, reloadBtn);

  const status = document.createElement('div');
  status.id = 'cfg-status';
  renderCfgStatus(status);

  body.append(addrInput, labelInput, mcastInput, dataPortInput, addBtn, btnRow, status);
  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;
}

The stub function onConfigAck(msg) { /* no-op until Task 10 */ } added in Task 7 Step 5 is now superseded — delete it, keeping only the version above.

  • Step 3: Update the caller

makeAddSourceSection had one caller, at the end of buildSidebar (app.js:2618). Change:

  list.appendChild(makeAddSourceSection());

to:

  list.appendChild(makeSourcesConfigSection());
  • Step 4: Verify no stale references remain
cd Client/udpstreamer && grep -n "makeAddSourceSection\|saveSourcesWS" static/app.js; node --check static/app.js

Expected: grep prints nothing (exit status 1) and node --check is silent.

  • Step 5: Manual test against the Go hub
cd Client/udpstreamer && rm -rf /tmp/cfgui && mkdir /tmp/cfgui
go run . -port 8099 -sources-file /tmp/cfgui/cfg.json

In the browser at http://127.0.0.1:8099:

  1. Expand "Sources & Config". Add a source, set a calibration on one of its signals, press Save. The status line must turn green and read Saved: /tmp/cfgui/cfg.json. Confirm with cat /tmp/cfgui/cfg.json that both the source and the calibration object are present.
  2. Change the calibration to something else without saving, then press Reload. The status line must read Reloaded: /tmp/cfgui/cfg.json and the calibration must snap back to the saved value while the live source keeps streaming without a gap in the plot.
  3. Stop the hub and restart it without -sources-file. Press Save. The status line must turn red with the hub's error text.
  • Step 6: Manual test against the C++ StreamHub
source env.sh
rm -rf /tmp/cfguicpp && mkdir -p /tmp/cfguicpp
cat > /tmp/cfguicpp/hub.cfg <<'EOF'
+Hub = {
    Class = StreamHub
    WSPort = 8098
    MaxPoints = 2000
    SourcesFile = "/tmp/cfguicpp/cfg.json"
}
EOF
./Build/x86-linux/Applications/StreamHub/StreamHub.ex -cfg /tmp/cfguicpp/hub.cfg

The C++ StreamHub serves no static files, so point the browser at the Go hub's page and override the WebSocket target, or simply open the page from a Go hub started on a different port and connect the browser's WebSocket manually. The quickest check is the Task 5 parity checker, which already covers this pair — run it and confirm the behaviours above match:

./Test/E2E/suite/client/configcheck/configcheck -url ws://127.0.0.1:8098/ws

Expected: configcheck OK.

  • Step 7: Commit
git add Client/udpstreamer/static/app.js Client/udpstreamer/static/style.css
git commit -m "webui: replace Add Source with Sources & Config save/reload panel"

Task 11: Documentation

Three documents describe the surfaces this feature changed. Update them last, so the wording matches what was actually built.

Files:

  • Modify: Docs/StreamHub-API.md:44-50 (saveSources), :125 (after setMaxPoints), :216 (after maxPointsUpdated), :273 (Limits table)
  • Modify: Docs/WebUI.md:133-149 (V-Scale Toolbar), :73-83 (Signal Sidebar)
  • Modify: ARCHITECTURE.md:371-405 (§6 command and event tables)

Interfaces:

  • Consumes: the final behaviour of Tasks 1-10.

  • Produces: nothing.

  • Step 1: Update Docs/StreamHub-API.md — commands

Replace the saveSources section (Docs/StreamHub-API.md:44-50):

### `saveSources`

```json
{"type":"saveSources"}
```
Persists the current dynamically-added source list to the hub's `SourcesFile`
(JSON array of `{label,addr,multicastGroup,dataPort}`); it is reloaded at startup.

with:

### `saveSources`

```json
{"type":"saveSources"}
```
Writes the hub's `SourcesFile`: the current dynamically-added source list **and**
the calibration table, as one flat JSON array (see [§5](#5-config-file-format)).
The hub replies with [`configSaved`](#configsaved). Despite the name, this
command persists the whole config, not just the sources.

### `setCalibration`

```json
{"type":"setCalibration","source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"}
```
Records an affine calibration `value = raw × scale + offset` for one signal,
keyed by the source's **label** (not its runtime id) and the **base** signal
name — one entry covers every element of an array signal.

| Field | Type | Default | Validation |
|---|---|---|---|
| `source` | string | — | non-empty after trimming |
| `signal` | string | — | non-empty after trimming; any trailing `[i]` is stripped |
| `scale` | number | `1` | finite and non-zero |
| `offset` | number | `0` | finite |
| `unit` | string | `""` | trimmed, truncated to 16 chars; empty = use the streamer's own unit |

Calibration is **metadata only**: the hub stores and redistributes it but never
applies it. Ring buffers, recorded history, the `zoom` reply, both binary frames
and the trigger comparator all stay in raw units — a client that ignores
calibration behaves exactly as before.

An entry that reduces to the identity (`scale = 1`, `offset = 0`, `unit = ""`) is
**deleted** rather than stored, so a reset leaves no residue in the config file.

On acceptance the hub broadcasts [`calibration`](#calibration) to every client. A
rejected entry produces **no** broadcast, so the offending client reverts to the
last value it was told.

### `reloadConfig`

```json
{"type":"reloadConfig"}
```
Re-reads `SourcesFile` and then:

- **replaces** the calibration table wholesale with the file's contents;
- **adds** any source in the file that is not already active;
- **never** removes, restarts or reconnects a live source.

The asymmetry is deliberate: calibration is cheap to reapply, whereas a source is
a live UDP session that must not be interrupted. An unsaved source the user added
keeps streaming.

The hub replies with [`configReloaded`](#configreloaded), followed on success by a
`calibration` broadcast and a `sources` broadcast.
  • Step 2: Update Docs/StreamHub-API.md — events

After the maxPointsUpdated section (Docs/StreamHub-API.md:216-222), add:

### `calibration`

```json
{"type":"calibration","cal":[
  {"source":"wave","signal":"Adc","scale":0.00030518,"offset":-1.25,"unit":"V"}
]}
```
The complete calibration table. Broadcast when a client connects (as an empty
array when nothing is calibrated), after every accepted `setCalibration`, and
after a successful `reloadConfig`. It is a separate frame rather than a field on
`sources` because `sources` is serialised into a fixed 4 KiB buffer.

### `configSaved`

```json
{"type":"configSaved","ok":true,"path":"/etc/streamhub/sources.json"}
{"type":"configSaved","ok":false,"path":"","error":"no SourcesFile configured"}
```
Broadcast in reply to `saveSources`. `path` is always present (empty when the hub
has no config file configured); `error` only when `ok` is false.

### `configReloaded`

```json
{"type":"configReloaded","ok":true,"path":"/etc/streamhub/sources.json"}
{"type":"configReloaded","ok":false,"path":"/etc/streamhub/sources.json","error":"cannot read sources file"}
```
Broadcast in reply to `reloadConfig`; same shape as `configSaved`. On success it
is followed by a `calibration` broadcast and, if the file added any source, a
`sources` broadcast.
  • Step 3: Add the config file format section to Docs/StreamHub-API.md

Before ## 4. Limits (Docs/StreamHub-API.md:273), insert a new section, and renumber ## 4. Limits to ## 5. Limits:

## 4. Config file format

`SourcesFile` (C++ `SourcesFile` config key, Go `-sources-file` flag) is a flat
JSON array of flat objects. A block containing `addr` is a source; a block
containing `signal` is a calibration entry; anything else is skipped with a
warning.

```json
[
  {"label": "wave", "addr": "127.0.0.1:44500"},
  {"label": "mc",   "addr": "127.0.0.1:44501", "multicastGroup": "239.0.0.1", "dataPort": 44502},
  {"source": "wave", "signal": "Adc", "scale": 0.00030518, "offset": -1.25, "unit": "V"}
]
```

**Every object must stay flat.** The C++ `StreamHub::LoadSourcesFile` parser
takes each `{` up to the next `}` as one object, so a nested object anywhere in
the file would truncate the parse at the inner brace. A nested
`"calibration": {…}` inside a source entry is therefore not an option, and this
is why calibration entries are siblings of sources rather than children.

Files written by hub versions predating calibration load unchanged, and a file
written by either hub loads in the other.
  • Step 4: Update ARCHITECTURE.md §6

In the "Commands (client → hub)" table (ARCHITECTURE.md:378-393), change the saveSources row:

| `saveSources`    | — | Persist the current dynamic source list to `SourcesFile` (JSON) |

to:

| `saveSources`    | — | Persist the dynamic source list **and** the calibration table to `SourcesFile`; replies `configSaved` |
| `setCalibration` | `source` (label), `signal` (base name), `scale`, `offset`, `unit` | Record `value = raw × scale + offset` for one signal; metadata only, the hub never applies it. Identity entries are deleted. Replies with a `calibration` broadcast |
| `reloadConfig`   | — | Re-read `SourcesFile`: calibration replaced wholesale, missing sources added, live sources never touched; replies `configReloaded` |

In the "Events (hub → client)" table (ARCHITECTURE.md:397-407), add after the maxPointsUpdated row:

| `calibration`      | `cal:[{source, signal, scale, offset, unit}]` | On connect; after an accepted `setCalibration`; after a successful `reloadConfig` |
| `configSaved`      | `ok`, `path`, `error?` | In reply to `saveSources` |
| `configReloaded`   | `ok`, `path`, `error?` | In reply to `reloadConfig` |

Then, immediately before ### Binary Push Frame (version 1, …) (ARCHITECTURE.md:409), insert:

### Config File Format

`SourcesFile` is a flat JSON array of flat objects; `addr` marks a source,
`signal` marks a calibration entry.

```json
[
  {"label": "wave", "addr": "127.0.0.1:44500"},
  {"source": "wave", "signal": "Adc", "scale": 0.00030518, "offset": -1.25, "unit": "V"}
]
```

Flatness is a hard constraint: `StreamHub::LoadSourcesFile` scans from each `{`
to the next `}`, so a nested object would truncate the parse. Both hubs read and
write this format identically, and pre-calibration files load unchanged.

Calibration is applied **client-side only**. Rings, history, `zoom` replies, both
binary frames and the trigger comparator are all in raw units.
  • Step 5: Update Docs/WebUI.md

In the V-Scale Toolbar table (Docs/WebUI.md:138-147), add three rows before the row:

| **Cal · Scale** | Data calibration gain. `value = raw × Scale + Offset` |
| **Cal · Offset** | Data calibration bias, in calibrated units |
| **Cal · Unit** | Overrides the unit reported by the streamer (max 16 chars) |
| **Reset** | Clears this signal's calibration (`Scale = 1`, `Offset = 0`, no unit override) |

and add this paragraph after the table's trailing "Offset markers…" note (Docs/WebUI.md:148-149):

**Calibration vs. V/div and Offset.** They are different things. V/div and Offset
are a *display* transform: they move and stretch the trace on screen. Calibration
changes *the value itself* — the plot, the Y-axis tick labels, the cursor and
hover readouts, the CSV export and the trigger threshold all report
`raw × Scale + Offset` in the calibrated unit. V/div is then read as "calibrated
units per division" and Offset as "the calibrated value at screen centre".

The calibration header names the **base** signal and its element count, because
one entry covers every element of an array — opening the toolbar on `Adc[3]` and
editing the calibration moves all of `Adc`.

Calibration is keyed by the source's **label**, is shared with every other
browser connected to the same hub, and is not persisted until you press **Save**
in the Sources & Config section. It is mirrored to `localStorage` so it survives
a page reload even against a hub with no config file.

In the Signal Sidebar section (Docs/WebUI.md:73-83), add before the "Click the sidebar toggle button" line:

The unit badge next to each signal shows the calibration's unit override when one
is set, and the streamer's own unit otherwise.

At the bottom of the sidebar, the collapsible **Sources & Config** section holds:

- the `host:port`, label, multicast group and data port inputs plus **Connect**,
  which adds a source at runtime;
- **Save** — writes the source list and the whole calibration table to the hub's
  config file;
- **Reload** — re-reads that file. Calibration is replaced wholesale (so unsaved
  edits are discarded), sources present in the file but not running are added,
  and no running source is stopped or reconnected;
- a status line showing the written path on success or the hub's error text on
  failure.
  • Step 6: Verify the docs are internally consistent
grep -n "setCalibration\|reloadConfig\|configSaved\|configReloaded" Docs/StreamHub-API.md ARCHITECTURE.md Docs/WebUI.md
grep -rn "Save list\|Add Source" Docs/ ARCHITECTURE.md

Expected: the first command lists all five frames in both Docs/StreamHub-API.md and ARCHITECTURE.md. The second prints nothing — no stale reference to the old button or section name survives.

Also confirm the section renumbering in Step 3 left no dangling links:

grep -n "^## [0-9]" Docs/StreamHub-API.md

Expected: 1. Commands, 2. Events, 3. Binary frames, 4. Config file format, 5. Limits — consecutive, no gaps.

  • Step 7: Commit
git add Docs/StreamHub-API.md Docs/WebUI.md ARCHITECTURE.md
git commit -m "docs: document per-signal calibration and config save/reload"

Final verification

Run once, after all eleven tasks are complete.

  • Step 1: Full C++ build and unit tests
source env.sh
make -f Makefile.gcc core && make -f Makefile.gcc apps && make -f Makefile.gcc test
./Build/x86-linux/GTest/MainGTest.ex

Expected: clean build, all GTest cases pass.

  • Step 2: Go tests
cd Common/Client/go && go vet ./... && go test ./...
cd ../../../Client/udpstreamer && go vet ./... && go build ./...
cd ../../Test/E2E/suite/client && go vet ./...

Expected: ok marte2/common/wshub, no vet findings, build succeeds.

  • Step 3: JS checks
cd Client/udpstreamer && node --check static/app.js && node --check static/calibration.js && node --test test/

Expected: checks silent, # fail 0.

  • Step 4: Cross-hub parity

Run the Task 5 checker against both hubs, as in Task 5 Steps 3-5. Both must print configcheck OK.

  • Step 5: E2E suite
./Test/E2E/suite/run_e2e.sh --skip-coverage

Expected: the same pass/XFAIL set as before this feature. Calibration is metadata-only on the hub side, so no scenario's waveform validation should move. Any change in validate_waveform.py fidelity is a regression to investigate, not an expected consequence.

  • Step 6: End-to-end manual pass

Start the full stack (./run_streamhub.sh), then in the browser:

  1. Set Scale, Offset and Unit on a live signal. Confirm the plot, Y-axis ticks, hover readout, cursor readout, CSV export and trigger threshold all agree.
  2. Press Save, reload the page, and confirm the calibration returns from the hub (not just from localStorage — check by clearing localStorage['udpscope.calibration'] first).
  3. Edit the calibration without saving, press Reload, and confirm the edit is discarded while the live source keeps streaming without a gap.
  4. Open a second browser tab and confirm an edit in one appears in the other.