Phase 2/4 done, working on phase 3/5
This commit is contained in:
@@ -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, 0–100",
|
||||
},
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user