Phase 2/4 done, working on phase 3/5

This commit is contained in:
Martino Ferrari
2026-04-24 15:46:04 +02:00
parent 9aa89cc0cf
commit 8b548ba1c2
43 changed files with 6800 additions and 156 deletions
+185
View File
@@ -0,0 +1,185 @@
// Package stub provides a synthetic data source that emits deterministic test
// signals (sine waves, ramp, boolean toggle) without any external dependencies.
// It is used during development and for integration tests.
package stub
import (
"context"
"math"
"math/rand/v2"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const updateInterval = 100 * time.Millisecond // 10 Hz
type signalDef struct {
meta datasource.Metadata
fn func(t time.Time) any
}
// Stub is a data source that emits canned signals for development and testing.
type Stub struct {
signals map[string]signalDef
// writable values (signal name → current value)
mu sync.RWMutex
values map[string]any
}
// New returns a Stub with a predefined set of test signals.
func New() *Stub {
s := &Stub{
signals: make(map[string]signalDef),
values: make(map[string]any),
}
s.register(signalDef{
meta: datasource.Metadata{
Name: "sine_1hz", Type: datasource.TypeFloat64,
Unit: "V", DisplayLow: -1, DisplayHigh: 1,
Description: "1 Hz sine wave",
},
fn: func(t time.Time) any {
return math.Sin(2 * math.Pi * float64(t.UnixNano()) / 1e9)
},
})
s.register(signalDef{
meta: datasource.Metadata{
Name: "sine_01hz", Type: datasource.TypeFloat64,
Unit: "A", DisplayLow: -1, DisplayHigh: 1,
Description: "0.1 Hz sine wave",
},
fn: func(t time.Time) any {
return math.Sin(2 * math.Pi * 0.1 * float64(t.UnixNano()) / 1e9)
},
})
s.register(signalDef{
meta: datasource.Metadata{
Name: "ramp_10s", Type: datasource.TypeFloat64,
Unit: "mm", DisplayLow: 0, DisplayHigh: 100,
Description: "10-second sawtooth ramp, 0100",
},
fn: func(t time.Time) any {
return math.Mod(float64(t.UnixNano())/1e10, 100)
},
})
s.register(signalDef{
meta: datasource.Metadata{
Name: "toggle_1hz", Type: datasource.TypeBool,
Description: "1 Hz square wave (boolean)",
},
fn: func(t time.Time) any { return (t.Unix() % 2) == 0 },
})
s.register(signalDef{
meta: datasource.Metadata{
Name: "noise", Type: datasource.TypeFloat64,
Unit: "dB", DisplayLow: -1, DisplayHigh: 1,
Description: "White noise",
},
fn: func(t time.Time) any {
// Seed a per-tick RNG from the timestamp so results are
// reproducible within a tick but vary across ticks.
r := rand.New(rand.NewPCG(uint64(t.UnixNano()), 0))
return r.Float64()*2 - 1
},
})
s.register(signalDef{
meta: datasource.Metadata{
Name: "setpoint", Type: datasource.TypeFloat64,
Unit: "°C", DisplayLow: 0, DisplayHigh: 200,
DriveLow: 0, DriveHigh: 200,
Description: "Writable setpoint (stub)",
Writable: true,
},
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
})
s.values["setpoint"] = 25.0
return s
}
func (s *Stub) register(d signalDef) {
s.signals[d.meta.Name] = d
}
// Name implements datasource.DataSource.
func (s *Stub) Name() string { return "stub" }
// Connect is a no-op for the stub source.
func (s *Stub) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for all built-in stub signals.
func (s *Stub) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
out := make([]datasource.Metadata, 0, len(s.signals))
for _, d := range s.signals {
out = append(out, d.meta)
}
return out, nil
}
// GetMetadata returns the metadata for the named signal.
func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
d, ok := s.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return d.meta, nil
}
// Subscribe starts a 10 Hz ticker that pushes generated values into ch.
func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
def, ok := s.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(updateInterval)
defer ticker.Stop()
for {
select {
case t := <-ticker.C:
var data any
if def.meta.Writable {
s.mu.RLock()
data = s.values[signal]
s.mu.RUnlock()
} else {
data = def.fn(t)
}
v := datasource.Value{Timestamp: t, Data: data, Quality: datasource.QualityGood}
select {
case ch <- v:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write updates the current value of a writable signal.
func (s *Stub) Write(_ context.Context, signal string, value any) error {
def, ok := s.signals[signal]
if !ok {
return datasource.ErrNotFound
}
if !def.meta.Writable {
return datasource.ErrNotWritable
}
s.mu.Lock()
s.values[signal] = value
s.mu.Unlock()
return nil
}
// History is not supported by the stub source.
func (s *Stub) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}