Finding 1: strip trailing [digits] array-element suffix from Signal so one calibration entry covers an entire array signal, matching the C++ strchr truncation and the JS equivalent. Finding 2: after the 16-byte Unit truncation, drop any trailing partial UTF-8 rune so json.Marshal never emits replacement characters; keeps byte limit in sync with C++ strncpy(u, unit, kMaxUnitLen). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
7.2 KiB
Go
228 lines
7.2 KiB
Go
package wshub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// arrayIndexSuffix matches a trailing "[digits]" at the very end of a signal
|
|
// name, used to strip array-element suffixes so one entry covers the whole
|
|
// array. Mirrors the C++ `strchr(signal,'[')` truncation and the JS equivalent.
|
|
var arrayIndexSuffix = regexp.MustCompile(`\[\d+\]$`)
|
|
|
|
// 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)
|
|
// Strip a trailing "[digits]" suffix so one entry covers an entire array
|
|
// signal. "Adc[3]" → "Adc". Must run before the empty check below so
|
|
// that "[0]" → "" → rejected, matching C++ and JS behaviour.
|
|
c.Signal = arrayIndexSuffix.ReplaceAllString(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]
|
|
// The byte cut may land mid-rune. Drop any trailing partial rune so
|
|
// the result is always valid UTF-8; json.Marshal would otherwise emit
|
|
// replacement characters and break the save→load round-trip.
|
|
for {
|
|
r, size := utf8.DecodeLastRuneInString(c.Unit)
|
|
if r != utf8.RuneError || size != 1 {
|
|
break
|
|
}
|
|
c.Unit = c.Unit[:len(c.Unit)-1]
|
|
}
|
|
}
|
|
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, "", " ")
|
|
}
|