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") } 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 } 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 }