wshub: persist calibration alongside sources; add config reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-16 19:27:23 +02:00
co-authored by Claude Sonnet 4.6
parent 66efd74dd5
commit dfd257cfd9
3 changed files with 213 additions and 12 deletions
+77 -12
View File
@@ -1,12 +1,12 @@
package wshub
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"os"
"sort"
"strconv"
"strings"
"sync"
@@ -95,11 +95,16 @@ func (sm *SourceManager) Remove(id string) {
}
}
// Save writes the current source list to filePath.
func (sm *SourceManager) Save() error {
if sm.filePath == "" {
return fmt.Errorf("no sources-file configured")
}
// 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 {
@@ -111,26 +116,86 @@ func (sm *SourceManager) Save() error {
})
}
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
}
data, err := json.MarshalIndent(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(sm.filePath, data, 0644)
return os.WriteFile(path, data, 0644)
}
// Load reads sources from path and adds them.
// 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
}
var cfgs []SourceConfig
if err := json.Unmarshal(data, &cfgs); err != nil {
srcs, cals, err := parseConfigFile(data)
if err != nil {
return err
}
sm.mu.Lock()
sm.filePath = path
for _, cfg := range cfgs {
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