This commit is contained in:
Martino Ferrari
2026-04-25 22:56:09 +02:00
parent 8b548ba1c2
commit 986f6cd6d8
85 changed files with 11479 additions and 5050 deletions
+137 -12
View File
@@ -3,22 +3,28 @@ package api
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/storage"
)
// Handler holds the dependencies shared across all API endpoints.
type Handler struct {
broker *broker.Broker
log *slog.Logger
broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
log *slog.Logger
}
// New creates an API Handler.
func New(b *broker.Broker, log *slog.Logger) *Handler {
return &Handler{broker: b, log: log}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Handler {
return &Handler{broker: b, synthetic: synth, store: store, log: log}
}
// Register mounts all API routes onto mux under the given prefix.
@@ -32,6 +38,10 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
// Synthetic signal CRUD
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
}
// ── /datasources ──────────────────────────────────────────────────────────────
@@ -91,30 +101,145 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
}
// ── /interfaces ───────────────────────────────────────────────────────────────
// Stub implementations — full persistence lands in Phase 6.
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
jsonOK(w, []any{})
list, err := h.store.List()
if err != nil {
h.log.Error("list interfaces", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, list)
}
func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
id, err := h.store.Create(body)
if err != nil {
h.log.Error("create interface", "err", err)
jsonError(w, http.StatusBadRequest, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
}
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotFound, "interface not found")
id := r.PathValue("id")
data, err := h.store.Get(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write(data)
}
func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
id := r.PathValue("id")
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
if err := h.store.Update(id, body); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("update interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
id := r.PathValue("id")
if err := h.store.Delete(id); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
id := r.PathValue("id")
newID, err := h.store.Clone(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("clone interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}
// ── /synthetic ────────────────────────────────────────────────────────────────
func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) {
if h.synthetic == nil {
jsonOK(w, []any{})
return
}
jsonOK(w, h.synthetic.GetDefs())
}
func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.synthetic.AddSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusConflict, err.Error())
} else {
jsonError(w, http.StatusBadRequest, err.Error())
}
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, def)
}
func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
if err := h.synthetic.RemoveSignal(name); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,34 @@
// Package synthetic implements a DataSource that computes signals from
// existing upstream signals by running them through configurable DSP pipelines.
package synthetic
// SignalDef describes one synthetic signal.
type SignalDef struct {
Name string `json:"name"`
DS string `json:"ds"` // upstream data source name
Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Meta MetaOverride `json:"meta"` // optional metadata overrides
}
// InputRef names one upstream signal used as input to the pipeline.
type InputRef struct {
DS string `json:"ds"`
Signal string `json:"signal"`
}
// NodeDef is a single node in the pipeline.
type NodeDef struct {
Type string `json:"type"`
Params map[string]any `json:"params,omitempty"`
}
// MetaOverride allows the synthetic signal to override display metadata.
type MetaOverride struct {
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow,omitempty"`
DisplayHigh float64 `json:"displayHigh,omitempty"`
Writable bool `json:"writable,omitempty"`
}
+108
View File
@@ -0,0 +1,108 @@
package synthetic
import (
"fmt"
"github.com/uopi/uopi/internal/dsp"
)
// floatParam extracts a float64 from params; returns 0 if missing or wrong type.
func floatParam(params map[string]any, key string) float64 {
if params == nil {
return 0
}
v, ok := params[key]
if !ok {
return 0
}
f, ok := v.(float64)
if !ok {
return 0
}
return f
}
// stringParam extracts a string from params; returns "" if missing or wrong type.
func stringParam(params map[string]any, key string) string {
if params == nil {
return ""
}
v, ok := params[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
nodes := make([]dsp.Node, 0, len(defs))
for i, d := range defs {
n, err := buildNode(d)
if err != nil {
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
}
nodes = append(nodes, n)
}
return nodes, nil
}
func buildNode(d NodeDef) (dsp.Node, error) {
p := d.Params
switch d.Type {
case "gain":
return &dsp.GainNode{Gain: floatParam(p, "gain")}, nil
case "offset":
return &dsp.OffsetNode{Offset: floatParam(p, "offset")}, nil
case "add":
return &dsp.AddNode{}, nil
case "subtract":
return &dsp.SubtractNode{}, nil
case "multiply":
return &dsp.MultiplyNode{}, nil
case "divide":
return &dsp.DivideNode{}, nil
case "moving_average":
return &dsp.MovingAverageNode{Window: max(1, int(floatParam(p, "window")))}, nil
case "rms":
return &dsp.RMSNode{Window: max(1, int(floatParam(p, "window")))}, nil
case "derivative":
return &dsp.DerivativeNode{}, nil
case "clamp":
return &dsp.ClampNode{
Min: floatParam(p, "min"),
Max: floatParam(p, "max"),
}, nil
case "threshold":
return &dsp.ThresholdNode{
Threshold: floatParam(p, "threshold"),
High: floatParam(p, "high"),
Low: floatParam(p, "low"),
}, nil
case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
default:
return nil, fmt.Errorf("unknown node type %q", d.Type)
}
}
+451
View File
@@ -0,0 +1,451 @@
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
nodes []dsp.Node
states []map[string]any // one map per node, persistent across calls
// 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))
}
return out, nil
}
// 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), 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 upstream references for this signal.
refs := upstreamRefs(st.def)
if len(refs) == 0 {
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
}
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
// Latest value per upstream input index.
latest := make([]float64, 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 := toFloat64(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[upd.idx] = upd.val
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
}
// Run the pipeline.
s.mu.RLock()
cur, stillExists := s.signals[signal]
s.mu.RUnlock()
if !stillExists {
return
}
result, err := runPipeline(cur.nodes, cur.states, latest)
if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue
}
v := datasource.Value{
Timestamp: upd.ts,
Data: result,
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")
}
nodes, err := BuildPipeline(def.Pipeline)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
}
s.mu.Lock()
if _, exists := s.signals[def.Name]; exists {
s.mu.Unlock()
return fmt.Errorf("signal %q already exists", def.Name)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
st := &signalState{
def: def,
nodes: nodes,
states: states,
}
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
}
// 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 builds the pipeline for def and registers the signalState.
// The actual goroutines are started lazily by Subscribe.
func (s *Synthetic) startSignal(def SignalDef) error {
nodes, err := BuildPipeline(def.Pipeline)
if err != nil {
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
s.mu.Lock()
s.signals[def.Name] = &signalState{
def: def,
nodes: nodes,
states: states,
}
s.mu.Unlock()
s.log.Info("synthetic: signal registered", "name", def.Name)
return nil
}
// runPipeline executes all nodes in sequence. The output of node N becomes
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
if len(nodes) == 0 {
if len(inputs) == 0 {
return 0, nil
}
return inputs[0], nil
}
// First node receives all upstream inputs.
cur := inputs
var result float64
var err error
for i, node := range nodes {
result, err = node.Process(cur, states[i])
if err != nil {
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
}
// Subsequent nodes receive only the single output of the previous node.
cur = []float64{result}
}
return result, nil
}
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
func upstreamRefs(def SignalDef) []broker.SignalRef {
if len(def.Inputs) > 0 {
refs := make([]broker.SignalRef, len(def.Inputs))
for i, inp := range def.Inputs {
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
}
return refs
}
if def.DS != "" && def.Signal != "" {
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
}
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata.
func defToMetadata(def SignalDef) datasource.Metadata {
return datasource.Metadata{
Name: def.Name,
Type: datasource.TypeFloat64,
Unit: def.Meta.Unit,
Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow,
DisplayHigh: def.Meta.DisplayHigh,
Writable: def.Meta.Writable,
}
}
// toFloat64 coerces any numeric 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 float64
ts time.Time
}
@@ -0,0 +1,259 @@
package synthetic
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
func newTestSynthetic(t *testing.T) (*Synthetic, *broker.Broker, context.CancelFunc) {
t.Helper()
dir := t.TempDir()
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
brk := broker.New(ctx, log)
syn := New(dir, brk, log)
return syn, brk, cancel
}
// TestNameAndConnect verifies basic construction and Connect.
func TestNameAndConnect(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if syn.Name() != "synthetic" {
t.Errorf("Name: want %q, got %q", "synthetic", syn.Name())
}
ctx := context.Background()
if err := syn.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
}
// TestConnectCreatesEmptyFile verifies that Connect creates an empty
// synthetic.json when none exists.
func TestConnectCreatesEmptyFile(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
path := filepath.Join(syn.storePath, "synthetic.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected file to be created: %v", err)
}
if string(data) == "" {
t.Error("file should not be empty")
}
}
// TestAddAndRemoveSignal exercises CRUD at runtime.
func TestAddAndRemoveSignal(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
def := SignalDef{
Name: "test_gain",
DS: "stub",
Signal: "sine_1hz",
Pipeline: []NodeDef{
{Type: "gain", Params: map[string]any{"gain": 2.0}},
},
Meta: MetaOverride{Unit: "V"},
}
if err := syn.AddSignal(def); err != nil {
t.Fatalf("AddSignal: %v", err)
}
// Duplicate should fail.
if err := syn.AddSignal(def); err == nil {
t.Error("expected error for duplicate signal")
}
// Should be listed.
metas, err := syn.ListSignals(context.Background())
if err != nil {
t.Fatal(err)
}
if len(metas) != 1 || metas[0].Name != "test_gain" {
t.Errorf("unexpected metas: %v", metas)
}
// GetMetadata should work.
meta, err := syn.GetMetadata(context.Background(), "test_gain")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if meta.Unit != "V" {
t.Errorf("unit: want V, got %q", meta.Unit)
}
// Remove it.
if err := syn.RemoveSignal("test_gain"); err != nil {
t.Fatalf("RemoveSignal: %v", err)
}
// Should be gone.
if _, err := syn.GetMetadata(context.Background(), "test_gain"); err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound after remove, got %v", err)
}
// Remove non-existing should return ErrNotFound.
if err := syn.RemoveSignal("nonexistent"); err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
// TestAddSignalEmptyName validates that empty name is rejected.
func TestAddSignalEmptyName(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{}); err == nil {
t.Error("expected error for empty signal name")
}
}
// TestGetMetadataNotFound verifies ErrNotFound for unknown signal.
func TestGetMetadataNotFound(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
_, err := syn.GetMetadata(context.Background(), "no_such_signal")
if err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
// TestWriteNotSupported ensures Write returns ErrNotWritable.
func TestWriteNotSupported(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
err := syn.Write(context.Background(), "any", 1.0)
if err != datasource.ErrNotWritable {
t.Errorf("expected ErrNotWritable, got %v", err)
}
}
// TestHistoryNotSupported ensures History returns ErrHistoryUnavailable.
func TestHistoryNotSupported(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
_, err := syn.History(context.Background(), "any", time.Now(), time.Now(), 10)
if err != datasource.ErrHistoryUnavailable {
t.Errorf("expected ErrHistoryUnavailable, got %v", err)
}
}
// TestPersistence verifies that definitions survive a restart (New+Connect).
func TestPersistence(t *testing.T) {
dir := t.TempDir()
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
brk := broker.New(ctx, log)
syn := New(dir, brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
def := SignalDef{
Name: "persisted",
DS: "stub",
Signal: "sine_1hz",
Pipeline: []NodeDef{
{Type: "offset", Params: map[string]any{"offset": 5.0}},
},
}
if err := syn.AddSignal(def); err != nil {
t.Fatal(err)
}
cancel()
// Reload.
ctx2 := t.Context()
brk2 := broker.New(ctx2, log)
syn2 := New(dir, brk2, log)
if err := syn2.Connect(ctx2); err != nil {
t.Fatal(err)
}
meta, err := syn2.GetMetadata(ctx2, "persisted")
if err != nil {
t.Fatalf("after reload, GetMetadata: %v", err)
}
if meta.Name != "persisted" {
t.Errorf("expected name %q, got %q", "persisted", meta.Name)
}
}
// TestSubscribeUnknownSignal verifies that subscribing to unknown signal returns error.
func TestSubscribeUnknownSignal(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 1)
_, err := syn.Subscribe(context.Background(), "nonexistent", ch)
if err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
// TestGetDefs verifies GetDefs returns current definitions.
func TestGetDefs(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
defs := syn.GetDefs()
if len(defs) != 0 {
t.Errorf("expected empty defs, got %d", len(defs))
}
def := SignalDef{
Name: "test",
DS: "stub",
Signal: "noise",
}
if err := syn.AddSignal(def); err != nil {
t.Fatal(err)
}
defs = syn.GetDefs()
if len(defs) != 1 || defs[0].Name != "test" {
t.Errorf("unexpected defs: %v", defs)
}
}
+5 -2
View File
@@ -9,6 +9,8 @@ import (
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/storage"
)
const apiPrefix = "/api/v1"
@@ -19,7 +21,8 @@ type Server struct {
}
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
func New(addr string, webFS fs.FS, brk *broker.Broker, log *slog.Logger) *Server {
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Server {
mux := http.NewServeMux()
// Health check
@@ -32,7 +35,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, log *slog.Logger) *Server
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
// REST API
api.New(brk, log).Register(mux, apiPrefix)
api.New(brk, synth, store, log).Register(mux, apiPrefix)
// Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS))
+160
View File
@@ -0,0 +1,160 @@
// Package storage provides file-based persistence for HMI interface definitions.
package storage
import (
"encoding/xml"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ErrNotFound is returned when the requested interface does not exist.
var ErrNotFound = errors.New("interface not found")
// InterfaceMeta is the lightweight representation returned by List.
type InterfaceMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
}
// Store persists interface definitions as XML files under {storageDir}/interfaces/.
type Store struct {
dir string
}
// New opens (and creates, if needed) the storage directory.
func New(storageDir string) (*Store, error) {
dir := filepath.Join(storageDir, "interfaces")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create interfaces dir: %w", err)
}
return &Store{dir: dir}, nil
}
func (s *Store) filePath(id string) string {
return filepath.Join(s.dir, id+".xml")
}
// List returns metadata for every stored interface.
func (s *Store) List() ([]InterfaceMeta, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
var out []InterfaceMeta
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") {
continue
}
id := strings.TrimSuffix(e.Name(), ".xml")
meta, err := s.readMeta(id)
if err != nil {
continue // skip corrupt files silently
}
out = append(out, meta)
}
if out == nil {
out = []InterfaceMeta{}
}
return out, nil
}
// rootAttrs is used to parse only the top-level XML attributes.
type rootAttrs struct {
XMLName xml.Name `xml:"interface"`
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
data, err := os.ReadFile(s.filePath(id))
if err != nil {
return InterfaceMeta{}, err
}
var root rootAttrs
if err := xml.Unmarshal(data, &root); err != nil {
return InterfaceMeta{}, err
}
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
}
// Get returns the raw XML bytes for the interface with the given ID.
func (s *Store) Get(id string) ([]byte, error) {
data, err := os.ReadFile(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to
// ensure uniqueness.
func (s *Store) Create(xmlData []byte) (string, error) {
var root rootAttrs
if err := xml.Unmarshal(xmlData, &root); err != nil {
return "", fmt.Errorf("invalid XML: %w", err)
}
id := root.ID
if id == "" {
id = slugify(root.Name)
}
// Guarantee uniqueness.
if _, err := os.Stat(s.filePath(id)); err == nil {
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
}
return id, os.WriteFile(s.filePath(id), xmlData, 0o644)
}
// Update replaces the XML for an existing interface.
func (s *Store) Update(id string, xmlData []byte) error {
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return os.WriteFile(s.filePath(id), xmlData, 0o644)
}
// Delete removes the interface with the given ID.
func (s *Store) Delete(id string) error {
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
// Clone duplicates an interface, assigning a new unique ID.
func (s *Store) Clone(srcID string) (string, error) {
data, err := s.Get(srcID)
if err != nil {
return "", err
}
newID := srcID + "-copy-" + fmt.Sprintf("%d", time.Now().UnixMilli())
return newID, os.WriteFile(s.filePath(newID), data, 0o644)
}
// slugify converts a human-readable name into a URL-safe identifier.
func slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
b.WriteByte('-')
}
}
}
result := strings.Trim(b.String(), "-")
if result == "" {
return "interface"
}
return result
}