Files
uopi/internal/datasource/synthetic/synthetic.go
T
2026-06-21 23:50:03 +02:00

517 lines
13 KiB
Go

package synthetic
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
)
const definitionsFile = "synthetic.json"
// signalState holds everything needed to run one synthetic signal.
type signalState struct {
def SignalDef
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
// cancel stops the goroutine driving this signal.
cancel context.CancelFunc
}
// Synthetic is a DataSource that computes values from upstream signals via
// configurable DSP pipelines.
type Synthetic struct {
brk *broker.Broker
storePath string
log *slog.Logger
mu sync.RWMutex
signals map[string]*signalState
// root context provided by Connect; used to start per-signal goroutines.
ctx context.Context
}
// New creates a Synthetic data source. storePath is the directory where
// synthetic.json is stored. brk is used to subscribe to upstream signals.
func New(storePath string, brk *broker.Broker, log *slog.Logger) *Synthetic {
return &Synthetic{
brk: brk,
storePath: storePath,
log: log,
signals: make(map[string]*signalState),
}
}
// Name implements datasource.DataSource.
func (s *Synthetic) Name() string { return "synthetic" }
// Connect loads the definitions file and starts all signal goroutines.
func (s *Synthetic) Connect(ctx context.Context) error {
s.ctx = ctx
defs, err := s.loadDefs()
if err != nil {
return fmt.Errorf("synthetic: load definitions: %w", err)
}
for _, def := range defs {
if err := s.startSignal(def); err != nil {
s.log.Warn("synthetic: failed to start signal", "name", def.Name, "err", err)
}
}
return nil
}
// ListSignals returns metadata for all defined synthetic signals.
func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
return out, nil
}
// FilteredMetadata returns metadata for every defined synthetic signal for
// which keep returns true. It lets the API layer apply per-caller visibility
// rules (which depend on SignalDef fields not present in datasource.Metadata).
func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Metadata {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
if keep(st.def) {
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
}
return out
}
// GetMetadata returns metadata for a single named signal.
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
st, ok := s.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return defToMetadata(st.def, outTypeOf(st)), nil
}
// Subscribe registers ch to receive computed values for the named signal.
// Values are pushed whenever an upstream signal changes.
func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.RLock()
st, ok := s.signals[signal]
s.mu.RUnlock()
if !ok {
return nil, datasource.ErrNotFound
}
// Collect the source node references for this signal's DAG.
refs := st.rg.sourceRefs()
if len(refs) == 0 {
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
}
// Source node ids, index-aligned with refs, so updates map to graph inputs.
srcIDs := make([]string, len(st.rg.sources))
for i, s := range st.rg.sources {
srcIDs[i] = s.id
}
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
// Latest value and timestamp per source node id.
latest := make(map[string]dsp.Sample, len(refs))
latestTs := make([]time.Time, len(refs))
ready := make([]bool, len(refs))
// Subscribe to every upstream ref via the broker.
updateCh := make(chan indexedUpdate, 32*len(refs))
unsubs := make([]func(), 0, len(refs))
for i, ref := range refs {
idx := i // capture
perCh := make(chan broker.Update, 16)
unsub, err := s.brk.Subscribe(ref, perCh)
if err != nil {
s.log.Warn("synthetic: upstream subscribe failed",
"signal", signal, "upstream", ref, "err", err)
// Continue; the slot will stay at zero.
} else {
unsubs = append(unsubs, unsub)
go func() {
for {
select {
case u, ok := <-perCh:
if !ok {
return
}
val := toSample(u.Value.Data)
select {
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
default:
}
case <-ctx.Done():
return
}
}
}()
}
}
defer func() {
for _, unsub := range unsubs {
unsub()
}
}()
for {
select {
case <-ctx.Done():
return
case upd := <-updateCh:
latest[srcIDs[upd.idx]] = upd.val
latestTs[upd.idx] = upd.ts
ready[upd.idx] = true
// Only compute once we have at least one value for every input.
allReady := true
for _, r := range ready {
if !r {
allReady = false
break
}
}
if !allReady {
continue
}
// The output is computed from the latest value of every input, so
// its timestamp is the most recent contributing sample time. Using
// the triggering update's timestamp instead would drag the output
// back in time whenever a slow/stale input fired, producing
// non-monotonic or duplicated timestamps on plots.
outTs := latestTs[0]
for _, ts := range latestTs[1:] {
if ts.After(outTs) {
outTs = ts
}
}
// Evaluate the DAG.
s.mu.RLock()
cur, stillExists := s.signals[signal]
s.mu.RUnlock()
if !stillExists {
return
}
result, err := cur.rg.evalSample(latest)
if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue
}
v := datasource.Value{
Timestamp: outTs,
Data: result.AsAny(),
Quality: datasource.QualityGood,
}
select {
case ch <- v:
case <-ctx.Done():
return
}
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write is not supported for synthetic signals.
func (s *Synthetic) Write(_ context.Context, _ string, _ any) error {
return datasource.ErrNotWritable
}
// History is not supported for synthetic signals.
func (s *Synthetic) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// AddSignal adds a new synthetic signal definition at runtime and persists it.
func (s *Synthetic) AddSignal(def SignalDef) error {
if def.Name == "" {
return errors.New("signal name must not be empty")
}
if def.Version < 1 {
def.Version = 1
}
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
if _, exists := s.signals[def.Name]; exists {
s.mu.Unlock()
return fmt.Errorf("signal %q already exists", def.Name)
}
st := &signalState{def: def, rg: rg}
s.signals[def.Name] = st
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
// Roll back the in-memory addition.
s.mu.Lock()
delete(s.signals, def.Name)
s.mu.Unlock()
return fmt.Errorf("persist definitions: %w", err)
}
return nil
}
// RemoveSignal removes a synthetic signal definition at runtime and persists.
func (s *Synthetic) RemoveSignal(name string) error {
s.mu.Lock()
st, ok := s.signals[name]
if !ok {
s.mu.Unlock()
return datasource.ErrNotFound
}
// Stop the goroutine if it is running.
if st.cancel != nil {
st.cancel()
}
delete(s.signals, name)
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
return fmt.Errorf("persist definitions: %w", err)
}
return nil
}
// GetSignal returns the definition of a single named signal.
func (s *Synthetic) GetSignal(name string) (SignalDef, error) {
s.mu.RLock()
defer s.mu.RUnlock()
st, ok := s.signals[name]
if !ok {
return SignalDef{}, datasource.ErrNotFound
}
return st.def, nil
}
// UpdateSignal replaces the pipeline of an existing synthetic signal at runtime
// and persists the change. The signal must already exist.
func (s *Synthetic) UpdateSignal(def SignalDef) error {
if def.Name == "" {
return errors.New("signal name must not be empty")
}
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
old, exists := s.signals[def.Name]
if !exists {
s.mu.Unlock()
return datasource.ErrNotFound
}
// Preserve the superseded revision as a backup and bump the version.
oldDef := old.def
if oldDef.Version < 1 {
oldDef.Version = 1
}
if err := s.backupVersion(oldDef); err != nil {
s.mu.Unlock()
return fmt.Errorf("back up revision: %w", err)
}
def.Version = oldDef.Version + 1
if old.cancel != nil {
old.cancel()
}
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
return fmt.Errorf("persist definitions: %w", err)
}
return nil
}
// GetDefs returns a copy of all current signal definitions (for the REST API).
func (s *Synthetic) GetDefs() []SignalDef {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]SignalDef, 0, len(s.signals))
for _, st := range s.signals {
out = append(out, st.def)
}
return out
}
// ── internal helpers ──────────────────────────────────────────────────────────
func (s *Synthetic) defsFilePath() string {
return filepath.Join(s.storePath, definitionsFile)
}
func (s *Synthetic) loadDefs() ([]SignalDef, error) {
path := s.defsFilePath()
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
// Create an empty definitions file.
if werr := os.WriteFile(path, []byte("[]\n"), 0o644); werr != nil {
s.log.Warn("synthetic: could not create empty definitions file", "path", path, "err", werr)
}
return nil, nil
}
if err != nil {
return nil, err
}
var defs []SignalDef
if err := json.Unmarshal(data, &defs); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return defs, nil
}
func (s *Synthetic) saveDefs() error {
s.mu.RLock()
defs := make([]SignalDef, 0, len(s.signals))
for _, st := range s.signals {
defs = append(defs, st.def)
}
s.mu.RUnlock()
data, err := json.MarshalIndent(defs, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.defsFilePath(), data, 0o644)
}
// startSignal compiles the DAG for def and registers the signalState.
// The actual goroutines are started lazily by Subscribe.
func (s *Synthetic) startSignal(def SignalDef) error {
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("compile graph for %q: %w", def.Name, err)
}
s.mu.Lock()
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
s.log.Info("synthetic: signal registered", "name", def.Name)
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
// compiled graph's best-effort output type; an array output is reported as a
// waveform (TypeFloat64Array) so widgets can pick a compatible view.
func defToMetadata(def SignalDef, outType dsp.ValType) datasource.Metadata {
dt := datasource.TypeFloat64
if outType == dsp.ValArray {
dt = datasource.TypeFloat64Array
}
return datasource.Metadata{
Name: def.Name,
Type: dt,
Unit: def.Meta.Unit,
Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow,
DisplayHigh: def.Meta.DisplayHigh,
Writable: def.Meta.Writable,
}
}
// outTypeOf returns the compiled output type for a signal state, or unknown.
func outTypeOf(st *signalState) dsp.ValType {
if st == nil || st.rg == nil {
return dsp.ValUnknown
}
return st.rg.outType
}
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
// array Samples (waveforms), everything else a scalar Sample.
func toSample(v any) dsp.Sample {
switch val := v.(type) {
case []float64:
return dsp.Array(val)
case []float32:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
case []int:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
default:
return dsp.Scalar(toFloat64(v))
}
}
// toFloat64 coerces any numeric scalar value from a datasource.Value.Data to float64.
func toFloat64(v any) float64 {
switch val := v.(type) {
case float64:
return val
case float32:
return float64(val)
case int64:
return float64(val)
case int32:
return float64(val)
case int:
return float64(val)
case bool:
if val {
return 1
}
return 0
default:
return 0
}
}
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
type indexedUpdate struct {
idx int
val dsp.Sample
ts time.Time
}