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. The regexp is anchored to the end of the string and requires digits, // so it only removes a well-formed trailing element index: "Adc[3]" → "Adc", // "[0]" → "", "A[1]B" → "A[1]B" (no match). // // Known difference vs C++: the C++ hub uses strchr(signal,'[') which finds the // FIRST '[' anywhere in the name, so C++ reduces "A[1]B" to "A" while this // regexp leaves it unchanged. Both implementations agree on the common cases // ("Name[i]" and "[i]" alone) that arise from real UDPS signal names. 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, "", " ") }