From 8b548ba1c2482e7feb5a64949acaa4a0490a8df2 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 24 Apr 2026 15:46:04 +0200 Subject: [PATCH] Phase 2/4 done, working on phase 3/5 --- .gitignore | 7 + Makefile | 14 +- cmd/uopi/main.go | 35 +- docs/WORK_PLAN.md | 158 ++- go.mod | 10 +- go.sum | 6 + internal/api/api.go | 163 +++ internal/broker/broker.go | 177 +++ internal/broker/broker_test.go | 156 +++ internal/datasource/epics/archive.go | 148 +++ internal/datasource/epics/ca_callback.go | 138 ++ internal/datasource/epics/ca_wrapper.h | 118 ++ internal/datasource/epics/epics.go | 458 +++++++ internal/datasource/epics/epics_noop_test.go | 25 + internal/datasource/epics/epics_test.go | 80 ++ internal/datasource/epics/noop.go | 17 + internal/datasource/iface.go | 104 ++ internal/datasource/stub/stub.go | 185 +++ internal/dsp/nodes.go | 504 ++++++++ internal/dsp/nodes_test.go | 351 +++++ internal/server/server.go | 19 +- internal/server/ws.go | 383 ++++++ web/package-lock.json | 1212 +++++++++++++++++- web/package.json | 10 +- web/src/App.svelte | 121 +- web/src/lib/Canvas.svelte | 98 ++ web/src/lib/EditMode.svelte | 20 + web/src/lib/InterfaceList.svelte | 214 ++++ web/src/lib/ViewMode.svelte | 175 +++ web/src/lib/stores.ts | 157 +++ web/src/lib/types.ts | 42 + web/src/lib/widgets/BarH.svelte | 132 ++ web/src/lib/widgets/BarV.svelte | 136 ++ web/src/lib/widgets/Button.svelte | 66 + web/src/lib/widgets/Gauge.svelte | 187 +++ web/src/lib/widgets/Led.svelte | 99 ++ web/src/lib/widgets/MultiLed.svelte | 108 ++ web/src/lib/widgets/PlotWidget.svelte | 378 ++++++ web/src/lib/widgets/SetValue.svelte | 170 +++ web/src/lib/widgets/TextView.svelte | 99 ++ web/src/lib/ws.ts | 206 +++ web/src/lib/xml.ts | 66 + web/vite.config.ts | 4 + 43 files changed, 6800 insertions(+), 156 deletions(-) create mode 100644 .gitignore create mode 100644 internal/api/api.go create mode 100644 internal/broker/broker.go create mode 100644 internal/broker/broker_test.go create mode 100644 internal/datasource/epics/archive.go create mode 100644 internal/datasource/epics/ca_callback.go create mode 100644 internal/datasource/epics/ca_wrapper.h create mode 100644 internal/datasource/epics/epics.go create mode 100644 internal/datasource/epics/epics_noop_test.go create mode 100644 internal/datasource/epics/epics_test.go create mode 100644 internal/datasource/epics/noop.go create mode 100644 internal/datasource/iface.go create mode 100644 internal/datasource/stub/stub.go create mode 100644 internal/dsp/nodes.go create mode 100644 internal/dsp/nodes_test.go create mode 100644 internal/server/ws.go create mode 100644 web/src/lib/Canvas.svelte create mode 100644 web/src/lib/EditMode.svelte create mode 100644 web/src/lib/InterfaceList.svelte create mode 100644 web/src/lib/ViewMode.svelte create mode 100644 web/src/lib/stores.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/lib/widgets/BarH.svelte create mode 100644 web/src/lib/widgets/BarV.svelte create mode 100644 web/src/lib/widgets/Button.svelte create mode 100644 web/src/lib/widgets/Gauge.svelte create mode 100644 web/src/lib/widgets/Led.svelte create mode 100644 web/src/lib/widgets/MultiLed.svelte create mode 100644 web/src/lib/widgets/PlotWidget.svelte create mode 100644 web/src/lib/widgets/SetValue.svelte create mode 100644 web/src/lib/widgets/TextView.svelte create mode 100644 web/src/lib/ws.ts create mode 100644 web/src/lib/xml.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc8567e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +dist/ +web/dist/ +web/node_modules/ +interfaces/ +synthetic.json +uopi.toml +*.local.toml diff --git a/Makefile b/Makefile index 5879c04..11f143a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all frontend backend test clean +.PHONY: all frontend backend backend-epics test test-epics clean all: frontend backend @@ -8,6 +8,14 @@ frontend: backend: go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi +# Build backend with EPICS Channel Access support. +# Requires EPICS Base to be installed and the following environment variables: +# EPICS_BASE=/path/to/epics/base +# CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux" +# CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom" +backend-epics: + CGO_ENABLED=1 go build -tags epics -ldflags="-s -w" -o dist/uopi ./cmd/uopi + # Build backend without -s -w for debugging backend-debug: go build -o dist/uopi ./cmd/uopi @@ -16,6 +24,10 @@ test: go test ./... cd web && npm run check +# Run EPICS integration tests (requires EPICS Base; see backend-epics target). +test-epics: + go test -tags epics ./internal/datasource/epics/... + lint: go vet ./... cd web && npm run lint diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 7f389bb..6757a3a 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "embed" "flag" "fmt" "io/fs" @@ -10,11 +11,18 @@ import ( "os/signal" "syscall" + "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/config" + "github.com/uopi/uopi/internal/datasource/epics" + "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/server" "github.com/uopi/uopi/web" ) +// The web package's embed.go embeds web/dist at build time. +// This blank import ensures the compiler includes the package. +var _ embed.FS + func main() { configPath := flag.String("config", "", "path to TOML config file (optional)") flag.Parse() @@ -38,11 +46,34 @@ func main() { os.Exit(1) } - srv := server.New(cfg.Server.Listen, webFS, log) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + brk := broker.New(ctx, log) + + // Register data sources + if cfg.Synthetic.Enabled { + ds := stub.New() + if err := ds.Connect(ctx); err != nil { + log.Error("stub connect", "err", err) + os.Exit(1) + } + brk.Register(ds) + } + // Register EPICS Channel Access data source (Phase 2). + // epics.Available() returns false when built without -tags epics so that + // machines without EPICS Base installed still compile and run correctly. + if cfg.EPICS.Enabled && epics.Available() { + ds := epics.New(cfg.EPICS.CAAddrList, cfg.EPICS.ArchiveURL) + if err := ds.Connect(ctx); err != nil { + log.Error("epics connect", "err", err) + } else { + brk.Register(ds) + } + } + + srv := server.New(cfg.Server.Listen, webFS, brk, log) + if err := srv.Start(ctx); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) os.Exit(1) diff --git a/docs/WORK_PLAN.md b/docs/WORK_PLAN.md index 3a7d134..9e0addb 100644 --- a/docs/WORK_PLAN.md +++ b/docs/WORK_PLAN.md @@ -18,61 +18,82 @@ --- -## Phase 0 — Scaffold +## Phase 0 — Scaffold ✅ **Goal:** working build pipeline, empty binary that serves a placeholder page. -- [ ] Initialise Go module (`go mod init github.com/org/uopi`) -- [ ] Create directory layout: `cmd/uopi/`, `internal/`, `web/` -- [ ] Svelte + Vite + TypeScript project in `web/` -- [ ] `//go:embed web/dist` in backend; `Makefile` builds frontend then backend -- [ ] HTTP server on configurable port; serves embedded frontend -- [ ] TOML config loading (`BurntSushi/toml` or `pelletier/go-toml`) -- [ ] GitHub Actions CI: `make test` on push -- [ ] `README.md` with quick-start instructions +- [x] Initialise Go module (`go mod init github.com/uopi/uopi`) +- [x] Create directory layout: `cmd/uopi/`, `internal/`, `web/` +- [x] Svelte + Vite + TypeScript project in `web/` +- [x] `//go:embed web/dist` in backend via `web/embed.go`; `Makefile` builds frontend then backend +- [x] HTTP server on configurable port; serves embedded frontend +- [x] TOML config loading (`BurntSushi/toml`) with `UOPI_*` env var overrides +- [x] GitHub Actions CI: runs `go test ./...` and `npm run check` on push +- [x] `README.md` with quick-start instructions -**Done when:** `./dist/uopi` starts and serves an "under construction" page. +**Done when:** `./dist/uopi` starts and serves an "under construction" page. ✅ + +**Notes:** +- Embed lives in `web/embed.go` (not `cmd/uopi/`) because `//go:embed` paths cannot use `..` +- CI runs commands directly (not via `make test`) but is functionally equivalent --- -## Phase 1 — Core Backend +## Phase 1 — Core Backend ✅ **Goal:** signal broker and WebSocket protocol working with a stub data source. -- [ ] Define `DataSource` interface (`internal/datasource/iface.go`) -- [ ] Implement in-memory stub data source (sine wave emitter) for development -- [ ] Implement `Broker` (`internal/broker/`): - - Subscribe / unsubscribe with reference counting - - Fan-out goroutine per signal - - Clean teardown when last subscriber leaves -- [ ] WebSocket handler (`internal/server/ws.go`): - - `subscribe` / `unsubscribe` / `write` / `history` messages - - Pushes `update` and `meta` messages to client - - Graceful close on disconnect -- [ ] REST API skeleton (`internal/api/`): datasources, signals, interfaces endpoints (stub responses) -- [ ] Unit tests: broker fan-out, subscribe/unsubscribe lifecycle +- [x] Define `DataSource` interface (`internal/datasource/iface.go`) +- [x] Implement in-memory stub data source: `sine_1hz`, `sine_01hz`, `ramp_10s`, `toggle_1hz`, `noise`, writable `setpoint` — all at 10 Hz +- [x] Implement `Broker` (`internal/broker/broker.go`): + - [x] Subscribe / unsubscribe with reference counting + - [x] Fan-out goroutine per signal + - [x] Clean teardown when last subscriber leaves +- [x] WebSocket handler (`internal/server/ws.go`): + - [x] `subscribe` / `unsubscribe` / `write` / `history` messages + - [x] Pushes `update` and `meta` messages to client + - [x] Graceful close on disconnect (all subscriptions cancelled) +- [x] REST API skeleton (`internal/api/api.go`): + - [x] `GET /api/v1/datasources` — lists registered sources + - [x] `GET /api/v1/signals?ds=` — lists signals for a source + - [x] Interface endpoints return 501 stubs (storage in Phase 6) +- [x] Unit tests: fan-out, teardown on last-unsub, unknown DS/signal, multi-signal (5 tests, all passing) -**Done when:** a `wscat` client can subscribe to the stub source and receive timed updates. +**Done when:** a `wscat` client can subscribe to the stub source and receive timed updates. ✅ + +**Notes:** +- WebSocket signal refs use `{"ds": "stub", "name": "sine_1hz"}` objects (not `"stub:sine_1hz"` strings) to avoid ambiguity with EPICS PV names containing colons +- `broker.ActiveSubscriptions()` exposed for test assertions +- WebSocket library switched from deprecated `nhooyr.io/websocket` to maintained `github.com/coder/websocket` (identical API) --- -## Phase 2 — EPICS Data Source +## Phase 2 — EPICS Data Source ✅ **Goal:** real EPICS PVs readable and writable through the broker. -- [ ] CGo wrapper for EPICS `libca` (`internal/datasource/epics/`): - - Context init and cleanup - - `ca_create_channel` with connection callback - - `ca_add_event` monitor with value callback - - `ca_put` for writes - - DBR_CTRL get for metadata (units, limits, enum strings) -- [ ] Map CA DBR types to internal `DataType` enum -- [ ] Implement `DataSource` interface for EPICS -- [ ] Config: `ca_addr_list`, reconnect interval -- [ ] Handle disconnected/reconnected channels gracefully (quality = Bad/Uncertain) -- [ ] Integration test with SoftIOC (gated on `EPICS_BASE` env var) +- [x] CGo wrapper for EPICS `libca` (`internal/datasource/epics/`): + - [x] Context init with `ca_enable_preemptive_callback` (no polling loop) + - [x] `ca_create_channel` with connection callback shim + - [x] `ca_add_event` monitor with value callback (`DBR_TIME_*` types) + - [x] `ca_put` for writes (float64, int64, string, bool) + - [x] `ca_get` for DBR_CTRL metadata (units, limits, enum strings) +- [x] Map CA DBR types to internal `DataType` enum; CA severity → Quality +- [x] Implement `DataSource` interface for EPICS (`epics.go`) +- [x] Config: `ca_addr_list` (overrides `EPICS_CA_ADDR_LIST`); archive URL +- [x] Handle disconnected channels gracefully (quality = Bad via connection callback) +- [x] Integration test with SoftIOC (gated on `EPICS_BASE` + `TEST_PV` env vars) +- [x] `noop.go` (`//go:build !epics`) — package compiles without EPICS; `epics.Available()` returns false +- [x] `archive.go` — Archive Appliance JSON HTTP client for `History()` +- [x] `make backend-epics` and `make test-epics` targets added to Makefile +- [x] CI comment explains EPICS build requirements; no EPICS step in CI runners -**Done when:** a PV subscription round-trips from IOC → broker → WebSocket client with correct timestamp and units. +**Done when:** a PV subscription round-trips from IOC → broker → WebSocket client with correct timestamp and units. ✅ (verified by design; integration test requires live IOC) + +**Notes:** +- Uses `//go:build epics` on all CGo files; clean build without the tag: `go build ./...` passes +- Handle table (Go map, mutex-protected) maps C-side `uintptr_t` back to Go channels; callbacks are minimal (no alloc) +- `epics.Available()` checked in `main.go` before registration so EPICS-less builds silently skip --- @@ -99,21 +120,30 @@ --- -## Phase 4 — Frontend Foundation +## Phase 4 — Frontend Foundation ✅ **Goal:** Svelte app connects to WebSocket and reactively displays values. -- [ ] Svelte project structure: routes for view (`/`) and edit (`/edit`) -- [ ] WebSocket client (`ws.ts`): connect, reconnect, message dispatch -- [ ] Signal store factory (`stores.ts`): `getStore(signalName)` returns reactive store -- [ ] Subscription manager: reference counting mirrors broker's -- [ ] View mode shell: collapsible interface list pane + empty canvas area -- [ ] Fetch and list interfaces from REST API -- [ ] Load interface XML; parse widget definitions -- [ ] Render a minimal text-view widget reactively from signal store -- [ ] Basic responsive layout; DPI adaptation for canvas +- [x] App shell (`App.svelte`): mode state (`view` | `edit`), WS connect on init, disconnect banner, `--dpr` CSS variable +- [x] WebSocket client (`ws.ts`): singleton `WsClient`, exponential back-off reconnect (500 ms→30 s), re-subscribe all signals on reconnect, `status` Svelte readable store +- [x] Reference-counted subscriptions in `WsClient`: one WS message per unique signal regardless of number of callers; `unsubscribe` sent when ref-count hits zero +- [x] Signal store factory (`stores.ts`): `getSignalStore(ref)` and `getMetaStore(ref)` — lazily created Svelte readable stores, WS subscription started on first use +- [x] `types.ts`: `SignalRef`, `SignalValue`, `SignalMeta`, `Widget`, `Interface` +- [x] `xml.ts`: `parseInterface(xml)` — `DOMParser`-based XML→`Interface` converter +- [x] View mode shell (`ViewMode.svelte`): collapsible `InterfaceList` pane (260 px) + `Canvas`, toolbar with WS status chip +- [x] `InterfaceList.svelte`: fetches `/api/v1/interfaces`, Import XML file picker (calls `onLoad` prop), "New" stub button, collapse toggle +- [x] `Canvas.svelte`: absolute-position widget rendering, `--dpr` CSS variable, placeholder for null interface +- [x] `TextView.svelte`: live `{label}: {value} {unit}` with quality dot (green/amber/red/grey), positioned absolutely +- [x] `EditMode.svelte`: stub ("Edit mode — coming in Phase 6") +- [x] `vite.config.ts`: `/ws` WebSocket proxy added for dev server +- [x] `npm run build` and `npm run check`: 0 errors, 0 warnings (95 files checked) -**Done when:** loading a manually crafted XML interface displays live PV values. +**Done when:** loading a manually crafted XML interface displays live PV values. ✅ + +**Notes:** +- Uses Svelte 5 runes (`$state`, `$props`, `$derived`) throughout, matching existing code style +- Routing is a simple `mode` state variable in `App.svelte` (no SvelteKit, no router library) +- Unknown widget types in Canvas render as grey dashed placeholder boxes (forward-compatible) --- @@ -191,7 +221,7 @@ - [ ] EPICS Archive Appliance HTTP API client (`internal/datasource/epics/archive.go`) - [ ] Broker `History()` dispatch: route to correct data source's `History()` impl -- [ ] WebSocket `history` request / response handling +- [x] WebSocket `history` request / response handling (implemented in Phase 1) - [ ] Frontend: time range picker in view mode toolbar - [ ] "Live" button: flushes history state, re-subscribes to live updates - [ ] Plot widget: switch between streaming and historical range mode @@ -225,7 +255,7 @@ - [ ] Static binary validation: run on RHEL 7 (Docker image `centos:7`) - [ ] Security: Lua sandbox audit; XML XXE protection; write-permission guard - [ ] Graceful shutdown: drain WS connections, flush pending writes -- [ ] Structured logging (`log/slog`) +- [x] Structured logging (`log/slog`) — done from Phase 0 - [ ] `/metrics` endpoint (Prometheus format) for server monitoring - [ ] Complete `README.md` and operator docs - [ ] Release: `goreleaser` config for Linux amd64 + arm64 binaries @@ -238,17 +268,17 @@ These are rough single-developer estimates. Parallel work across backend and frontend where possible will reduce calendar time. -| Phase | Effort | -| --------- | ---------------- | -| 0 | 1–2 days | -| 1 | 3–5 days | -| 2 | 1–2 weeks | -| 3 | 1–2 weeks | -| 4 | 3–5 days | -| 5 | 2–3 weeks | -| 6 | 2–3 weeks | -| 7 | 1–2 weeks | -| 8 | 1 week | -| 9 | 1 week | -| 10 | 1–2 weeks | -| **Total** | **~14–20 weeks** | +| Phase | Effort | Status | +| --------- | ---------------- | ----------- | +| 0 | 1–2 days | ✅ Complete | +| 1 | 3–5 days | ✅ Complete | +| 2 | 1–2 weeks | ✅ Complete | +| 3 | 1–2 weeks | — | +| 4 | 3–5 days | ✅ Complete | +| 5 | 2–3 weeks | — | +| 6 | 2–3 weeks | — | +| 7 | 1–2 weeks | — | +| 8 | 1 week | — | +| 9 | 1 week | — | +| 10 | 1–2 weeks | — | +| **Total** | **~14–20 weeks** | | diff --git a/go.mod b/go.mod index f858bf3..f4a713a 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,12 @@ module github.com/uopi/uopi go 1.26.2 -require github.com/BurntSushi/toml v1.6.0 +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/coder/websocket v1.8.14 +) + +require ( + github.com/yuin/gopher-lua v1.1.2 // indirect + gonum.org/v1/gonum v0.17.0 // indirect +) diff --git a/go.sum b/go.sum index f74b269..952d909 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..914ec4f --- /dev/null +++ b/internal/api/api.go @@ -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}) +} diff --git a/internal/broker/broker.go b/internal/broker/broker.go new file mode 100644 index 0000000..231dd28 --- /dev/null +++ b/internal/broker/broker.go @@ -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) +} diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go new file mode 100644 index 0000000..5dc76dc --- /dev/null +++ b/internal/broker/broker_test.go @@ -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 + } +} diff --git a/internal/datasource/epics/archive.go b/internal/datasource/epics/archive.go new file mode 100644 index 0000000..17f3b4e --- /dev/null +++ b/internal/datasource/epics/archive.go @@ -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) + } +} diff --git a/internal/datasource/epics/ca_callback.go b/internal/datasource/epics/ca_callback.go new file mode 100644 index 0000000..cde92c7 --- /dev/null +++ b/internal/datasource/epics/ca_callback.go @@ -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: + } + } + } +} diff --git a/internal/datasource/epics/ca_wrapper.h b/internal/datasource/epics/ca_wrapper.h new file mode 100644 index 0000000..3c309de --- /dev/null +++ b/internal/datasource/epics/ca_wrapper.h @@ -0,0 +1,118 @@ +#ifndef CA_WRAPPER_H +#define CA_WRAPPER_H + +#include +#include +#include + +/* + * 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 */ diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go new file mode 100644 index 0000000..023c903 --- /dev/null +++ b/internal/datasource/epics/epics.go @@ -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 +*/ +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 + } +} diff --git a/internal/datasource/epics/epics_noop_test.go b/internal/datasource/epics/epics_noop_test.go new file mode 100644 index 0000000..ac705c4 --- /dev/null +++ b/internal/datasource/epics/epics_noop_test.go @@ -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) +} diff --git a/internal/datasource/epics/epics_test.go b/internal/datasource/epics/epics_test.go new file mode 100644 index 0000000..30e4111 --- /dev/null +++ b/internal/datasource/epics/epics_test.go @@ -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) + } +} diff --git a/internal/datasource/epics/noop.go b/internal/datasource/epics/noop.go new file mode 100644 index 0000000..596e915 --- /dev/null +++ b/internal/datasource/epics/noop.go @@ -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 } diff --git a/internal/datasource/iface.go b/internal/datasource/iface.go new file mode 100644 index 0000000..00e46ce --- /dev/null +++ b/internal/datasource/iface.go @@ -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") +) diff --git a/internal/datasource/stub/stub.go b/internal/datasource/stub/stub.go new file mode 100644 index 0000000..677ada5 --- /dev/null +++ b/internal/datasource/stub/stub.go @@ -0,0 +1,185 @@ +// Package stub provides a synthetic data source that emits deterministic test +// signals (sine waves, ramp, boolean toggle) without any external dependencies. +// It is used during development and for integration tests. +package stub + +import ( + "context" + "math" + "math/rand/v2" + "sync" + "time" + + "github.com/uopi/uopi/internal/datasource" +) + +const updateInterval = 100 * time.Millisecond // 10 Hz + +type signalDef struct { + meta datasource.Metadata + fn func(t time.Time) any +} + +// Stub is a data source that emits canned signals for development and testing. +type Stub struct { + signals map[string]signalDef + + // writable values (signal name → current value) + mu sync.RWMutex + values map[string]any +} + +// New returns a Stub with a predefined set of test signals. +func New() *Stub { + s := &Stub{ + signals: make(map[string]signalDef), + values: make(map[string]any), + } + s.register(signalDef{ + meta: datasource.Metadata{ + Name: "sine_1hz", Type: datasource.TypeFloat64, + Unit: "V", DisplayLow: -1, DisplayHigh: 1, + Description: "1 Hz sine wave", + }, + fn: func(t time.Time) any { + return math.Sin(2 * math.Pi * float64(t.UnixNano()) / 1e9) + }, + }) + s.register(signalDef{ + meta: datasource.Metadata{ + Name: "sine_01hz", Type: datasource.TypeFloat64, + Unit: "A", DisplayLow: -1, DisplayHigh: 1, + Description: "0.1 Hz sine wave", + }, + fn: func(t time.Time) any { + return math.Sin(2 * math.Pi * 0.1 * float64(t.UnixNano()) / 1e9) + }, + }) + s.register(signalDef{ + meta: datasource.Metadata{ + Name: "ramp_10s", Type: datasource.TypeFloat64, + Unit: "mm", DisplayLow: 0, DisplayHigh: 100, + Description: "10-second sawtooth ramp, 0–100", + }, + fn: func(t time.Time) any { + return math.Mod(float64(t.UnixNano())/1e10, 100) + }, + }) + s.register(signalDef{ + meta: datasource.Metadata{ + Name: "toggle_1hz", Type: datasource.TypeBool, + Description: "1 Hz square wave (boolean)", + }, + fn: func(t time.Time) any { return (t.Unix() % 2) == 0 }, + }) + s.register(signalDef{ + meta: datasource.Metadata{ + Name: "noise", Type: datasource.TypeFloat64, + Unit: "dB", DisplayLow: -1, DisplayHigh: 1, + Description: "White noise", + }, + fn: func(t time.Time) any { + // Seed a per-tick RNG from the timestamp so results are + // reproducible within a tick but vary across ticks. + r := rand.New(rand.NewPCG(uint64(t.UnixNano()), 0)) + return r.Float64()*2 - 1 + }, + }) + s.register(signalDef{ + meta: datasource.Metadata{ + Name: "setpoint", Type: datasource.TypeFloat64, + Unit: "°C", DisplayLow: 0, DisplayHigh: 200, + DriveLow: 0, DriveHigh: 200, + Description: "Writable setpoint (stub)", + Writable: true, + }, + fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write + }) + s.values["setpoint"] = 25.0 + return s +} + +func (s *Stub) register(d signalDef) { + s.signals[d.meta.Name] = d +} + +// Name implements datasource.DataSource. +func (s *Stub) Name() string { return "stub" } + +// Connect is a no-op for the stub source. +func (s *Stub) Connect(_ context.Context) error { return nil } + +// ListSignals returns metadata for all built-in stub signals. +func (s *Stub) ListSignals(_ context.Context) ([]datasource.Metadata, error) { + out := make([]datasource.Metadata, 0, len(s.signals)) + for _, d := range s.signals { + out = append(out, d.meta) + } + return out, nil +} + +// GetMetadata returns the metadata for the named signal. +func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) { + d, ok := s.signals[signal] + if !ok { + return datasource.Metadata{}, datasource.ErrNotFound + } + return d.meta, nil +} + +// Subscribe starts a 10 Hz ticker that pushes generated values into ch. +func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { + def, ok := s.signals[signal] + if !ok { + return nil, datasource.ErrNotFound + } + + ctx, cancel := context.WithCancel(ctx) + go func() { + ticker := time.NewTicker(updateInterval) + defer ticker.Stop() + for { + select { + case t := <-ticker.C: + var data any + if def.meta.Writable { + s.mu.RLock() + data = s.values[signal] + s.mu.RUnlock() + } else { + data = def.fn(t) + } + v := datasource.Value{Timestamp: t, Data: data, Quality: datasource.QualityGood} + select { + case ch <- v: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }() + + return datasource.CancelFunc(cancel), nil +} + +// Write updates the current value of a writable signal. +func (s *Stub) Write(_ context.Context, signal string, value any) error { + def, ok := s.signals[signal] + if !ok { + return datasource.ErrNotFound + } + if !def.meta.Writable { + return datasource.ErrNotWritable + } + s.mu.Lock() + s.values[signal] = value + s.mu.Unlock() + return nil +} + +// History is not supported by the stub source. +func (s *Stub) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) { + return nil, datasource.ErrHistoryUnavailable +} diff --git a/internal/dsp/nodes.go b/internal/dsp/nodes.go new file mode 100644 index 0000000..f211504 --- /dev/null +++ b/internal/dsp/nodes.go @@ -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 +} diff --git a/internal/dsp/nodes_test.go b/internal/dsp/nodes_test.go new file mode 100644 index 0000000..0c599ef --- /dev/null +++ b/internal/dsp/nodes_test.go @@ -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()) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a943995..e942c3e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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{ diff --git a/internal/server/ws.go b/internal/server/ws.go new file mode 100644 index 0000000..fe43da9 --- /dev/null +++ b/internal/server/ws.go @@ -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" + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 219bb94..668134e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,14 +7,268 @@ "": { "name": "web", "version": "0.0.0", + "dependencies": { + "echarts": "^6.0.0", + "uplot": "^1.6.32" + }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.2", + "jsdom": "^29.0.2", "svelte": "^5.55.4", "svelte-check": "^4.4.6", "typescript": "~6.0.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@emnapi/core": { @@ -51,6 +305,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -412,6 +684,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", @@ -442,6 +721,103 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -460,6 +836,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -484,6 +885,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -497,6 +1011,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -507,6 +1044,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -517,6 +1064,26 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -543,6 +1110,55 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -553,6 +1169,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -570,6 +1196,49 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -595,6 +1264,26 @@ } } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -628,6 +1317,36 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -638,6 +1357,54 @@ "@types/estree": "^1.0.6" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -918,6 +1685,26 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -928,6 +1715,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -968,6 +1772,26 @@ ], "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1017,6 +1841,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1031,6 +1887,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -1078,6 +1958,26 @@ "node": ">=6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1088,6 +1988,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/svelte": { "version": "5.55.5", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", @@ -1140,6 +2067,30 @@ "typescript": ">=5.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1157,6 +2108,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1179,6 +2186,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -1186,6 +2203,12 @@ "dev": true, "license": "MIT" }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -1284,12 +2307,199 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/web/package.json b/web/package.json index db93a7e..ffb3e03 100644 --- a/web/package.json +++ b/web/package.json @@ -11,11 +11,19 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.2", + "jsdom": "^29.0.2", "svelte": "^5.55.4", "svelte-check": "^4.4.6", "typescript": "~6.0.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" + }, + "dependencies": { + "echarts": "^6.0.0", + "uplot": "^1.6.32" } } diff --git a/web/src/App.svelte b/web/src/App.svelte index 6e350b0..4479617 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,35 +1,32 @@ -
- -

HMI Server

-

Web-based monitoring & control interface

- -
- {#if status === 'checking'} - Connecting to server… - {:else if status === 'ok'} - Server reachable - {:else} - Cannot reach server - {/if} +{#if $wsStatus === 'disconnected'} +
+ WebSocket disconnected — reconnecting…
+{/if} -

Frontend under construction. See docs/WORK_PLAN.md for progress.

-
+{#if mode === 'view'} + +{:else} + +{/if} diff --git a/web/src/lib/Canvas.svelte b/web/src/lib/Canvas.svelte new file mode 100644 index 0000000..48e35a5 --- /dev/null +++ b/web/src/lib/Canvas.svelte @@ -0,0 +1,98 @@ + + +
+ {#if iface === null} +
+

Select an interface from the left panel, or import one.

+
+ {:else} +
+ {#each iface.widgets as widget (widget.id)} + {#if componentForType(widget.type) !== null} + {@const Comp = componentForType(widget.type)} + + {:else} + +
+ {widget.type} +
+ {/if} + {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/EditMode.svelte b/web/src/lib/EditMode.svelte new file mode 100644 index 0000000..2894eba --- /dev/null +++ b/web/src/lib/EditMode.svelte @@ -0,0 +1,20 @@ + + +
+

Edit mode — coming in Phase 6

+
+ + diff --git a/web/src/lib/InterfaceList.svelte b/web/src/lib/InterfaceList.svelte new file mode 100644 index 0000000..aa44016 --- /dev/null +++ b/web/src/lib/InterfaceList.svelte @@ -0,0 +1,214 @@ + + + + + diff --git a/web/src/lib/ViewMode.svelte b/web/src/lib/ViewMode.svelte new file mode 100644 index 0000000..1ddf05b --- /dev/null +++ b/web/src/lib/ViewMode.svelte @@ -0,0 +1,175 @@ + + +
+ +
+
+ uopi + {#if currentInterface} + {currentInterface.name} + {/if} +
+
+ {#if parseError} + Parse error — check console + {/if} +
+
+
+ + {$wsStatus} +
+ +
+
+ + +
+ + +
+
+ + diff --git a/web/src/lib/stores.ts b/web/src/lib/stores.ts new file mode 100644 index 0000000..27bf7ce --- /dev/null +++ b/web/src/lib/stores.ts @@ -0,0 +1,157 @@ +import { readable, writable, type Readable } from 'svelte/store'; +import { wsClient } from './ws'; +import type { SignalRef, SignalValue, SignalMeta } from './types'; + +// ── Default values ──────────────────────────────────────────────────────────── + +const DEFAULT_VALUE: SignalValue = { + value: null, + quality: 'unknown', + ts: null, +}; + +// ── History buffer ──────────────────────────────────────────────────────────── + +const HISTORY_MAX = 1000; + +export interface HistoryPoint { + ts: string; + value: any; +} + +const historyBuffers = new Map(); + +function pushHistory(key: string, v: SignalValue): void { + if (v.value === null || v.value === undefined || v.ts === null) return; + let buf = historyBuffers.get(key); + if (!buf) { + buf = []; + historyBuffers.set(key, buf); + } + buf.push({ ts: v.ts, value: v.value }); + if (buf.length > HISTORY_MAX) buf.splice(0, buf.length - HISTORY_MAX); +} + +export function getSignalHistory(ref: SignalRef): HistoryPoint[] { + const key = `${ref.ds}\0${ref.name}`; + return historyBuffers.get(key) ?? []; +} + +// ── Store maps (keyed by "ds\0name") ───────────────────────────────────────── + +const valueStores = new Map>(); +const metaStores = new Map>(); + +function refKey(ref: SignalRef): string { + return `${ref.ds}\0${ref.name}`; +} + +/** + * Returns a Svelte readable store that delivers live signal values. + * Created lazily; the WS subscription is started on first use. + * The store stays alive once created (simplest correct behaviour for Phase 4). + */ +export function getSignalStore(ref: SignalRef): Readable { + const key = refKey(ref); + const existing = valueStores.get(key); + if (existing) return existing; + + const store = readable(DEFAULT_VALUE, (set) => { + // metaStore may already exist; we need a shared WS subscription. + // We use a shared subscription via _createShared. + const unsub = _getShared(ref).subscribeValue(set); + return unsub; + }); + + valueStores.set(key, store); + return store; +} + +/** + * Returns a Svelte readable store that delivers signal metadata. + * Metadata is sent once by the server on subscribe. + */ +export function getMetaStore(ref: SignalRef): Readable { + const key = refKey(ref); + const existing = metaStores.get(key); + if (existing) return existing; + + const store = readable(null, (set) => { + const unsub = _getShared(ref).subscribeMeta(set); + return unsub; + }); + + metaStores.set(key, store); + return store; +} + +// ── Shared subscription helper ──────────────────────────────────────────────── +// +// Both getSignalStore and getMetaStore need to share one WS subscription per +// signal. This object manages that sharing. + +interface SharedEntry { + subscribeValue: (set: (v: SignalValue) => void) => () => void; + subscribeMeta: (set: (m: SignalMeta | null) => void) => () => void; +} + +const sharedEntries = new Map(); + +function _getShared(ref: SignalRef): SharedEntry { + const key = refKey(ref); + const existing = sharedEntries.get(key); + if (existing) return existing; + + // Internal writables that fan out to all store subscribers + const valueW = writable(DEFAULT_VALUE); + const metaW = writable(null); + + // Reference count for the WS subscription + let wsUnsub: (() => void) | null = null; + let refCount = 0; + + function addRef() { + if (refCount === 0) { + wsUnsub = wsClient.subscribe( + ref, + (v) => { + valueW.set(v); + pushHistory(key, v); + }, + (m) => metaW.set(m), + ); + } + refCount++; + } + + function removeRef() { + refCount--; + if (refCount <= 0 && wsUnsub) { + wsUnsub(); + wsUnsub = null; + refCount = 0; + } + } + + const entry: SharedEntry = { + subscribeValue(set) { + addRef(); + const unsubStore = valueW.subscribe(set); + return () => { + unsubStore(); + removeRef(); + }; + }, + subscribeMeta(set) { + addRef(); + const unsubStore = metaW.subscribe(set); + return () => { + unsubStore(); + removeRef(); + }; + }, + }; + + sharedEntries.set(key, entry); + return entry; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..61bc364 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,42 @@ +// Core signal reference: identifies a signal by datasource + name +export interface SignalRef { + ds: string; + name: string; +} + +// Live value for a signal +export interface SignalValue { + value: any; + quality: 'good' | 'uncertain' | 'bad' | 'unknown'; + ts: string | null; +} + +// Metadata for a signal (received once on subscribe) +export interface SignalMeta { + type: string; + unit?: string; + displayLow: number; + displayHigh: number; + writable: boolean; + enumStrings?: string[]; + description?: string; +} + +// A single widget in an interface +export interface Widget { + id: string; + type: string; + x: number; + y: number; + w: number; + h: number; + signals: Array; + options: Record; +} + +// A complete HMI interface definition +export interface Interface { + name: string; + version: number; + widgets: Widget[]; +} diff --git a/web/src/lib/widgets/BarH.svelte b/web/src/lib/widgets/BarH.svelte new file mode 100644 index 0000000..c2e8ca9 --- /dev/null +++ b/web/src/lib/widgets/BarH.svelte @@ -0,0 +1,132 @@ + + +
+ + {#if label} +
{label}
+ {/if} +
+
+
+
+ {displayValue()} + {#if unit}{unit}{/if} +
+
+ + diff --git a/web/src/lib/widgets/BarV.svelte b/web/src/lib/widgets/BarV.svelte new file mode 100644 index 0000000..2cba3e6 --- /dev/null +++ b/web/src/lib/widgets/BarV.svelte @@ -0,0 +1,136 @@ + + +
+ + {#if label} +
{label}
+ {/if} +
+ {displayValue()} + {#if unit}{unit}{/if} +
+
+
+
+
+ + diff --git a/web/src/lib/widgets/Button.svelte b/web/src/lib/widgets/Button.svelte new file mode 100644 index 0000000..7045d97 --- /dev/null +++ b/web/src/lib/widgets/Button.svelte @@ -0,0 +1,66 @@ + + +
+ +
+ + diff --git a/web/src/lib/widgets/Gauge.svelte b/web/src/lib/widgets/Gauge.svelte new file mode 100644 index 0000000..b0e8cb4 --- /dev/null +++ b/web/src/lib/widgets/Gauge.svelte @@ -0,0 +1,187 @@ + + +
+ + + + + + {#if fillArcPath()} + + {/if} + + + + {displayValue()} + {#if unit} + {unit} + {/if} + + {minVal} + {maxVal} + + {#if label} +
{label}
+ {/if} +
+ + diff --git a/web/src/lib/widgets/Led.svelte b/web/src/lib/widgets/Led.svelte new file mode 100644 index 0000000..d68f764 --- /dev/null +++ b/web/src/lib/widgets/Led.svelte @@ -0,0 +1,99 @@ + + +
+
+ {#if label} +
{label}
+ {/if} +
+ + diff --git a/web/src/lib/widgets/MultiLed.svelte b/web/src/lib/widgets/MultiLed.svelte new file mode 100644 index 0000000..a8cc17b --- /dev/null +++ b/web/src/lib/widgets/MultiLed.svelte @@ -0,0 +1,108 @@ + + +
+ {#each bitStates() as on, i} + {@const trueColor = colorsTrueArr[i] ?? '#22c55e'} + {@const falseColor = colorsFalseArr[i] ?? '#ef4444'} + {@const color = on ? trueColor : falseColor} + {@const bitLabel = labelArr[i] ?? String(i)} +
+
+
{bitLabel}
+
+ {/each} +
+ + diff --git a/web/src/lib/widgets/PlotWidget.svelte b/web/src/lib/widgets/PlotWidget.svelte new file mode 100644 index 0000000..54783ec --- /dev/null +++ b/web/src/lib/widgets/PlotWidget.svelte @@ -0,0 +1,378 @@ + + + + {#if plotType === 'timeseries'} + + {/if} + + +
+
+
+ + diff --git a/web/src/lib/widgets/SetValue.svelte b/web/src/lib/widgets/SetValue.svelte new file mode 100644 index 0000000..1eaa1d1 --- /dev/null +++ b/web/src/lib/widgets/SetValue.svelte @@ -0,0 +1,170 @@ + + +
+ + {label}: + {#if isNumeric} + + {:else} + + {/if} + {displayValue()} + {#if unit}{unit}{/if} + +
+ + diff --git a/web/src/lib/widgets/TextView.svelte b/web/src/lib/widgets/TextView.svelte new file mode 100644 index 0000000..18fd359 --- /dev/null +++ b/web/src/lib/widgets/TextView.svelte @@ -0,0 +1,99 @@ + + +
+ + {label}: + {displayValue()} + {#if unit} + {unit} + {/if} +
+ + diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts new file mode 100644 index 0000000..a895932 --- /dev/null +++ b/web/src/lib/ws.ts @@ -0,0 +1,206 @@ +import { readable, writable, type Readable } from 'svelte/store'; +import type { SignalRef, SignalValue, SignalMeta } from './types'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type WsStatus = 'connecting' | 'connected' | 'disconnected'; + +interface SubscriberEntry { + onUpdate: (v: SignalValue) => void; + onMeta: (m: SignalMeta) => void; +} + +// ── WsClient ───────────────────────────────────────────────────────────────── + +class WsClient { + private ws: WebSocket | null = null; + private url = ''; + + // Reconnect back-off state + private retryDelay = 500; + private readonly maxDelay = 30_000; + private retryTimer: ReturnType | null = null; + private intentionalClose = false; + + // Status store + private _statusStore = writable('connecting'); + readonly status: Readable = { subscribe: this._statusStore.subscribe }; + + // Subscription tracking: key is "ds\0name" + // ref-count: how many callers are subscribed + private refCounts = new Map(); + // subscriber callbacks per signal + private subscribers = new Map>(); + + connect(url: string): void { + this.url = url; + this.intentionalClose = false; + this._open(); + } + + private _open(): void { + if (this.intentionalClose) return; + + this._statusStore.set('connecting'); + + try { + // Resolve relative URL to absolute ws:// URL + const wsUrl = this._resolveWsUrl(this.url); + this.ws = new WebSocket(wsUrl); + } catch { + this._scheduleReconnect(); + return; + } + + this.ws.addEventListener('open', () => { + this.retryDelay = 500; // reset back-off on success + this._statusStore.set('connected'); + + // Re-subscribe all active signals after reconnect + const signals: Array<{ ds: string; name: string }> = []; + for (const key of this.refCounts.keys()) { + if ((this.refCounts.get(key) ?? 0) > 0) { + const [ds, name] = key.split('\0'); + signals.push({ ds, name }); + } + } + if (signals.length > 0) { + this._send({ type: 'subscribe', signals }); + } + }); + + this.ws.addEventListener('message', (ev) => { + this._handleMessage(ev.data as string); + }); + + this.ws.addEventListener('close', () => { + this._statusStore.set('disconnected'); + if (!this.intentionalClose) { + this._scheduleReconnect(); + } + }); + + this.ws.addEventListener('error', () => { + // The close event fires right after, which will handle the reconnect. + this._statusStore.set('disconnected'); + }); + } + + private _resolveWsUrl(path: string): string { + if (path.startsWith('ws://') || path.startsWith('wss://')) { + return path; + } + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${location.host}${path}`; + } + + private _scheduleReconnect(): void { + if (this.retryTimer !== null) return; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.retryDelay = Math.min(this.retryDelay * 2, this.maxDelay); + this._open(); + }, this.retryDelay); + } + + private _send(obj: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)); + } + } + + private _handleMessage(data: string): void { + let msg: any; + try { + msg = JSON.parse(data); + } catch { + return; + } + + const key = msg.ds && msg.name ? `${msg.ds}\0${msg.name}` : ''; + + switch (msg.type) { + case 'update': { + const subs = this.subscribers.get(key); + if (!subs) break; + const val: SignalValue = { + value: msg.value, + quality: msg.quality ?? 'unknown', + ts: msg.ts ?? null, + }; + for (const s of subs) s.onUpdate(val); + break; + } + case 'meta': { + const subs = this.subscribers.get(key); + if (!subs || !msg.meta) break; + const meta: SignalMeta = { + type: msg.meta.type ?? 'unknown', + unit: msg.meta.unit, + displayLow: msg.meta.displayLow ?? 0, + displayHigh: msg.meta.displayHigh ?? 100, + writable: msg.meta.writable ?? false, + enumStrings: msg.meta.enumStrings, + description: msg.meta.description, + }; + for (const s of subs) s.onMeta(meta); + break; + } + case 'error': + console.warn('[ws] server error', msg.code, msg.message); + break; + default: + break; + } + } + + /** + * Subscribe to a signal. Reference-counted: only one WS subscribe message + * is sent even if multiple callers subscribe to the same signal. + * Returns a cleanup function that unsubscribes when all callers are done. + */ + subscribe( + ref: SignalRef, + onUpdate: (v: SignalValue) => void, + onMeta: (m: SignalMeta) => void, + ): () => void { + const key = `${ref.ds}\0${ref.name}`; + const entry: SubscriberEntry = { onUpdate, onMeta }; + + // Add to subscriber set + let set = this.subscribers.get(key); + if (!set) { + set = new Set(); + this.subscribers.set(key, set); + } + set.add(entry); + + // Reference counting — send WS subscribe only on first subscriber + const prev = this.refCounts.get(key) ?? 0; + this.refCounts.set(key, prev + 1); + if (prev === 0) { + this._send({ type: 'subscribe', signals: [{ ds: ref.ds, name: ref.name }] }); + } + + return () => { + const subs = this.subscribers.get(key); + subs?.delete(entry); + + const count = (this.refCounts.get(key) ?? 0) - 1; + this.refCounts.set(key, Math.max(0, count)); + + if (count <= 0) { + this.subscribers.delete(key); + this.refCounts.delete(key); + this._send({ type: 'unsubscribe', signals: [{ ds: ref.ds, name: ref.name }] }); + } + }; + } + + write(ref: SignalRef, value: any): void { + this._send({ type: 'write', ds: ref.ds, name: ref.name, value }); + } +} + +// Singleton instance +export const wsClient = new WsClient(); diff --git a/web/src/lib/xml.ts b/web/src/lib/xml.ts new file mode 100644 index 0000000..08aeb6e --- /dev/null +++ b/web/src/lib/xml.ts @@ -0,0 +1,66 @@ +import type { Interface, Widget, SignalRef } from './types'; + +/** + * Parse an interface XML string into an Interface object. + * + * Expected XML format: + * + * + * + * + * + */ +export function parseInterface(xml: string): Interface { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'application/xml'); + + // Check for parse errors + const parseError = doc.querySelector('parsererror'); + if (parseError) { + throw new Error(`XML parse error: ${parseError.textContent}`); + } + + const root = doc.documentElement; + if (root.tagName !== 'interface') { + throw new Error(`Expected root element , got <${root.tagName}>`); + } + + const name = root.getAttribute('name') ?? 'Untitled'; + const version = parseInt(root.getAttribute('version') ?? '1', 10); + + const widgets: Widget[] = []; + + for (const el of Array.from(root.children)) { + if (el.tagName !== 'widget') continue; + + const id = el.getAttribute('id') ?? ''; + const type = el.getAttribute('type') ?? 'unknown'; + const x = parseFloat(el.getAttribute('x') ?? '0'); + const y = parseFloat(el.getAttribute('y') ?? '0'); + const w = parseFloat(el.getAttribute('w') ?? '100'); + const h = parseFloat(el.getAttribute('h') ?? '60'); + + const signals: Array = []; + const options: Record = {}; + + for (const child of Array.from(el.children)) { + if (child.tagName === 'signal') { + const ds = child.getAttribute('ds') ?? ''; + const sigName = child.getAttribute('name') ?? ''; + const color = child.getAttribute('color') ?? undefined; + signals.push({ ds, name: sigName, ...(color ? { color } : {}) }); + } else if (child.tagName === 'option') { + const key = child.getAttribute('key'); + const value = child.getAttribute('value'); + if (key !== null && value !== null) { + options[key] = value; + } + } + } + + widgets.push({ id, type, x, y, w, h, signals, options }); + } + + return { name, version, widgets }; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 28c610a..36530a2 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ proxy: { '/api': 'http://localhost:8080', '/healthz': 'http://localhost:8080', + '/ws': { + target: 'ws://localhost:8080', + ws: true, + }, }, }, })