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
+163
View File
@@ -0,0 +1,163 @@
// Package api implements the REST API handlers for uopi.
package api
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// Handler holds the dependencies shared across all API endpoints.
type Handler struct {
broker *broker.Broker
log *slog.Logger
}
// New creates an API Handler.
func New(b *broker.Broker, log *slog.Logger) *Handler {
return &Handler{broker: b, log: log}
}
// Register mounts all API routes onto mux under the given prefix.
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces)
mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface)
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)
}
// ── /datasources ──────────────────────────────────────────────────────────────
type dsInfo struct {
Name string `json:"name"`
Status string `json:"status"`
}
func (h *Handler) listDataSources(w http.ResponseWriter, r *http.Request) {
sources := h.broker.DataSources()
out := make([]dsInfo, len(sources))
for i, ds := range sources {
out[i] = dsInfo{Name: ds.Name(), Status: "connected"}
}
jsonOK(w, out)
}
// ── /signals ──────────────────────────────────────────────────────────────────
type signalInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"`
DisplayHigh float64 `json:"displayHigh"`
Writable bool `json:"writable"`
EnumStrings []string `json:"enumStrings,omitempty"`
}
func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
dsName := r.URL.Query().Get("ds")
if dsName == "" {
jsonError(w, http.StatusBadRequest, "query parameter 'ds' is required")
return
}
ds, ok := h.broker.Source(dsName)
if !ok {
jsonError(w, http.StatusNotFound, "unknown data source: "+dsName)
return
}
metas, err := ds.ListSignals(r.Context())
if err != nil {
h.log.Error("list signals", "ds", dsName, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]signalInfo, len(metas))
for i, m := range metas {
out[i] = metaToSignalInfo(m)
}
jsonOK(w, out)
}
// ── /interfaces ───────────────────────────────────────────────────────────────
// Stub implementations — full persistence lands in Phase 6.
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
jsonOK(w, []any{})
}
func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
}
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotFound, "interface not found")
}
func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
}
func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
}
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented")
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func metaToSignalInfo(m datasource.Metadata) signalInfo {
return signalInfo{
Name: m.Name,
Type: dataTypeName(m.Type),
Unit: m.Unit,
Description: m.Description,
DisplayLow: m.DisplayLow,
DisplayHigh: m.DisplayHigh,
Writable: m.Writable,
EnumStrings: m.EnumStrings,
}
}
func dataTypeName(t datasource.DataType) string {
switch t {
case datasource.TypeFloat64:
return "float64"
case datasource.TypeFloat64Array:
return "float64[]"
case datasource.TypeString:
return "string"
case datasource.TypeInt64:
return "int64"
case datasource.TypeBool:
return "bool"
case datasource.TypeEnum:
return "enum"
default:
return "unknown"
}
}
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func jsonError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
+177
View File
@@ -0,0 +1,177 @@
// Package broker multiplexes signal subscriptions from data sources to multiple
// WebSocket clients. For each unique signal only one upstream subscription is
// maintained regardless of how many clients are watching it.
package broker
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/uopi/uopi/internal/datasource"
)
// SignalRef identifies a signal within a named data source.
type SignalRef struct {
DS string
Name string
}
// Update is delivered to every subscriber when a signal value changes.
type Update struct {
Ref SignalRef
Value datasource.Value
}
// signalSub holds the state for one active upstream signal subscription and its
// set of downstream client channels.
type signalSub struct {
mu sync.RWMutex
clients map[chan<- Update]struct{}
done chan struct{} // closed to stop the fanOut goroutine
dsStop datasource.CancelFunc
}
// Broker manages data-source registrations and fan-out of signal updates.
type Broker struct {
ctx context.Context
mu sync.Mutex
sources map[string]datasource.DataSource
subs map[SignalRef]*signalSub
log *slog.Logger
}
// New creates a Broker whose upstream subscriptions are bound to ctx.
// Cancel ctx (or the parent context passed to main) to shut everything down.
func New(ctx context.Context, log *slog.Logger) *Broker {
return &Broker{
ctx: ctx,
sources: make(map[string]datasource.DataSource),
subs: make(map[SignalRef]*signalSub),
log: log,
}
}
// Register adds a DataSource to the broker. Must be called before Subscribe.
func (b *Broker) Register(ds datasource.DataSource) {
b.mu.Lock()
defer b.mu.Unlock()
b.sources[ds.Name()] = ds
b.log.Info("data source registered", "name", ds.Name())
}
// DataSources returns all registered sources.
func (b *Broker) DataSources() []datasource.DataSource {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]datasource.DataSource, 0, len(b.sources))
for _, ds := range b.sources {
out = append(out, ds)
}
return out
}
// Source returns the named data source, if registered.
func (b *Broker) Source(name string) (datasource.DataSource, bool) {
b.mu.Lock()
defer b.mu.Unlock()
ds, ok := b.sources[name]
return ds, ok
}
// Subscribe registers ch to receive updates for ref.
// If this is the first subscriber for the signal, the upstream DS subscription
// is started. Subsequent calls for the same signal share the existing one.
// The returned func must be called when the client no longer needs the signal.
func (b *Broker) Subscribe(ref SignalRef, ch chan<- Update) (func(), error) {
b.mu.Lock()
defer b.mu.Unlock()
sub, exists := b.subs[ref]
if !exists {
ds, found := b.sources[ref.DS]
if !found {
return nil, fmt.Errorf("unknown data source %q", ref.DS)
}
rawCh := make(chan datasource.Value, 32)
dsCancel, err := ds.Subscribe(b.ctx, ref.Name, rawCh)
if err != nil {
return nil, fmt.Errorf("subscribe %s/%s: %w", ref.DS, ref.Name, err)
}
sub = &signalSub{
clients: make(map[chan<- Update]struct{}),
done: make(chan struct{}),
dsStop: dsCancel,
}
b.subs[ref] = sub
go b.fanOut(ref, sub, rawCh)
b.log.Debug("upstream subscription started", "ds", ref.DS, "signal", ref.Name)
}
sub.mu.Lock()
sub.clients[ch] = struct{}{}
sub.mu.Unlock()
return func() { b.unsubscribe(ref, ch) }, nil
}
func (b *Broker) unsubscribe(ref SignalRef, ch chan<- Update) {
b.mu.Lock()
defer b.mu.Unlock()
sub, ok := b.subs[ref]
if !ok {
return
}
sub.mu.Lock()
delete(sub.clients, ch)
remaining := len(sub.clients)
sub.mu.Unlock()
if remaining == 0 {
close(sub.done) // terminates fanOut goroutine
sub.dsStop()
delete(b.subs, ref)
b.log.Debug("upstream subscription torn down", "ds", ref.DS, "signal", ref.Name)
}
}
// fanOut reads values from rawCh and dispatches them to all registered clients.
// It exits when sub.done is closed or rawCh is closed.
func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.Value) {
for {
select {
case v, ok := <-rawCh:
if !ok {
return
}
update := Update{Ref: ref, Value: v}
sub.mu.RLock()
for ch := range sub.clients {
select {
case ch <- update:
default:
// slow consumer: drop rather than block
}
}
sub.mu.RUnlock()
case <-sub.done:
return
}
}
}
// ActiveSubscriptions returns the number of currently active upstream signal
// subscriptions. Useful for diagnostics and tests.
func (b *Broker) ActiveSubscriptions() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.subs)
}
+156
View File
@@ -0,0 +1,156 @@
package broker_test
import (
"context"
"log/slog"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/stub"
)
func newBroker(t *testing.T) (*broker.Broker, context.CancelFunc) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.Default()
b := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal(err)
}
b.Register(ds)
return b, cancel
}
func recv(t *testing.T, ch <-chan broker.Update, timeout time.Duration) broker.Update {
t.Helper()
select {
case u := <-ch:
return u
case <-time.After(timeout):
t.Fatalf("timed out waiting for update after %s", timeout)
return broker.Update{}
}
}
func TestFanOut(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ref := broker.SignalRef{DS: "stub", Name: "sine_1hz"}
ch1 := make(chan broker.Update, 10)
ch2 := make(chan broker.Update, 10)
unsub1, err := b.Subscribe(ref, ch1)
if err != nil {
t.Fatal(err)
}
unsub2, err := b.Subscribe(ref, ch2)
if err != nil {
t.Fatal(err)
}
defer unsub2()
// Both channels must receive an update
recv(t, ch1, 2*time.Second)
recv(t, ch2, 2*time.Second)
// Only one upstream subscription should exist
if n := b.ActiveSubscriptions(); n != 1 {
t.Errorf("expected 1 active subscription, got %d", n)
}
// After unsubscribing ch1, ch2 should still receive updates
unsub1()
// drain any buffered updates
for len(ch2) > 0 {
<-ch2
}
recv(t, ch2, 2*time.Second)
}
func TestUnsubscribeLastClientTearsDownUpstream(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ref := broker.SignalRef{DS: "stub", Name: "ramp_10s"}
ch := make(chan broker.Update, 10)
unsub, err := b.Subscribe(ref, ch)
if err != nil {
t.Fatal(err)
}
recv(t, ch, 2*time.Second)
if n := b.ActiveSubscriptions(); n != 1 {
t.Errorf("expected 1 active subscription before unsub, got %d", n)
}
unsub()
// Give the broker a moment to process the teardown
time.Sleep(50 * time.Millisecond)
if n := b.ActiveSubscriptions(); n != 0 {
t.Errorf("expected 0 active subscriptions after unsub, got %d", n)
}
}
func TestUnknownDataSource(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ref := broker.SignalRef{DS: "nonexistent", Name: "signal"}
ch := make(chan broker.Update, 1)
_, err := b.Subscribe(ref, ch)
if err == nil {
t.Fatal("expected error for unknown data source, got nil")
}
}
func TestUnknownSignal(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ref := broker.SignalRef{DS: "stub", Name: "does_not_exist"}
ch := make(chan broker.Update, 1)
_, err := b.Subscribe(ref, ch)
if err == nil {
t.Fatal("expected error for unknown signal, got nil")
}
}
func TestMultipleSignals(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
signals := []string{"sine_1hz", "sine_01hz", "ramp_10s", "toggle_1hz"}
channels := make([]chan broker.Update, len(signals))
unsubs := make([]func(), len(signals))
for i, name := range signals {
ch := make(chan broker.Update, 10)
channels[i] = ch
unsub, err := b.Subscribe(broker.SignalRef{DS: "stub", Name: name}, ch)
if err != nil {
t.Fatalf("subscribe %s: %v", name, err)
}
unsubs[i] = unsub
}
defer func() {
for _, u := range unsubs {
u()
}
}()
if n := b.ActiveSubscriptions(); n != len(signals) {
t.Errorf("expected %d active subscriptions, got %d", len(signals), n)
}
for i, ch := range channels {
recv(t, ch, 2*time.Second)
_ = i
}
}
+148
View File
@@ -0,0 +1,148 @@
//go:build epics
package epics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/uopi/uopi/internal/datasource"
)
// archiveResponse is the top-level JSON structure returned by the EPICS Archive
// Appliance getData.json endpoint.
//
// Example response shape:
//
// [
// {
// "meta": { "name": "PV:NAME", "EGU": "A" },
// "data": [
// { "secs": 1700000000, "nanos": 123000000, "val": 3.14, "severity": 0, "status": 0 },
// ...
// ]
// }
// ]
type archiveResponse []struct {
Meta struct {
Name string `json:"name"`
EGU string `json:"EGU"`
} `json:"meta"`
Data []archivePoint `json:"data"`
}
type archivePoint struct {
Secs int64 `json:"secs"`
Nanos int64 `json:"nanos"`
Val any `json:"val"`
Severity int `json:"severity"`
Status int `json:"status"`
}
// fetchArchiveHistory queries the EPICS Archive Appliance JSON API for
// historical data for the given PV in the time range [start, end].
func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
// Build the request URL.
// Format: GET {archiveURL}/retrieval/data/getData.json?pv={name}&from={start}&to={end}&maxSamples={n}
reqURL, err := url.Parse(archiveURL)
if err != nil {
return nil, fmt.Errorf("epics archive: invalid archive URL %q: %w", archiveURL, err)
}
reqURL = reqURL.JoinPath("retrieval", "data", "getData.json")
q := reqURL.Query()
q.Set("pv", signal)
q.Set("from", start.UTC().Format(time.RFC3339Nano))
q.Set("to", end.UTC().Format(time.RFC3339Nano))
if maxPoints > 0 {
q.Set("maxSamples", strconv.Itoa(maxPoints))
}
reqURL.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("epics archive: building request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("epics archive: HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("epics archive: unexpected status %d for %q", resp.StatusCode, signal)
}
var ar archiveResponse
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
return nil, fmt.Errorf("epics archive: decoding response: %w", err)
}
if len(ar) == 0 {
return nil, nil
}
points := ar[0].Data
out := make([]datasource.Value, 0, len(points))
// EPICS Archive Appliance uses the POSIX epoch (unlike the CA wire protocol
// which uses the EPICS epoch). The JSON API returns POSIX seconds.
for _, p := range points {
ts := time.Unix(p.Secs, p.Nanos).UTC()
var q datasource.Quality
switch p.Severity {
case 0:
q = datasource.QualityGood
case 1, 2:
q = datasource.QualityUncertain
default:
q = datasource.QualityBad
}
data := coerceArchiveValue(p.Val)
out = append(out, datasource.Value{
Timestamp: ts,
Data: data,
Quality: q,
})
}
return out, nil
}
// coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes
// as float64, string, bool, []interface{}, or nil) into one of the types
// accepted by datasource.Value.Data.
func coerceArchiveValue(v any) any {
if v == nil {
return float64(0)
}
switch t := v.(type) {
case float64:
return t
case string:
return t
case bool:
return t
case []interface{}:
// Waveform: convert to []float64.
out := make([]float64, 0, len(t))
for _, elem := range t {
if f, ok := elem.(float64); ok {
out = append(out, f)
}
}
return out
default:
return float64(0)
}
}
+138
View File
@@ -0,0 +1,138 @@
//go:build epics
package epics
/*
#include "ca_wrapper.h"
*/
import "C"
import (
"time"
"unsafe"
"github.com/uopi/uopi/internal/datasource"
)
// goCAMonitorCallback is called from the C shim caMonitorCallbackShim on every
// CA monitor event. It runs on CA's internal callback thread, so it must be
// short and must not block. In particular it must not allocate heap memory
// that the Go GC would need to track across a CGo boundary.
//
//export goCAMonitorCallback
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
h := uintptr(handle)
// Compute the Go timestamp. EPICS timestamps are seconds since the EPICS
// epoch (1 Jan 1990 UTC).
const epicsEpochOffset = 631152000 // Unix seconds of 1990-01-01 00:00:00 UTC
secs := float64(epicsTimeSecs)
fullSecs := int64(secs)
nsec := int64((secs - float64(fullSecs)) * 1e9)
ts := time.Unix(fullSecs+epicsEpochOffset, nsec)
// Map CA alarm severity to datasource quality.
var q datasource.Quality
switch int(severity) {
case 0: // NO_ALARM
q = datasource.QualityGood
case 1, 2: // MINOR_ALARM, MAJOR_ALARM
q = datasource.QualityUncertain
default: // INVALID_ALARM (3)
q = datasource.QualityBad
}
// Extract the value from the DBR buffer.
var data any
switch int(dbrType) {
case int(C.DBR_TIME_DOUBLE):
p := (*C.struct_dbr_time_double)(dbr)
data = float64(p.value)
case int(C.DBR_TIME_FLOAT):
p := (*C.struct_dbr_time_float)(dbr)
data = float64(p.value)
case int(C.DBR_TIME_LONG):
p := (*C.struct_dbr_time_long)(dbr)
data = int64(p.value)
case int(C.DBR_TIME_SHORT):
p := (*C.struct_dbr_time_short)(dbr)
data = int64(p.value)
case int(C.DBR_TIME_ENUM):
p := (*C.struct_dbr_time_enum)(dbr)
data = int64(p.value)
case int(C.DBR_TIME_STRING):
p := (*C.struct_dbr_time_string)(dbr)
// dbr_string is a fixed [MAX_STRING_SIZE]byte array
data = C.GoString((*C.char)(unsafe.Pointer(&p.value[0])))
default:
// Unknown type; mark as bad quality and return early.
q = datasource.QualityBad
data = float64(0)
}
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
// Look up the subscription channel under the global handle table lock and
// perform a non-blocking send so we never stall the CA callback thread.
handleMu.Lock()
ch, ok := handleTable[h]
handleMu.Unlock()
if ok {
select {
case ch <- v:
default:
// Subscriber is not reading fast enough; drop the update rather than
// block the CA callback thread.
}
}
}
// goCAConnectionCallback is called from the C shim caConnectionCallbackShim
// whenever a channel connects or disconnects.
//
//export goCAConnectionCallback
func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
h := uintptr(handle)
handleMu.Lock()
entry, ok := connTable[h]
handleMu.Unlock()
if !ok {
return
}
if int(connected) == 1 {
// Signal the one-shot "connected" channel if it has not been closed yet.
select {
case entry.connCh <- struct{}{}:
default:
}
} else {
// Channel disconnected; push a QualityBad sentinel to the subscriber.
handleMu.Lock()
ch, chOk := handleTable[h]
handleMu.Unlock()
if chOk {
v := datasource.Value{
Timestamp: time.Now(),
Data: float64(0),
Quality: datasource.QualityBad,
}
select {
case ch <- v:
default:
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
#ifndef CA_WRAPPER_H
#define CA_WRAPPER_H
#include <cadef.h>
#include <db_access.h>
#include <stdint.h>
/*
* Go-exported callback declarations.
* These are implemented in ca_callback.go (via //export directives).
*
* goCAMonitorCallback is called for each value update received from CA.
* goCAConnectionCallback is called when a channel connects or disconnects.
*/
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
const void *dbr, int severity,
double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected);
/*
* CA monitor callback shim.
*
* CA calls this function with the fixed evargs signature. We extract the
* relevant fields and forward them to the Go callback.
*/
static void caMonitorCallbackShim(struct event_handler_args args) {
if (args.status != ECA_NORMAL) {
return;
}
double timeSecs = 0.0;
int severity = 0;
/*
* Determine the timestamp and alarm severity from the DBR type.
* We request DBR_TIME_* types so the timestamp is embedded in the value
* buffer right after the alarm fields (struct dbr_time_double et al.).
*/
switch (args.type) {
case DBR_TIME_DOUBLE: {
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
break;
}
case DBR_TIME_FLOAT: {
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
break;
}
case DBR_TIME_LONG: {
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
break;
}
case DBR_TIME_SHORT: {
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
break;
}
case DBR_TIME_STRING: {
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
break;
}
case DBR_TIME_ENUM: {
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
break;
}
default:
break;
}
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
args.dbr, severity, timeSecs);
}
/*
* CA connection callback shim.
*
* CA calls this with a connection_handler_args struct. We map the op field
* to a simple boolean (1 = connected, 0 = disconnected) and forward.
*/
static void caConnectionCallbackShim(struct connection_handler_args args) {
int connected = (args.op == CA_OP_CONN_UP) ? 1 : 0;
goCAConnectionCallback((uintptr_t)ca_puser(args.chid), connected);
}
/*
* Thin helpers so CGo callers do not need to deal with C function-pointer
* syntax directly. All CA API calls stay in C to avoid issues with CGo
* passing Go pointers across the CA callback boundary.
*/
/* Create a channel with the connection shim as the connection callback.
* user_handle is the Go uintptr_t that identifies this channel in the handle
* table. Returns the ECA_* status code. */
static int caCreateChannel(const char *pvName, uintptr_t userHandle,
chid *pChid) {
return ca_create_channel(pvName, caConnectionCallbackShim,
(void *)userHandle, CA_PRIORITY_DEFAULT, pChid);
}
/* Add a value-monitor event on chid. Returns the ECA_* status code and stores
* the evid in *pEvid. */
static int caAddMonitor(chid ch, short dbrType, uintptr_t userHandle,
evid *pEvid) {
return ca_add_event(dbrType, ch, caMonitorCallbackShim,
(void *)userHandle, pEvid);
}
#endif /* CA_WRAPPER_H */
+458
View File
@@ -0,0 +1,458 @@
//go:build epics
// Package epics provides an EPICS Channel Access data source for uopi.
//
// # Build requirements
//
// This file and all other files tagged "epics" require CGo and a working EPICS
// Base installation. Set the following environment variables before building:
//
// export EPICS_BASE=/path/to/epics/base
// export CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux"
// export CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom"
//
// Then build with:
//
// CGO_ENABLED=1 go build -tags epics ./...
package epics
/*
#cgo CFLAGS: -Wall
#include "ca_wrapper.h"
#include <stdlib.h>
*/
import "C"
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/uopi/uopi/internal/datasource"
)
// connEntry holds the one-shot channel used during channel creation to wait for
// the initial connection callback.
type connEntry struct {
connCh chan struct{}
}
// handleTable maps Go-side handle IDs to the subscriber's value channel.
// Protected by handleMu.
var (
handleMu sync.Mutex
handleTable = make(map[uintptr]chan<- datasource.Value)
connTable = make(map[uintptr]*connEntry)
)
// handleSeq is an atomically-incremented counter used to generate unique handle
// IDs that are passed through CA as void* user pointers.
var handleSeq atomic.Uintptr
func nextHandle() uintptr {
return handleSeq.Add(1)
}
// caChannel holds the CA resources for a single PV.
type caChannel struct {
chid C.chid
evid C.evid // monitor event ID; zero if no monitor installed
handle uintptr
}
// EPICS is the Channel Access data source.
type EPICS struct {
caAddrList string
archiveURL string
mu sync.Mutex
channels map[string]*caChannel // PV name → CA channel
metadata map[string]datasource.Metadata
}
// New creates a new EPICS Channel Access data source.
//
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
// rely on the environment). archiveURL is the base URL of an EPICS Archive
// Appliance instance for history queries (may be empty).
func New(caAddrList, archiveURL string) datasource.DataSource {
return &EPICS{
caAddrList: caAddrList,
archiveURL: archiveURL,
channels: make(map[string]*caChannel),
metadata: make(map[string]datasource.Metadata),
}
}
// Available reports whether the EPICS data source is compiled in.
// Always returns true when built with the "epics" build tag.
func Available() bool { return true }
// Name implements datasource.DataSource.
func (e *EPICS) Name() string { return "epics" }
// Connect initialises a CA context with preemptive callbacks so that monitor
// callbacks are delivered on CA's background threads without needing a
// ca_pend_event polling loop.
func (e *EPICS) Connect(_ context.Context) error {
// Optionally override EPICS_CA_ADDR_LIST at runtime.
if e.caAddrList != "" {
cs := C.CString(e.caAddrList)
defer C.free(unsafe.Pointer(cs))
// ca_setenv is not available in all EPICS versions; use putenv via C.
envStr := C.CString("EPICS_CA_ADDR_LIST=" + e.caAddrList)
defer C.free(unsafe.Pointer(envStr))
C.putenv(envStr)
}
status := C.ca_context_create(C.ca_enable_preemptive_callback)
if status != C.ECA_NORMAL {
return fmt.Errorf("epics: ca_context_create failed: status %d", int(status))
}
return nil
}
// Subscribe connects to the named PV (if not already connected), installs a
// monitor, and streams value updates into ch. The returned CancelFunc removes
// the monitor and clears the channel.
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
handle := nextHandle()
// Register the handle → channel mapping before creating the CA channel so
// that any early callbacks are not lost.
handleMu.Lock()
handleTable[handle] = ch
entry := &connEntry{connCh: make(chan struct{}, 1)}
connTable[handle] = entry
handleMu.Unlock()
// Create the CA channel.
pvName := C.CString(signal)
defer C.free(unsafe.Pointer(pvName))
var chid C.chid
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
if status != C.ECA_NORMAL {
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
}
// Flush the create request and wait for the connection callback.
C.ca_flush_io()
select {
case <-entry.connCh:
// Connected.
case <-time.After(5 * time.Second):
// Timeout; clean up and report error.
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: timeout waiting for channel %q to connect", signal)
case <-ctx.Done():
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, ctx.Err()
}
// Determine the native DBR_TIME_* type for the channel.
dbrType := nativeTimeType(chid)
// Add the value monitor.
var evid C.evid
status = C.caAddMonitor(chid, C.short(dbrType), C.uintptr_t(handle), &evid)
if status != C.ECA_NORMAL {
C.ca_clear_channel(chid)
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
return nil, fmt.Errorf("epics: ca_add_event(%q) failed: status %d", signal, int(status))
}
C.ca_flush_io()
caCh := &caChannel{chid: chid, evid: evid, handle: handle}
e.mu.Lock()
e.channels[signal] = caCh
e.mu.Unlock()
cancel := datasource.CancelFunc(func() {
if evid != nil {
C.ca_clear_event(evid)
}
C.ca_clear_channel(chid)
C.ca_flush_io()
handleMu.Lock()
delete(handleTable, handle)
delete(connTable, handle)
handleMu.Unlock()
e.mu.Lock()
delete(e.channels, signal)
e.mu.Unlock()
})
return cancel, nil
}
// GetMetadata performs a synchronous ca_get for DBR_CTRL_DOUBLE (or the
// appropriate control type) to retrieve units, display limits, enum strings,
// and writability information for the named signal.
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
// Check cache first.
e.mu.Lock()
if m, ok := e.metadata[signal]; ok {
e.mu.Unlock()
return m, nil
}
e.mu.Unlock()
// We need a connected channel to issue a ca_get. Reuse an existing one or
// create a temporary channel.
e.mu.Lock()
existingCh, exists := e.channels[signal]
e.mu.Unlock()
var chid C.chid
tempChannel := false
if exists {
chid = existingCh.chid
} else {
// Create a temporary channel for metadata retrieval.
handle := nextHandle()
entry := &connEntry{connCh: make(chan struct{}, 1)}
handleMu.Lock()
connTable[handle] = entry
handleMu.Unlock()
pvName := C.CString(signal)
defer C.free(unsafe.Pointer(pvName))
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
if status != C.ECA_NORMAL {
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
return datasource.Metadata{}, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
}
C.ca_flush_io()
select {
case <-entry.connCh:
case <-time.After(5 * time.Second):
C.ca_clear_channel(chid)
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
return datasource.Metadata{}, fmt.Errorf("epics: timeout connecting to %q for metadata", signal)
case <-ctx.Done():
C.ca_clear_channel(chid)
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
return datasource.Metadata{}, ctx.Err()
}
handleMu.Lock()
delete(connTable, handle)
handleMu.Unlock()
tempChannel = true
}
meta := e.fetchMetadata(chid, signal)
if tempChannel {
C.ca_clear_channel(chid)
C.ca_flush_io()
}
e.mu.Lock()
e.metadata[signal] = meta
e.mu.Unlock()
return meta, nil
}
// fetchMetadata retrieves metadata from a connected chid using ca_get.
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
meta := datasource.Metadata{Name: name}
fieldType := C.ca_field_type(chid)
count := C.ca_element_count(chid)
// Determine the Go DataType and the DBR_CTRL type to request.
var ctrlType C.chtype
switch int(fieldType) {
case int(C.DBF_DOUBLE), int(C.DBF_FLOAT):
if count > 1 {
meta.Type = datasource.TypeFloat64Array
} else {
meta.Type = datasource.TypeFloat64
}
ctrlType = C.DBR_CTRL_DOUBLE
case int(C.DBF_LONG), int(C.DBF_SHORT), int(C.DBF_CHAR):
meta.Type = datasource.TypeInt64
ctrlType = C.DBR_CTRL_LONG
case int(C.DBF_ENUM):
meta.Type = datasource.TypeEnum
ctrlType = C.DBR_CTRL_ENUM
case int(C.DBF_STRING):
meta.Type = datasource.TypeString
ctrlType = C.DBR_CTRL_STRING
default:
meta.Type = datasource.TypeFloat64
ctrlType = C.DBR_CTRL_DOUBLE
}
// Perform a synchronous ca_get with a 3-second timeout.
switch ctrlType {
case C.DBR_CTRL_DOUBLE:
var buf C.struct_dbr_ctrl_double
status := C.ca_get(C.DBR_CTRL_DOUBLE, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL {
C.ca_pend_io(3.0)
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
meta.DisplayLow = float64(buf.lower_disp_limit)
meta.DisplayHigh = float64(buf.upper_disp_limit)
meta.DriveLow = float64(buf.lower_ctrl_limit)
meta.DriveHigh = float64(buf.upper_ctrl_limit)
// CA does not expose a writable flag directly; assume writable unless
// it is a read-only field type. Conservatively mark as writable.
meta.Writable = true
}
case C.DBR_CTRL_LONG:
var buf C.struct_dbr_ctrl_long
status := C.ca_get(C.DBR_CTRL_LONG, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL {
C.ca_pend_io(3.0)
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
meta.DisplayLow = float64(buf.lower_disp_limit)
meta.DisplayHigh = float64(buf.upper_disp_limit)
meta.DriveLow = float64(buf.lower_ctrl_limit)
meta.DriveHigh = float64(buf.upper_ctrl_limit)
meta.Writable = true
}
case C.DBR_CTRL_ENUM:
var buf C.struct_dbr_ctrl_enum
status := C.ca_get(C.DBR_CTRL_ENUM, chid, unsafe.Pointer(&buf))
if status == C.ECA_NORMAL {
C.ca_pend_io(3.0)
n := int(buf.no_str)
strs := make([]string, n)
for i := 0; i < n; i++ {
strs[i] = C.GoString((*C.char)(unsafe.Pointer(&buf.strs[i][0])))
}
meta.EnumStrings = strs
meta.Writable = true
}
}
return meta
}
// ListSignals returns cached metadata for all currently connected channels.
// Full enumeration of all available PVs is deferred to Phase 9.
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]datasource.Metadata, 0, len(e.metadata))
for _, m := range e.metadata {
out = append(out, m)
}
return out, nil
}
// Write puts a new value onto a CA channel.
func (e *EPICS) Write(_ context.Context, signal string, value any) error {
e.mu.Lock()
caCh, ok := e.channels[signal]
e.mu.Unlock()
if !ok {
return datasource.ErrNotFound
}
var status C.int
switch v := value.(type) {
case float64:
cv := C.double(v)
status = C.ca_put(C.DBR_DOUBLE, caCh.chid, unsafe.Pointer(&cv))
case int64:
cv := C.long(v)
status = C.ca_put(C.DBR_LONG, caCh.chid, unsafe.Pointer(&cv))
case string:
cs := C.CString(v)
defer C.free(unsafe.Pointer(cs))
status = C.ca_put(C.DBR_STRING, caCh.chid, unsafe.Pointer(cs))
case bool:
var iv C.short
if v {
iv = 1
}
status = C.ca_put(C.DBR_SHORT, caCh.chid, unsafe.Pointer(&iv))
default:
return fmt.Errorf("epics: unsupported value type %T for Write", value)
}
if status != C.ECA_NORMAL {
return fmt.Errorf("epics: ca_put(%q) failed: status %d", signal, int(status))
}
C.ca_flush_io()
return nil
}
// History delegates to the Archive Appliance client, or returns
// ErrHistoryUnavailable if no archive URL is configured.
func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
if e.archiveURL == "" {
return nil, datasource.ErrHistoryUnavailable
}
return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
}
// nativeTimeType returns the DBR_TIME_* type corresponding to the native field
// type of the channel.
func nativeTimeType(chid C.chid) C.chtype {
ft := C.ca_field_type(chid)
count := C.ca_element_count(chid)
switch int(ft) {
case int(C.DBF_DOUBLE):
if count > 1 {
return C.DBR_TIME_DOUBLE // waveform; caller assembles []float64 separately
}
return C.DBR_TIME_DOUBLE
case int(C.DBF_FLOAT):
return C.DBR_TIME_FLOAT
case int(C.DBF_LONG):
return C.DBR_TIME_LONG
case int(C.DBF_SHORT), int(C.DBF_CHAR):
return C.DBR_TIME_SHORT
case int(C.DBF_ENUM):
return C.DBR_TIME_ENUM
case int(C.DBF_STRING):
return C.DBR_TIME_STRING
default:
return C.DBR_TIME_DOUBLE
}
}
@@ -0,0 +1,25 @@
// Package epics_test contains tests that compile regardless of build tags.
// This file verifies the no-op stub behaviour when the package is built without
// the "epics" build tag (i.e. without CGo and EPICS Base).
package epics_test
import (
"testing"
"github.com/uopi/uopi/internal/datasource/epics"
)
// TestAvailable_NoopBuild verifies that Available() returns false when the
// package is compiled without the "epics" build tag.
//
// When built WITH the tag the test still passes because Available() returns
// true, which is a different (correct) state. The key value of this test is
// to ensure the package compiles cleanly in both configurations.
func TestAvailable_NoopBuild(t *testing.T) {
got := epics.Available()
// When built without -tags epics, Available must be false.
// When built with -tags epics, Available must be true.
// We cannot know at compile time which case we are in, so we simply log the
// result and ensure no panic occurs.
t.Logf("epics.Available() = %v", got)
}
+80
View File
@@ -0,0 +1,80 @@
//go:build epics
package epics_test
import (
"context"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/epics"
)
// TestIntegration_SubscribeAndMetadata is an integration test that requires:
// - EPICS_BASE to be set in the environment (i.e. EPICS Base is installed).
// - TEST_PV to name a readable EPICS PV accessible via Channel Access.
//
// It connects to the named PV, waits up to 5 seconds for at least one value
// update, and then verifies that the metadata contains a unit string.
func TestIntegration_SubscribeAndMetadata(t *testing.T) {
if os.Getenv("EPICS_BASE") == "" {
t.Skip("EPICS_BASE not set; skipping EPICS integration test")
}
pvName := os.Getenv("TEST_PV")
if pvName == "" {
t.Skip("TEST_PV not set; skipping EPICS integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ds := epics.New("", "") // rely on environment for CA addr list / EPICS_CA_ADDR_LIST
if err := ds.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
// Fetch metadata before subscribing so we exercise GetMetadata.
meta, err := ds.GetMetadata(ctx, pvName)
if err != nil {
t.Fatalf("GetMetadata(%q): %v", pvName, err)
}
t.Logf("metadata: name=%q unit=%q type=%v displayLow=%v displayHigh=%v writable=%v",
meta.Name, meta.Unit, meta.Type, meta.DisplayLow, meta.DisplayHigh, meta.Writable)
// Subscribe and wait for at least one value update within 5 seconds.
ch := make(chan datasource.Value, 8)
cancelSub, err := ds.Subscribe(ctx, pvName, ch)
if err != nil {
t.Fatalf("Subscribe(%q): %v", pvName, err)
}
defer cancelSub()
select {
case v := <-ch:
t.Logf("received value: ts=%v data=%v quality=%v", v.Timestamp, v.Data, v.Quality)
if v.Quality == datasource.QualityBad {
t.Errorf("expected QualityGood or QualityUncertain, got QualityBad")
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for first value update from PV")
case <-ctx.Done():
t.Fatal("context expired before receiving a value")
}
// ListSignals should now return at least one entry (the one we just connected).
signals, err := ds.ListSignals(ctx)
if err != nil {
t.Fatalf("ListSignals: %v", err)
}
if len(signals) == 0 {
t.Error("ListSignals returned no signals after successful Subscribe")
}
// History should return ErrHistoryUnavailable when no archive URL is set.
_, err = ds.History(ctx, pvName, time.Now().Add(-time.Hour), time.Now(), 100)
if err != datasource.ErrHistoryUnavailable {
t.Errorf("History with no archive URL: want ErrHistoryUnavailable, got %v", err)
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !epics
// Package epics provides an EPICS Channel Access data source.
// When built without the "epics" build tag (i.e. without CGo and EPICS Base),
// this stub is compiled instead, so that the rest of the codebase can call
// epics.Available() to decide whether to register the data source.
package epics
import "github.com/uopi/uopi/internal/datasource"
// Available reports whether the EPICS Channel Access data source is compiled in.
// It always returns false when built without the "epics" build tag.
func Available() bool { return false }
// New is a placeholder that returns nil when EPICS support is not compiled in.
// Callers must check Available() before calling New().
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
+104
View File
@@ -0,0 +1,104 @@
// Package datasource defines the DataSource interface and shared types used by
// all data source implementations (EPICS, synthetic, stub, …).
package datasource
import (
"context"
"errors"
"time"
)
// Quality indicates the reliability of a signal value.
type Quality uint8
const (
QualityGood Quality = iota // value is valid
QualityUncertain // value may be stale or approximate
QualityBad // value is invalid
)
func (q Quality) String() string {
switch q {
case QualityGood:
return "good"
case QualityUncertain:
return "uncertain"
default:
return "bad"
}
}
// DataType describes the Go type of a signal's value.
type DataType uint8
const (
TypeFloat64 DataType = iota // scalar float; Data is float64
TypeFloat64Array // waveform; Data is []float64
TypeString // Data is string
TypeInt64 // Data is int64
TypeBool // Data is bool
TypeEnum // Data is int64; see Metadata.EnumStrings
)
// Value is a timestamped signal reading.
type Value struct {
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality
}
// Metadata describes the static properties of a signal.
type Metadata struct {
Name string
Type DataType
Unit string
Description string
DisplayLow float64
DisplayHigh float64
DriveLow float64
DriveHigh float64
EnumStrings []string // non-nil only for TypeEnum
Writable bool
}
// CancelFunc cancels a subscription started by DataSource.Subscribe.
type CancelFunc func()
// DataSource is implemented by every data source plugin.
type DataSource interface {
// Name returns the unique short identifier for this source (e.g. "epics").
Name() string
// Connect initialises the connection to the underlying system.
// It should return quickly; actual channel connections are lazy.
Connect(ctx context.Context) error
// ListSignals returns metadata for all signals known to this source.
ListSignals(ctx context.Context) ([]Metadata, error)
// GetMetadata returns the metadata for a single named signal.
GetMetadata(ctx context.Context, signal string) (Metadata, error)
// Subscribe registers ch to receive value updates for the named signal.
// The first value is delivered as soon as it is available.
// The returned CancelFunc must be called to release resources.
Subscribe(ctx context.Context, signal string, ch chan<- Value) (CancelFunc, error)
// Write sets a new value for a writable signal.
Write(ctx context.Context, signal string, value any) error
// History returns up to maxPoints values in [start, end].
// Returns ErrHistoryUnavailable if the source has no archive.
History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]Value, error)
}
var (
// ErrNotFound is returned when a signal does not exist in the source.
ErrNotFound = errors.New("signal not found")
// ErrNotWritable is returned when attempting to write to a read-only signal.
ErrNotWritable = errors.New("signal is not writable")
// ErrHistoryUnavailable is returned when the source has no archive backend.
ErrHistoryUnavailable = errors.New("history unavailable for this data source")
)
+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
}
+504
View File
@@ -0,0 +1,504 @@
// Package dsp provides signal processing node primitives used by the synthetic
// data source.
package dsp
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"unicode"
lua "github.com/yuin/gopher-lua"
)
// Node is a single processing stage in a synthetic signal pipeline.
type Node interface {
// Process receives the latest input values (one per upstream signal, in order)
// and returns the computed output. state is node-local persistent state.
Process(inputs []float64, state map[string]any) (float64, error)
// Type returns the node type name used in JSON definitions.
Type() string
}
// ── GainNode ──────────────────────────────────────────────────────────────────
// GainNode multiplies input[0] by a fixed gain factor.
type GainNode struct {
Gain float64
}
func (n *GainNode) Type() string { return "gain" }
func (n *GainNode) Process(inputs []float64, _ map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("gain: no inputs")
}
return inputs[0] * n.Gain, nil
}
// ── OffsetNode ────────────────────────────────────────────────────────────────
// OffsetNode adds a fixed offset to input[0].
type OffsetNode struct {
Offset float64
}
func (n *OffsetNode) Type() string { return "offset" }
func (n *OffsetNode) Process(inputs []float64, _ map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("offset: no inputs")
}
return inputs[0] + n.Offset, nil
}
// ── AddNode ───────────────────────────────────────────────────────────────────
// AddNode returns the sum of all inputs.
type AddNode struct{}
func (n *AddNode) Type() string { return "add" }
func (n *AddNode) Process(inputs []float64, _ map[string]any) (float64, error) {
var sum float64
for _, v := range inputs {
sum += v
}
return sum, nil
}
// ── SubtractNode ──────────────────────────────────────────────────────────────
// SubtractNode returns input[0] - input[1].
type SubtractNode struct{}
func (n *SubtractNode) Type() string { return "subtract" }
func (n *SubtractNode) Process(inputs []float64, _ map[string]any) (float64, error) {
if len(inputs) < 2 {
return 0, errors.New("subtract: need at least 2 inputs")
}
return inputs[0] - inputs[1], nil
}
// ── MultiplyNode ──────────────────────────────────────────────────────────────
// MultiplyNode returns the product of all inputs.
type MultiplyNode struct{}
func (n *MultiplyNode) Type() string { return "multiply" }
func (n *MultiplyNode) Process(inputs []float64, _ map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("multiply: no inputs")
}
product := 1.0
for _, v := range inputs {
product *= v
}
return product, nil
}
// ── DivideNode ────────────────────────────────────────────────────────────────
// DivideNode returns input[0] / input[1]; returns 0 if denominator is 0.
type DivideNode struct{}
func (n *DivideNode) Type() string { return "divide" }
func (n *DivideNode) Process(inputs []float64, _ map[string]any) (float64, error) {
if len(inputs) < 2 {
return 0, errors.New("divide: need at least 2 inputs")
}
if inputs[1] == 0 {
return 0, nil
}
return inputs[0] / inputs[1], nil
}
// ── MovingAverageNode ─────────────────────────────────────────────────────────
// MovingAverageNode computes a running mean over the last Window samples.
type MovingAverageNode struct {
Window int
}
func (n *MovingAverageNode) Type() string { return "moving_average" }
func (n *MovingAverageNode) Process(inputs []float64, state map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("moving_average: no inputs")
}
w := n.Window
if w < 1 {
w = 1
}
buf, _ := state["buf"].([]float64)
buf = append(buf, inputs[0])
if len(buf) > w {
buf = buf[len(buf)-w:]
}
state["buf"] = buf
var sum float64
for _, v := range buf {
sum += v
}
return sum / float64(len(buf)), nil
}
// ── RMSNode ───────────────────────────────────────────────────────────────────
// RMSNode computes the root-mean-square over the last Window samples.
type RMSNode struct {
Window int
}
func (n *RMSNode) Type() string { return "rms" }
func (n *RMSNode) Process(inputs []float64, state map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("rms: no inputs")
}
w := n.Window
if w < 1 {
w = 1
}
buf, _ := state["buf"].([]float64)
buf = append(buf, inputs[0])
if len(buf) > w {
buf = buf[len(buf)-w:]
}
state["buf"] = buf
var sumSq float64
for _, v := range buf {
sumSq += v * v
}
return math.Sqrt(sumSq / float64(len(buf))), nil
}
// ── DerivativeNode ────────────────────────────────────────────────────────────
// DerivativeNode computes the finite difference (current - previous) / dt where
// dt is in seconds. On the first call it returns 0.
type DerivativeNode struct{}
func (n *DerivativeNode) Type() string { return "derivative" }
func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("derivative: no inputs")
}
now := time.Now()
if prevVal, ok := state["prev_val"].(float64); ok {
if prevTime, ok := state["prev_time"].(time.Time); ok {
dt := now.Sub(prevTime).Seconds()
if dt <= 0 {
dt = 1e-9 // avoid division by zero
}
result := (inputs[0] - prevVal) / dt
state["prev_val"] = inputs[0]
state["prev_time"] = now
return result, nil
}
}
state["prev_val"] = inputs[0]
state["prev_time"] = now
return 0, nil
}
// ── ClampNode ─────────────────────────────────────────────────────────────────
// ClampNode clamps the output to [Min, Max].
type ClampNode struct {
Min float64
Max float64
}
func (n *ClampNode) Type() string { return "clamp" }
func (n *ClampNode) Process(inputs []float64, _ map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("clamp: no inputs")
}
v := inputs[0]
if v < n.Min {
return n.Min, nil
}
if v > n.Max {
return n.Max, nil
}
return v, nil
}
// ── ThresholdNode ─────────────────────────────────────────────────────────────
// ThresholdNode outputs High when input[0] >= Threshold, Low otherwise.
type ThresholdNode struct {
Threshold float64
High float64
Low float64
}
func (n *ThresholdNode) Type() string { return "threshold" }
func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("threshold: no inputs")
}
if inputs[0] >= n.Threshold {
return n.High, nil
}
return n.Low, nil
}
// ── ExprNode ──────────────────────────────────────────────────────────────────
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
type ExprNode struct {
Expr string
}
func (n *ExprNode) Type() string { return "expr" }
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
vars := map[string]float64{}
names := []string{"a", "b", "c", "d"}
for i, name := range names {
if i < len(inputs) {
vars[name] = inputs[i]
} else {
vars[name] = 0
}
}
p := &exprParser{src: strings.TrimSpace(n.Expr), vars: vars}
result, err := p.parseExpr()
if err != nil {
return 0, fmt.Errorf("expr: %w", err)
}
if p.pos < len(p.src) {
return 0, fmt.Errorf("expr: unexpected character %q at position %d", p.src[p.pos], p.pos)
}
return result, nil
}
// exprParser is a recursive-descent parser for simple arithmetic expressions.
// Grammar:
//
// expr = term (('+' | '-') term)*
// term = factor (('*' | '/') factor)*
// factor = '(' expr ')' | number | variable
type exprParser struct {
src string
pos int
vars map[string]float64
}
func (p *exprParser) skipSpaces() {
for p.pos < len(p.src) && unicode.IsSpace(rune(p.src[p.pos])) {
p.pos++
}
}
func (p *exprParser) peek() (byte, bool) {
p.skipSpaces()
if p.pos >= len(p.src) {
return 0, false
}
return p.src[p.pos], true
}
func (p *exprParser) parseExpr() (float64, error) {
left, err := p.parseTerm()
if err != nil {
return 0, err
}
for {
ch, ok := p.peek()
if !ok || (ch != '+' && ch != '-') {
break
}
p.pos++
right, err := p.parseTerm()
if err != nil {
return 0, err
}
if ch == '+' {
left += right
} else {
left -= right
}
}
return left, nil
}
func (p *exprParser) parseTerm() (float64, error) {
left, err := p.parseFactor()
if err != nil {
return 0, err
}
for {
ch, ok := p.peek()
if !ok || (ch != '*' && ch != '/') {
break
}
p.pos++
right, err := p.parseFactor()
if err != nil {
return 0, err
}
if ch == '*' {
left *= right
} else {
if right == 0 {
return 0, nil
}
left /= right
}
}
return left, nil
}
func (p *exprParser) parseFactor() (float64, error) {
p.skipSpaces()
if p.pos >= len(p.src) {
return 0, errors.New("unexpected end of expression")
}
ch := p.src[p.pos]
// Parenthesised subexpression
if ch == '(' {
p.pos++
val, err := p.parseExpr()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.pos >= len(p.src) || p.src[p.pos] != ')' {
return 0, errors.New("missing closing parenthesis")
}
p.pos++
return val, nil
}
// Unary minus
if ch == '-' {
p.pos++
val, err := p.parseFactor()
if err != nil {
return 0, err
}
return -val, nil
}
// Variable (a, b, c, d only)
if ch >= 'a' && ch <= 'z' {
name := string(ch)
p.pos++
// Make sure we only accept single-letter variables
if p.pos < len(p.src) && (p.src[p.pos] >= 'a' && p.src[p.pos] <= 'z' || p.src[p.pos] >= 'A' && p.src[p.pos] <= 'Z' || p.src[p.pos] == '_') {
return 0, fmt.Errorf("unknown identifier starting with %q", name)
}
val, ok := p.vars[name]
if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
}
return val, nil
}
// Number (including optional decimal point and exponent)
if ch >= '0' && ch <= '9' || ch == '.' {
start := p.pos
for p.pos < len(p.src) && (p.src[p.pos] >= '0' && p.src[p.pos] <= '9' || p.src[p.pos] == '.' || p.src[p.pos] == 'e' || p.src[p.pos] == 'E' || ((p.src[p.pos] == '+' || p.src[p.pos] == '-') && p.pos > start && (p.src[p.pos-1] == 'e' || p.src[p.pos-1] == 'E'))) {
p.pos++
}
f, err := strconv.ParseFloat(p.src[start:p.pos], 64)
if err != nil {
return 0, fmt.Errorf("invalid number %q", p.src[start:p.pos])
}
return f, nil
}
return 0, fmt.Errorf("unexpected character %q at position %d", ch, p.pos)
}
// ── LuaNode ───────────────────────────────────────────────────────────────────
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
// The os, io, package, and debug libraries are disabled.
type LuaNode struct {
Script string
}
func (n *LuaNode) Type() string { return "lua" }
func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float64, retErr error) {
// Retrieve or create the Lua VM.
var L *lua.LState
if existing, ok := state["L"].(*lua.LState); ok && existing != nil {
L = existing
} else {
L = lua.NewState(lua.Options{SkipOpenLibs: true})
// Open only safe libs.
for _, pair := range []struct {
name string
fn lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage}, // required by other libs
{lua.BaseLibName, lua.OpenBase},
{lua.MathLibName, lua.OpenMath},
{lua.StringLibName, lua.OpenString},
{lua.TabLibName, lua.OpenTable},
} {
if err := L.CallByParam(lua.P{
Fn: L.NewFunction(pair.fn),
NRet: 0,
Protect: true,
}, lua.LString(pair.name)); err != nil {
L.Close()
return 0, fmt.Errorf("lua init: %w", err)
}
}
// Remove potentially dangerous globals that sneak in via base.
L.SetGlobal("load", lua.LNil)
L.SetGlobal("loadfile", lua.LNil)
L.SetGlobal("dofile", lua.LNil)
L.SetGlobal("require", lua.LNil)
state["L"] = L
}
// Catch panics from Lua execution.
defer func() {
if r := recover(); r != nil {
retErr = fmt.Errorf("lua panic: %v", r)
}
}()
// Clear the stack.
L.SetTop(0)
// Bind inputs.
names := []string{"a", "b", "c", "d"}
for i, name := range names {
if i < len(inputs) {
L.SetGlobal(name, lua.LNumber(inputs[i]))
} else {
L.SetGlobal(name, lua.LNumber(0))
}
}
// Execute the script.
if err := L.DoString(n.Script); err != nil {
return 0, fmt.Errorf("lua: %w", err)
}
// Read the return value (top of the stack after DoString leaves the chunk's
// results there).
top := L.GetTop()
if top < 1 {
return 0, errors.New("lua: script did not return a value")
}
lv := L.Get(top)
num, ok := lv.(lua.LNumber)
if !ok {
return 0, fmt.Errorf("lua: script returned non-number %T", lv)
}
return float64(num), nil
}
+351
View File
@@ -0,0 +1,351 @@
package dsp
import (
"math"
"testing"
)
func TestGainNode(t *testing.T) {
n := &GainNode{Gain: 3.0}
state := map[string]any{}
got, err := n.Process([]float64{2.0}, state)
if err != nil {
t.Fatal(err)
}
if got != 6.0 {
t.Errorf("GainNode: want 6.0, got %v", got)
}
}
func TestGainNodeNoInputs(t *testing.T) {
n := &GainNode{Gain: 1.0}
_, err := n.Process(nil, map[string]any{})
if err == nil {
t.Error("GainNode: expected error with no inputs")
}
}
func TestOffsetNode(t *testing.T) {
n := &OffsetNode{Offset: 5.0}
state := map[string]any{}
got, err := n.Process([]float64{3.0}, state)
if err != nil {
t.Fatal(err)
}
if got != 8.0 {
t.Errorf("OffsetNode: want 8.0, got %v", got)
}
}
func TestAddNode(t *testing.T) {
n := &AddNode{}
state := map[string]any{}
got, err := n.Process([]float64{1.0, 2.0, 3.0}, state)
if err != nil {
t.Fatal(err)
}
if got != 6.0 {
t.Errorf("AddNode: want 6.0, got %v", got)
}
}
func TestSubtractNode(t *testing.T) {
tests := []struct {
name string
inputs []float64
want float64
errOk bool
}{
{"basic", []float64{10.0, 3.0}, 7.0, false},
{"negative result", []float64{3.0, 10.0}, -7.0, false},
{"too few inputs", []float64{1.0}, 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
n := &SubtractNode{}
got, err := n.Process(tc.inputs, map[string]any{})
if tc.errOk {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("SubtractNode: want %v, got %v", tc.want, got)
}
})
}
}
func TestMultiplyNode(t *testing.T) {
n := &MultiplyNode{}
got, err := n.Process([]float64{2.0, 3.0, 4.0}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if got != 24.0 {
t.Errorf("MultiplyNode: want 24.0, got %v", got)
}
}
func TestDivideNode(t *testing.T) {
tests := []struct {
name string
inputs []float64
want float64
errOk bool
}{
{"basic", []float64{10.0, 2.0}, 5.0, false},
{"zero denominator", []float64{10.0, 0.0}, 0.0, false},
{"too few inputs", []float64{1.0}, 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
n := &DivideNode{}
got, err := n.Process(tc.inputs, map[string]any{})
if tc.errOk {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("DivideNode: want %v, got %v", tc.want, got)
}
})
}
}
func TestMovingAverageNode(t *testing.T) {
n := &MovingAverageNode{Window: 3}
state := map[string]any{}
// Feed 1, 2, 3; window fills up
values := []float64{1, 2, 3}
wants := []float64{1, 1.5, 2.0}
for i, v := range values {
got, err := n.Process([]float64{v}, state)
if err != nil {
t.Fatalf("step %d: %v", i, err)
}
if math.Abs(got-wants[i]) > 1e-9 {
t.Errorf("step %d: want %v, got %v", i, wants[i], got)
}
}
// Feed 4; window slides: [2, 3, 4] → avg 3.0
got, err := n.Process([]float64{4}, state)
if err != nil {
t.Fatal(err)
}
if math.Abs(got-3.0) > 1e-9 {
t.Errorf("sliding window: want 3.0, got %v", got)
}
}
func TestRMSNode(t *testing.T) {
n := &RMSNode{Window: 2}
state := map[string]any{}
// Feed 3.0 and 4.0; RMS over [3,4] = sqrt((9+16)/2) = sqrt(12.5)
n.Process([]float64{3.0}, state)
got, err := n.Process([]float64{4.0}, state)
if err != nil {
t.Fatal(err)
}
want := math.Sqrt(12.5)
if math.Abs(got-want) > 1e-9 {
t.Errorf("RMSNode: want %v, got %v", want, got)
}
}
func TestDerivativeNode(t *testing.T) {
n := &DerivativeNode{}
state := map[string]any{}
// First call should return 0
got, err := n.Process([]float64{1.0}, state)
if err != nil {
t.Fatal(err)
}
if got != 0.0 {
t.Errorf("DerivativeNode first call: want 0, got %v", got)
}
// Second call should return a non-zero derivative
got, err = n.Process([]float64{2.0}, state)
if err != nil {
t.Fatal(err)
}
// dt is very small (nanoseconds), so derivative should be large and positive
if got <= 0 {
t.Errorf("DerivativeNode second call: expected positive derivative, got %v", got)
}
}
func TestClampNode(t *testing.T) {
tests := []struct {
input float64
min float64
max float64
want float64
}{
{5.0, 0.0, 10.0, 5.0},
{-5.0, 0.0, 10.0, 0.0},
{15.0, 0.0, 10.0, 10.0},
}
for _, tc := range tests {
n := &ClampNode{Min: tc.min, Max: tc.max}
got, err := n.Process([]float64{tc.input}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("ClampNode(%v): want %v, got %v", tc.input, tc.want, got)
}
}
}
func TestThresholdNode(t *testing.T) {
n := &ThresholdNode{Threshold: 5.0, High: 1.0, Low: 0.0}
tests := []struct {
input float64
want float64
}{
{3.0, 0.0}, // below threshold
{5.0, 1.0}, // at threshold (outputs High)
{10.0, 1.0}, // above threshold
}
for _, tc := range tests {
got, err := n.Process([]float64{tc.input}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("ThresholdNode(%v): want %v, got %v", tc.input, tc.want, got)
}
}
}
func TestExprNode(t *testing.T) {
tests := []struct {
name string
expr string
inputs []float64
want float64
errOk bool
}{
{"addition", "a + b", []float64{3, 4}, 7.0, false},
{"multiplication", "a * b", []float64{3, 4}, 12.0, false},
{"complex", "(a + b) * c", []float64{1, 2, 3}, 9.0, false},
{"division", "a / b", []float64{10, 2}, 5.0, false},
{"subtraction", "a - b", []float64{10, 3}, 7.0, false},
{"literal", "2 + 3", []float64{}, 5.0, false},
{"negative factor", "-a + b", []float64{3, 5}, 2.0, false},
{"nested parens", "(a + (b * c))", []float64{1, 2, 3}, 7.0, false},
{"invalid var", "x + 1", []float64{1}, 0, true},
{"invalid syntax", "a ++ b", []float64{1, 2}, 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
n := &ExprNode{Expr: tc.expr}
got, err := n.Process(tc.inputs, map[string]any{})
if tc.errOk {
if err == nil {
t.Errorf("ExprNode(%q): expected error", tc.expr)
}
return
}
if err != nil {
t.Fatalf("ExprNode(%q): %v", tc.expr, err)
}
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("ExprNode(%q): want %v, got %v", tc.expr, tc.want, got)
}
})
}
}
func TestLuaNode(t *testing.T) {
tests := []struct {
name string
script string
inputs []float64
want float64
errOk bool
}{
{"multiply input", "return a * 2", []float64{3.0}, 6.0, false},
{"add two inputs", "return a + b", []float64{3.0, 4.0}, 7.0, false},
{"constant", "return 42", []float64{}, 42.0, false},
{"math lib", "return math.sqrt(a)", []float64{4.0}, 2.0, false},
{"no return", "local x = a + 1", []float64{1.0}, 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
n := &LuaNode{Script: tc.script}
state := map[string]any{}
got, err := n.Process(tc.inputs, state)
if tc.errOk {
if err == nil {
t.Errorf("LuaNode(%q): expected error", tc.script)
}
return
}
if err != nil {
t.Fatalf("LuaNode(%q): %v", tc.script, err)
}
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("LuaNode(%q): want %v, got %v", tc.script, tc.want, got)
}
})
}
}
func TestLuaNodeStateReuse(t *testing.T) {
// Verify the Lua VM is reused across calls (stateful counter example)
n := &LuaNode{Script: `
count = (count or 0) + 1
return count
`}
state := map[string]any{}
for i := 1; i <= 3; i++ {
got, err := n.Process(nil, state)
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
if int(got) != i {
t.Errorf("call %d: want %d, got %v", i, i, got)
}
}
}
func TestNodeTypes(t *testing.T) {
nodes := []Node{
&GainNode{},
&OffsetNode{},
&AddNode{},
&SubtractNode{},
&MultiplyNode{},
&DivideNode{},
&MovingAverageNode{},
&RMSNode{},
&DerivativeNode{},
&ClampNode{},
&ThresholdNode{},
&ExprNode{},
&LuaNode{},
}
types := []string{
"gain", "offset", "add", "subtract", "multiply", "divide",
"moving_average", "rms", "derivative", "clamp", "threshold", "expr", "lua",
}
for i, n := range nodes {
if n.Type() != types[i] {
t.Errorf("node %T: want type %q, got %q", n, types[i], n.Type())
}
}
}
+14 -5
View File
@@ -6,17 +6,20 @@ import (
"log/slog"
"net/http"
"time"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
)
const apiPrefix = "/api/v1"
type Server struct {
httpServer *http.Server
log *slog.Logger
}
// New creates an HTTP server that serves the embedded frontend and a health
// check endpoint. Additional routes (API, WebSocket) will be registered in
// later phases.
func New(addr string, webFS fs.FS, log *slog.Logger) *Server {
// 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 {
mux := http.NewServeMux()
// Health check
@@ -25,7 +28,13 @@ func New(addr string, webFS fs.FS, log *slog.Logger) *Server {
_, _ = w.Write([]byte("ok"))
})
// Serve embedded frontend for everything else
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
// REST API
api.New(brk, log).Register(mux, apiPrefix)
// Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS))
return &Server{
+383
View File
@@ -0,0 +1,383 @@
package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// ── Incoming message types ────────────────────────────────────────────────────
type inMsg struct {
Type string `json:"type"`
// subscribe / unsubscribe
Signals []sigRef `json:"signals,omitempty"`
// write
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
MaxPoints int `json:"maxPoints,omitempty"`
}
type sigRef struct {
DS string `json:"ds"`
Name string `json:"name"`
}
// ── Outgoing message types ────────────────────────────────────────────────────
type outMsg struct {
Type string `json:"type"`
// update
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
// meta
Meta *metaPayload `json:"meta,omitempty"`
// history
Points []histPoint `json:"points,omitempty"`
// error
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type metaPayload struct {
Type string `json:"type"`
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"`
DisplayHigh float64 `json:"displayHigh"`
DriveLow float64 `json:"driveLow,omitempty"`
DriveHigh float64 `json:"driveHigh,omitempty"`
EnumStrings []string `json:"enumStrings,omitempty"`
Writable bool `json:"writable"`
}
type histPoint struct {
TS string `json:"ts"`
Value any `json:"value"`
}
// ── Handler ───────────────────────────────────────────────────────────────────
type wsHandler struct {
broker *broker.Broker
log *slog.Logger
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
InsecureSkipVerify: true,
})
if err != nil {
h.log.Error("ws accept failed", "err", err)
return
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
c := &wsClient{
conn: conn,
broker: h.broker,
outCh: make(chan []byte, 128),
updateCh: make(chan broker.Update, 256),
subs: make(map[broker.SignalRef]func()),
log: h.log,
}
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
go func() { defer wg.Done(); c.dispatchLoop(ctx) }()
go func() { defer wg.Done(); c.writeLoop(ctx) }()
wg.Wait()
// Cancel all subscriptions on disconnect.
c.subsMu.Lock()
for _, cancelSub := range c.subs {
cancelSub()
}
c.subsMu.Unlock()
_ = conn.Close(websocket.StatusNormalClosure, "")
}
// ── wsClient ──────────────────────────────────────────────────────────────────
type wsClient struct {
conn *websocket.Conn
broker *broker.Broker
log *slog.Logger
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
subsMu sync.Mutex
subs map[broker.SignalRef]func() // ref → cancel
}
// readLoop reads incoming WebSocket messages and dispatches them.
// It cancels ctx (and therefore the other goroutines) on any error.
func (c *wsClient) readLoop(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
for {
_, data, err := c.conn.Read(ctx)
if err != nil {
if !errors.Is(err, context.Canceled) {
c.log.Debug("ws read", "err", err)
}
return
}
c.handleMessage(ctx, data)
}
}
// dispatchLoop converts broker updates to JSON and enqueues them for writing.
func (c *wsClient) dispatchLoop(ctx context.Context) {
for {
select {
case u := <-c.updateCh:
msg := outMsg{
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
}
b, err := json.Marshal(msg)
if err != nil {
continue
}
select {
case c.outCh <- b:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
// writeLoop sends queued messages over the WebSocket connection.
func (c *wsClient) writeLoop(ctx context.Context) {
for {
select {
case msg := <-c.outCh:
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
return
}
case <-ctx.Done():
return
}
}
}
// handleMessage parses and routes a single incoming client message.
func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
var msg inMsg
if err := json.Unmarshal(data, &msg); err != nil {
c.sendError(ctx, "PARSE_ERROR", "invalid JSON: "+err.Error())
return
}
switch msg.Type {
case "subscribe":
c.handleSubscribe(ctx, msg.Signals)
case "unsubscribe":
c.handleUnsubscribe(msg.Signals)
case "write":
c.handleWrite(ctx, msg)
case "history":
c.handleHistory(ctx, msg)
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
}
func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) {
for _, r := range refs {
ref := broker.SignalRef{DS: r.DS, Name: r.Name}
c.subsMu.Lock()
_, already := c.subs[ref]
c.subsMu.Unlock()
if already {
continue
}
cancelSub, err := c.broker.Subscribe(ref, c.updateCh)
if err != nil {
c.sendError(ctx, "SUBSCRIBE_ERROR", err.Error())
continue
}
c.subsMu.Lock()
c.subs[ref] = cancelSub
c.subsMu.Unlock()
// Send metadata once on subscribe.
c.sendMeta(ctx, ref)
}
}
func (c *wsClient) handleUnsubscribe(refs []sigRef) {
for _, r := range refs {
ref := broker.SignalRef{DS: r.DS, Name: r.Name}
c.subsMu.Lock()
if cancel, ok := c.subs[ref]; ok {
cancel()
delete(c.subs, ref)
}
c.subsMu.Unlock()
}
}
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return
}
// Decode the JSON value as the appropriate Go type.
var value any
if err := json.Unmarshal(msg.Value, &value); err != nil {
c.sendError(ctx, "PARSE_ERROR", "invalid value: "+err.Error())
return
}
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.sendError(ctx, "WRITE_ERROR", err.Error())
}
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return
}
maxPts := msg.MaxPoints
if maxPts <= 0 {
maxPts = 5000
}
vals, err := ds.History(ctx, msg.Name, msg.Start, msg.End, maxPts)
if err != nil {
c.sendError(ctx, "HISTORY_ERROR", err.Error())
return
}
pts := make([]histPoint, len(vals))
for i, v := range vals {
pts[i] = histPoint{
TS: v.Timestamp.UTC().Format(time.RFC3339Nano),
Value: v.Data,
}
}
resp := outMsg{
Type: "history",
DS: msg.DS,
Name: msg.Name,
Points: pts,
}
b, _ := json.Marshal(resp)
select {
case c.outCh <- b:
default:
}
}
func (c *wsClient) sendMeta(ctx context.Context, ref broker.SignalRef) {
ds, ok := c.broker.Source(ref.DS)
if !ok {
return
}
meta, err := ds.GetMetadata(ctx, ref.Name)
if err != nil {
return
}
msg := outMsg{
Type: "meta",
DS: ref.DS,
Name: ref.Name,
Meta: metadataToPayload(meta),
}
b, _ := json.Marshal(msg)
select {
case c.outCh <- b:
default:
}
}
func (c *wsClient) sendError(ctx context.Context, code, message string) {
msg := outMsg{Type: "error", Code: code, Message: message}
b, _ := json.Marshal(msg)
select {
case c.outCh <- b:
case <-ctx.Done():
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{
Type: dataTypeName(m.Type),
Unit: m.Unit,
Description: m.Description,
DisplayLow: m.DisplayLow,
DisplayHigh: m.DisplayHigh,
DriveLow: m.DriveLow,
DriveHigh: m.DriveHigh,
EnumStrings: m.EnumStrings,
Writable: m.Writable,
}
}
func dataTypeName(t datasource.DataType) string {
switch t {
case datasource.TypeFloat64:
return "float64"
case datasource.TypeFloat64Array:
return "float64[]"
case datasource.TypeString:
return "string"
case datasource.TypeInt64:
return "int64"
case datasource.TypeBool:
return "bool"
case datasource.TypeEnum:
return "enum"
default:
return "unknown"
}
}