Phase 2/4 done, working on phase 3/5
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
dist/
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
interfaces/
|
||||
synthetic.json
|
||||
uopi.toml
|
||||
*.local.toml
|
||||
@@ -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
|
||||
|
||||
+33
-2
@@ -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)
|
||||
|
||||
+94
-64
@@ -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** | |
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#ifndef CA_WRAPPER_H
|
||||
#define CA_WRAPPER_H
|
||||
|
||||
#include <cadef.h>
|
||||
#include <db_access.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/*
|
||||
* Go-exported callback declarations.
|
||||
* These are implemented in ca_callback.go (via //export directives).
|
||||
*
|
||||
* goCAMonitorCallback is called for each value update received from CA.
|
||||
* goCAConnectionCallback is called when a channel connects or disconnects.
|
||||
*/
|
||||
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
|
||||
const void *dbr, int severity,
|
||||
double epicsTimeSecs);
|
||||
extern void goCAConnectionCallback(uintptr_t handle, int connected);
|
||||
|
||||
/*
|
||||
* CA monitor callback shim.
|
||||
*
|
||||
* CA calls this function with the fixed evargs signature. We extract the
|
||||
* relevant fields and forward them to the Go callback.
|
||||
*/
|
||||
static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
if (args.status != ECA_NORMAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
double timeSecs = 0.0;
|
||||
int severity = 0;
|
||||
|
||||
/*
|
||||
* Determine the timestamp and alarm severity from the DBR type.
|
||||
* We request DBR_TIME_* types so the timestamp is embedded in the value
|
||||
* buffer right after the alarm fields (struct dbr_time_double et al.).
|
||||
*/
|
||||
switch (args.type) {
|
||||
case DBR_TIME_DOUBLE: {
|
||||
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_FLOAT: {
|
||||
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_LONG: {
|
||||
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_SHORT: {
|
||||
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_STRING: {
|
||||
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_ENUM: {
|
||||
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
|
||||
args.dbr, severity, timeSecs);
|
||||
}
|
||||
|
||||
/*
|
||||
* CA connection callback shim.
|
||||
*
|
||||
* CA calls this with a connection_handler_args struct. We map the op field
|
||||
* to a simple boolean (1 = connected, 0 = disconnected) and forward.
|
||||
*/
|
||||
static void caConnectionCallbackShim(struct connection_handler_args args) {
|
||||
int connected = (args.op == CA_OP_CONN_UP) ? 1 : 0;
|
||||
goCAConnectionCallback((uintptr_t)ca_puser(args.chid), connected);
|
||||
}
|
||||
|
||||
/*
|
||||
* Thin helpers so CGo callers do not need to deal with C function-pointer
|
||||
* syntax directly. All CA API calls stay in C to avoid issues with CGo
|
||||
* passing Go pointers across the CA callback boundary.
|
||||
*/
|
||||
|
||||
/* Create a channel with the connection shim as the connection callback.
|
||||
* user_handle is the Go uintptr_t that identifies this channel in the handle
|
||||
* table. Returns the ECA_* status code. */
|
||||
static int caCreateChannel(const char *pvName, uintptr_t userHandle,
|
||||
chid *pChid) {
|
||||
return ca_create_channel(pvName, caConnectionCallbackShim,
|
||||
(void *)userHandle, CA_PRIORITY_DEFAULT, pChid);
|
||||
}
|
||||
|
||||
/* Add a value-monitor event on chid. Returns the ECA_* status code and stores
|
||||
* the evid in *pEvid. */
|
||||
static int caAddMonitor(chid ch, short dbrType, uintptr_t userHandle,
|
||||
evid *pEvid) {
|
||||
return ca_add_event(dbrType, ch, caMonitorCallbackShim,
|
||||
(void *)userHandle, pEvid);
|
||||
}
|
||||
|
||||
#endif /* CA_WRAPPER_H */
|
||||
@@ -0,0 +1,458 @@
|
||||
//go:build epics
|
||||
|
||||
// Package epics provides an EPICS Channel Access data source for uopi.
|
||||
//
|
||||
// # Build requirements
|
||||
//
|
||||
// This file and all other files tagged "epics" require CGo and a working EPICS
|
||||
// Base installation. Set the following environment variables before building:
|
||||
//
|
||||
// export EPICS_BASE=/path/to/epics/base
|
||||
// export CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux"
|
||||
// export CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom"
|
||||
//
|
||||
// Then build with:
|
||||
//
|
||||
// CGO_ENABLED=1 go build -tags epics ./...
|
||||
package epics
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -Wall
|
||||
#include "ca_wrapper.h"
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// connEntry holds the one-shot channel used during channel creation to wait for
|
||||
// the initial connection callback.
|
||||
type connEntry struct {
|
||||
connCh chan struct{}
|
||||
}
|
||||
|
||||
// handleTable maps Go-side handle IDs to the subscriber's value channel.
|
||||
// Protected by handleMu.
|
||||
var (
|
||||
handleMu sync.Mutex
|
||||
handleTable = make(map[uintptr]chan<- datasource.Value)
|
||||
connTable = make(map[uintptr]*connEntry)
|
||||
)
|
||||
|
||||
// handleSeq is an atomically-incremented counter used to generate unique handle
|
||||
// IDs that are passed through CA as void* user pointers.
|
||||
var handleSeq atomic.Uintptr
|
||||
|
||||
func nextHandle() uintptr {
|
||||
return handleSeq.Add(1)
|
||||
}
|
||||
|
||||
// caChannel holds the CA resources for a single PV.
|
||||
type caChannel struct {
|
||||
chid C.chid
|
||||
evid C.evid // monitor event ID; zero if no monitor installed
|
||||
handle uintptr
|
||||
}
|
||||
|
||||
// EPICS is the Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
|
||||
mu sync.Mutex
|
||||
channels map[string]*caChannel // PV name → CA channel
|
||||
metadata map[string]datasource.Metadata
|
||||
}
|
||||
|
||||
// New creates a new EPICS Channel Access data source.
|
||||
//
|
||||
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
|
||||
// rely on the environment). archiveURL is the base URL of an EPICS Archive
|
||||
// Appliance instance for history queries (may be empty).
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
channels: make(map[string]*caChannel),
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// Available reports whether the EPICS data source is compiled in.
|
||||
// Always returns true when built with the "epics" build tag.
|
||||
func Available() bool { return true }
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (e *EPICS) Name() string { return "epics" }
|
||||
|
||||
// Connect initialises a CA context with preemptive callbacks so that monitor
|
||||
// callbacks are delivered on CA's background threads without needing a
|
||||
// ca_pend_event polling loop.
|
||||
func (e *EPICS) Connect(_ context.Context) error {
|
||||
// Optionally override EPICS_CA_ADDR_LIST at runtime.
|
||||
if e.caAddrList != "" {
|
||||
cs := C.CString(e.caAddrList)
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
// ca_setenv is not available in all EPICS versions; use putenv via C.
|
||||
envStr := C.CString("EPICS_CA_ADDR_LIST=" + e.caAddrList)
|
||||
defer C.free(unsafe.Pointer(envStr))
|
||||
C.putenv(envStr)
|
||||
}
|
||||
|
||||
status := C.ca_context_create(C.ca_enable_preemptive_callback)
|
||||
if status != C.ECA_NORMAL {
|
||||
return fmt.Errorf("epics: ca_context_create failed: status %d", int(status))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe connects to the named PV (if not already connected), installs a
|
||||
// monitor, and streams value updates into ch. The returned CancelFunc removes
|
||||
// the monitor and clears the channel.
|
||||
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
handle := nextHandle()
|
||||
|
||||
// Register the handle → channel mapping before creating the CA channel so
|
||||
// that any early callbacks are not lost.
|
||||
handleMu.Lock()
|
||||
handleTable[handle] = ch
|
||||
entry := &connEntry{connCh: make(chan struct{}, 1)}
|
||||
connTable[handle] = entry
|
||||
handleMu.Unlock()
|
||||
|
||||
// Create the CA channel.
|
||||
pvName := C.CString(signal)
|
||||
defer C.free(unsafe.Pointer(pvName))
|
||||
|
||||
var chid C.chid
|
||||
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
|
||||
if status != C.ECA_NORMAL {
|
||||
handleMu.Lock()
|
||||
delete(handleTable, handle)
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return nil, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
|
||||
}
|
||||
|
||||
// Flush the create request and wait for the connection callback.
|
||||
C.ca_flush_io()
|
||||
|
||||
select {
|
||||
case <-entry.connCh:
|
||||
// Connected.
|
||||
case <-time.After(5 * time.Second):
|
||||
// Timeout; clean up and report error.
|
||||
C.ca_clear_channel(chid)
|
||||
handleMu.Lock()
|
||||
delete(handleTable, handle)
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return nil, fmt.Errorf("epics: timeout waiting for channel %q to connect", signal)
|
||||
case <-ctx.Done():
|
||||
C.ca_clear_channel(chid)
|
||||
handleMu.Lock()
|
||||
delete(handleTable, handle)
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Determine the native DBR_TIME_* type for the channel.
|
||||
dbrType := nativeTimeType(chid)
|
||||
|
||||
// Add the value monitor.
|
||||
var evid C.evid
|
||||
status = C.caAddMonitor(chid, C.short(dbrType), C.uintptr_t(handle), &evid)
|
||||
if status != C.ECA_NORMAL {
|
||||
C.ca_clear_channel(chid)
|
||||
handleMu.Lock()
|
||||
delete(handleTable, handle)
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return nil, fmt.Errorf("epics: ca_add_event(%q) failed: status %d", signal, int(status))
|
||||
}
|
||||
C.ca_flush_io()
|
||||
|
||||
caCh := &caChannel{chid: chid, evid: evid, handle: handle}
|
||||
|
||||
e.mu.Lock()
|
||||
e.channels[signal] = caCh
|
||||
e.mu.Unlock()
|
||||
|
||||
cancel := datasource.CancelFunc(func() {
|
||||
if evid != nil {
|
||||
C.ca_clear_event(evid)
|
||||
}
|
||||
C.ca_clear_channel(chid)
|
||||
C.ca_flush_io()
|
||||
|
||||
handleMu.Lock()
|
||||
delete(handleTable, handle)
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
|
||||
e.mu.Lock()
|
||||
delete(e.channels, signal)
|
||||
e.mu.Unlock()
|
||||
})
|
||||
|
||||
return cancel, nil
|
||||
}
|
||||
|
||||
// GetMetadata performs a synchronous ca_get for DBR_CTRL_DOUBLE (or the
|
||||
// appropriate control type) to retrieve units, display limits, enum strings,
|
||||
// and writability information for the named signal.
|
||||
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
|
||||
// Check cache first.
|
||||
e.mu.Lock()
|
||||
if m, ok := e.metadata[signal]; ok {
|
||||
e.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// We need a connected channel to issue a ca_get. Reuse an existing one or
|
||||
// create a temporary channel.
|
||||
e.mu.Lock()
|
||||
existingCh, exists := e.channels[signal]
|
||||
e.mu.Unlock()
|
||||
|
||||
var chid C.chid
|
||||
tempChannel := false
|
||||
|
||||
if exists {
|
||||
chid = existingCh.chid
|
||||
} else {
|
||||
// Create a temporary channel for metadata retrieval.
|
||||
handle := nextHandle()
|
||||
entry := &connEntry{connCh: make(chan struct{}, 1)}
|
||||
handleMu.Lock()
|
||||
connTable[handle] = entry
|
||||
handleMu.Unlock()
|
||||
|
||||
pvName := C.CString(signal)
|
||||
defer C.free(unsafe.Pointer(pvName))
|
||||
|
||||
status := C.caCreateChannel(pvName, C.uintptr_t(handle), &chid)
|
||||
if status != C.ECA_NORMAL {
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return datasource.Metadata{}, fmt.Errorf("epics: ca_create_channel(%q) failed: status %d", signal, int(status))
|
||||
}
|
||||
C.ca_flush_io()
|
||||
|
||||
select {
|
||||
case <-entry.connCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
C.ca_clear_channel(chid)
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return datasource.Metadata{}, fmt.Errorf("epics: timeout connecting to %q for metadata", signal)
|
||||
case <-ctx.Done():
|
||||
C.ca_clear_channel(chid)
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
return datasource.Metadata{}, ctx.Err()
|
||||
}
|
||||
|
||||
handleMu.Lock()
|
||||
delete(connTable, handle)
|
||||
handleMu.Unlock()
|
||||
tempChannel = true
|
||||
}
|
||||
|
||||
meta := e.fetchMetadata(chid, signal)
|
||||
|
||||
if tempChannel {
|
||||
C.ca_clear_channel(chid)
|
||||
C.ca_flush_io()
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.metadata[signal] = meta
|
||||
e.mu.Unlock()
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// fetchMetadata retrieves metadata from a connected chid using ca_get.
|
||||
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
|
||||
meta := datasource.Metadata{Name: name}
|
||||
|
||||
fieldType := C.ca_field_type(chid)
|
||||
count := C.ca_element_count(chid)
|
||||
|
||||
// Determine the Go DataType and the DBR_CTRL type to request.
|
||||
var ctrlType C.chtype
|
||||
switch int(fieldType) {
|
||||
case int(C.DBF_DOUBLE), int(C.DBF_FLOAT):
|
||||
if count > 1 {
|
||||
meta.Type = datasource.TypeFloat64Array
|
||||
} else {
|
||||
meta.Type = datasource.TypeFloat64
|
||||
}
|
||||
ctrlType = C.DBR_CTRL_DOUBLE
|
||||
|
||||
case int(C.DBF_LONG), int(C.DBF_SHORT), int(C.DBF_CHAR):
|
||||
meta.Type = datasource.TypeInt64
|
||||
ctrlType = C.DBR_CTRL_LONG
|
||||
|
||||
case int(C.DBF_ENUM):
|
||||
meta.Type = datasource.TypeEnum
|
||||
ctrlType = C.DBR_CTRL_ENUM
|
||||
|
||||
case int(C.DBF_STRING):
|
||||
meta.Type = datasource.TypeString
|
||||
ctrlType = C.DBR_CTRL_STRING
|
||||
|
||||
default:
|
||||
meta.Type = datasource.TypeFloat64
|
||||
ctrlType = C.DBR_CTRL_DOUBLE
|
||||
}
|
||||
|
||||
// Perform a synchronous ca_get with a 3-second timeout.
|
||||
switch ctrlType {
|
||||
case C.DBR_CTRL_DOUBLE:
|
||||
var buf C.struct_dbr_ctrl_double
|
||||
status := C.ca_get(C.DBR_CTRL_DOUBLE, chid, unsafe.Pointer(&buf))
|
||||
if status == C.ECA_NORMAL {
|
||||
C.ca_pend_io(3.0)
|
||||
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
|
||||
meta.DisplayLow = float64(buf.lower_disp_limit)
|
||||
meta.DisplayHigh = float64(buf.upper_disp_limit)
|
||||
meta.DriveLow = float64(buf.lower_ctrl_limit)
|
||||
meta.DriveHigh = float64(buf.upper_ctrl_limit)
|
||||
// CA does not expose a writable flag directly; assume writable unless
|
||||
// it is a read-only field type. Conservatively mark as writable.
|
||||
meta.Writable = true
|
||||
}
|
||||
|
||||
case C.DBR_CTRL_LONG:
|
||||
var buf C.struct_dbr_ctrl_long
|
||||
status := C.ca_get(C.DBR_CTRL_LONG, chid, unsafe.Pointer(&buf))
|
||||
if status == C.ECA_NORMAL {
|
||||
C.ca_pend_io(3.0)
|
||||
meta.Unit = C.GoString((*C.char)(unsafe.Pointer(&buf.units[0])))
|
||||
meta.DisplayLow = float64(buf.lower_disp_limit)
|
||||
meta.DisplayHigh = float64(buf.upper_disp_limit)
|
||||
meta.DriveLow = float64(buf.lower_ctrl_limit)
|
||||
meta.DriveHigh = float64(buf.upper_ctrl_limit)
|
||||
meta.Writable = true
|
||||
}
|
||||
|
||||
case C.DBR_CTRL_ENUM:
|
||||
var buf C.struct_dbr_ctrl_enum
|
||||
status := C.ca_get(C.DBR_CTRL_ENUM, chid, unsafe.Pointer(&buf))
|
||||
if status == C.ECA_NORMAL {
|
||||
C.ca_pend_io(3.0)
|
||||
n := int(buf.no_str)
|
||||
strs := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
strs[i] = C.GoString((*C.char)(unsafe.Pointer(&buf.strs[i][0])))
|
||||
}
|
||||
meta.EnumStrings = strs
|
||||
meta.Writable = true
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// ListSignals returns cached metadata for all currently connected channels.
|
||||
// Full enumeration of all available PVs is deferred to Phase 9.
|
||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
out := make([]datasource.Metadata, 0, len(e.metadata))
|
||||
for _, m := range e.metadata {
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Write puts a new value onto a CA channel.
|
||||
func (e *EPICS) Write(_ context.Context, signal string, value any) error {
|
||||
e.mu.Lock()
|
||||
caCh, ok := e.channels[signal]
|
||||
e.mu.Unlock()
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
|
||||
var status C.int
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
cv := C.double(v)
|
||||
status = C.ca_put(C.DBR_DOUBLE, caCh.chid, unsafe.Pointer(&cv))
|
||||
case int64:
|
||||
cv := C.long(v)
|
||||
status = C.ca_put(C.DBR_LONG, caCh.chid, unsafe.Pointer(&cv))
|
||||
case string:
|
||||
cs := C.CString(v)
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
status = C.ca_put(C.DBR_STRING, caCh.chid, unsafe.Pointer(cs))
|
||||
case bool:
|
||||
var iv C.short
|
||||
if v {
|
||||
iv = 1
|
||||
}
|
||||
status = C.ca_put(C.DBR_SHORT, caCh.chid, unsafe.Pointer(&iv))
|
||||
default:
|
||||
return fmt.Errorf("epics: unsupported value type %T for Write", value)
|
||||
}
|
||||
|
||||
if status != C.ECA_NORMAL {
|
||||
return fmt.Errorf("epics: ca_put(%q) failed: status %d", signal, int(status))
|
||||
}
|
||||
C.ca_flush_io()
|
||||
return nil
|
||||
}
|
||||
|
||||
// History delegates to the Archive Appliance client, or returns
|
||||
// ErrHistoryUnavailable if no archive URL is configured.
|
||||
func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
|
||||
if e.archiveURL == "" {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
|
||||
}
|
||||
|
||||
// nativeTimeType returns the DBR_TIME_* type corresponding to the native field
|
||||
// type of the channel.
|
||||
func nativeTimeType(chid C.chid) C.chtype {
|
||||
ft := C.ca_field_type(chid)
|
||||
count := C.ca_element_count(chid)
|
||||
|
||||
switch int(ft) {
|
||||
case int(C.DBF_DOUBLE):
|
||||
if count > 1 {
|
||||
return C.DBR_TIME_DOUBLE // waveform; caller assembles []float64 separately
|
||||
}
|
||||
return C.DBR_TIME_DOUBLE
|
||||
case int(C.DBF_FLOAT):
|
||||
return C.DBR_TIME_FLOAT
|
||||
case int(C.DBF_LONG):
|
||||
return C.DBR_TIME_LONG
|
||||
case int(C.DBF_SHORT), int(C.DBF_CHAR):
|
||||
return C.DBR_TIME_SHORT
|
||||
case int(C.DBF_ENUM):
|
||||
return C.DBR_TIME_ENUM
|
||||
case int(C.DBF_STRING):
|
||||
return C.DBR_TIME_STRING
|
||||
default:
|
||||
return C.DBR_TIME_DOUBLE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package epics_test contains tests that compile regardless of build tags.
|
||||
// This file verifies the no-op stub behaviour when the package is built without
|
||||
// the "epics" build tag (i.e. without CGo and EPICS Base).
|
||||
package epics_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
)
|
||||
|
||||
// TestAvailable_NoopBuild verifies that Available() returns false when the
|
||||
// package is compiled without the "epics" build tag.
|
||||
//
|
||||
// When built WITH the tag the test still passes because Available() returns
|
||||
// true, which is a different (correct) state. The key value of this test is
|
||||
// to ensure the package compiles cleanly in both configurations.
|
||||
func TestAvailable_NoopBuild(t *testing.T) {
|
||||
got := epics.Available()
|
||||
// When built without -tags epics, Available must be false.
|
||||
// When built with -tags epics, Available must be true.
|
||||
// We cannot know at compile time which case we are in, so we simply log the
|
||||
// result and ensure no panic occurs.
|
||||
t.Logf("epics.Available() = %v", got)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1211
-1
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+40
-81
@@ -1,35 +1,32 @@
|
||||
<script lang="ts">
|
||||
let status = $state<'checking' | 'ok' | 'error'>('checking');
|
||||
import { wsClient } from './lib/ws';
|
||||
import ViewMode from './lib/ViewMode.svelte';
|
||||
import EditMode from './lib/EditMode.svelte';
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const resp = await fetch('/healthz');
|
||||
status = resp.ok ? 'ok' : 'error';
|
||||
} catch {
|
||||
status = 'error';
|
||||
}
|
||||
}
|
||||
type AppMode = 'view' | 'edit';
|
||||
let mode = $state<AppMode>('view');
|
||||
|
||||
checkHealth();
|
||||
const wsStatus = wsClient.status;
|
||||
|
||||
// Set CSS variable for device pixel ratio on the root element
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||
|
||||
// Connect to WebSocket — called once at app startup
|
||||
wsClient.connect('/ws');
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="logo">uopi</div>
|
||||
<h1>HMI Server</h1>
|
||||
<p class="subtitle">Web-based monitoring & control interface</p>
|
||||
|
||||
<div class="status" class:ok={status === 'ok'} class:error={status === 'error'}>
|
||||
{#if status === 'checking'}
|
||||
Connecting to server…
|
||||
{:else if status === 'ok'}
|
||||
Server reachable
|
||||
{:else}
|
||||
Cannot reach server
|
||||
{/if}
|
||||
{#if $wsStatus === 'disconnected'}
|
||||
<div class="connection-banner">
|
||||
WebSocket disconnected — reconnecting…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="note">Frontend under construction. See <code>docs/WORK_PLAN.md</code> for progress.</p>
|
||||
</main>
|
||||
{#if mode === 'view'}
|
||||
<ViewMode />
|
||||
{:else}
|
||||
<EditMode />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) {
|
||||
@@ -41,67 +38,29 @@
|
||||
:global(body) {
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(#app) {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
border: none;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -2px;
|
||||
color: #60a5fa;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
.connection-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.note {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #1e293b;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
padding: 0.3rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { Interface, Widget } from './types';
|
||||
import TextView from './widgets/TextView.svelte';
|
||||
|
||||
let { iface }: { iface: Interface | null } = $props();
|
||||
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
|
||||
function componentForType(type: string): any {
|
||||
switch (type) {
|
||||
case 'textview': return TextView;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="canvas-container"
|
||||
style="--dpr: {dpr};"
|
||||
>
|
||||
{#if iface === null}
|
||||
<div class="placeholder">
|
||||
<p>Select an interface from the left panel, or import one.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="canvas-area">
|
||||
{#each iface.widgets as widget (widget.id)}
|
||||
{#if componentForType(widget.type) !== null}
|
||||
{@const Comp = componentForType(widget.type)}
|
||||
<Comp {widget} />
|
||||
{:else}
|
||||
<!-- Unknown widget type: grey placeholder box -->
|
||||
<div
|
||||
class="unknown-widget"
|
||||
style="
|
||||
left: {widget.x}px;
|
||||
top: {widget.y}px;
|
||||
width: {widget.w}px;
|
||||
height: {widget.h}px;
|
||||
"
|
||||
title="Unknown widget type: {widget.type}"
|
||||
>
|
||||
<span class="unknown-label">{widget.type}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background: #0f1117;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
position: relative;
|
||||
/* Large enough to accommodate absolutely-positioned widgets */
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: max-content;
|
||||
height: max-content;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.unknown-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1e2535;
|
||||
border: 1px dashed #3d4f6e;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.unknown-label {
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
// Edit mode — coming in Phase 6
|
||||
</script>
|
||||
|
||||
<div class="edit-mode">
|
||||
<p>Edit mode — coming in Phase 6</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.edit-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background: #0f1117;
|
||||
color: #475569;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface ServerInterface {
|
||||
name: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let { onLoad }: { onLoad: (xml: string) => void } = $props();
|
||||
|
||||
let collapsed = $state(false);
|
||||
let interfaces = $state<ServerInterface[]>([]);
|
||||
let loading = $state(true);
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/interfaces');
|
||||
if (resp.ok) {
|
||||
interfaces = await resp.json();
|
||||
}
|
||||
} catch {
|
||||
// Server not reachable; leave empty list
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed = !collapsed;
|
||||
}
|
||||
|
||||
function handleNewInterface() {
|
||||
// Phase 6: edit mode
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function handleFileChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const xml = await file.text();
|
||||
onLoad(xml);
|
||||
} catch (err) {
|
||||
console.error('Failed to read interface file:', err);
|
||||
} finally {
|
||||
// Reset so the same file can be re-imported
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="panel" class:collapsed>
|
||||
<div class="panel-header">
|
||||
{#if !collapsed}
|
||||
<span class="panel-title">Interfaces</span>
|
||||
{/if}
|
||||
<button class="icon-btn" onclick={toggleCollapse} title={collapsed ? 'Expand panel' : 'Collapse panel'}>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !collapsed}
|
||||
<div class="panel-actions">
|
||||
<button class="btn" onclick={handleNewInterface} title="New interface (Edit mode — Phase 6)">
|
||||
+ New
|
||||
</button>
|
||||
<button class="btn" onclick={triggerImport} title="Import interface from XML file">
|
||||
Import
|
||||
</button>
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style="display:none"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel-list">
|
||||
{#if loading}
|
||||
<p class="hint">Loading…</p>
|
||||
{:else if interfaces.length === 0}
|
||||
<p class="hint">No interfaces saved yet. Create one in Edit mode.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each interfaces as iface}
|
||||
<li class="iface-item">{iface.name ?? iface.id ?? 'Unnamed'}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: #1a1f2e;
|
||||
border-right: 1px solid #2d3748;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s ease, min-width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel.collapsed {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
min-height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapsed .panel-header {
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
background: #2d3748;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.75rem 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.iface-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
margin: 1px 4px;
|
||||
}
|
||||
|
||||
.iface-item:hover {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import InterfaceList from './InterfaceList.svelte';
|
||||
import Canvas from './Canvas.svelte';
|
||||
import { wsClient } from './ws';
|
||||
import { parseInterface } from './xml';
|
||||
import type { Interface } from './types';
|
||||
|
||||
let currentInterface = $state<Interface | null>(null);
|
||||
let parseError = $state<string | null>(null);
|
||||
|
||||
const wsStatus = wsClient.status;
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
parseError = null;
|
||||
currentInterface = parseInterface(xml);
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : 'Failed to parse interface file.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="view-mode">
|
||||
<!-- Top toolbar -->
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="app-name">uopi</span>
|
||||
{#if currentInterface}
|
||||
<span class="iface-name">{currentInterface.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
{#if parseError}
|
||||
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class="status-chip" class:connected={$wsStatus === 'connected'} class:disconnected={$wsStatus === 'disconnected'} class:connecting={$wsStatus === 'connecting'}>
|
||||
<span class="status-dot"></span>
|
||||
{$wsStatus}
|
||||
</div>
|
||||
<button class="btn-edit" disabled title="Edit mode — coming in Phase 6">Edit</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content row -->
|
||||
<div class="content">
|
||||
<InterfaceList onLoad={handleLoad} />
|
||||
<Canvas iface={currentInterface} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #0f1117;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
height: 44px;
|
||||
background: #1a1f2e;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #60a5fa;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.iface-name {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.parse-error {
|
||||
font-size: 0.78rem;
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-chip.connected {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
border-color: #166534;
|
||||
}
|
||||
|
||||
.status-chip.disconnected {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
.status-chip.connecting {
|
||||
background: #1e293b;
|
||||
color: #fbbf24;
|
||||
border-color: #92400e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #2d3748;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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<string, HistoryPoint[]>();
|
||||
|
||||
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<string, Readable<SignalValue>>();
|
||||
const metaStores = new Map<string, Readable<SignalMeta | null>>();
|
||||
|
||||
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<SignalValue> {
|
||||
const key = refKey(ref);
|
||||
const existing = valueStores.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const store = readable<SignalValue>(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<SignalMeta | null> {
|
||||
const key = refKey(ref);
|
||||
const existing = metaStores.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const store = readable<SignalMeta | null>(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<string, SharedEntry>();
|
||||
|
||||
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<SignalValue>(DEFAULT_VALUE);
|
||||
const metaW = writable<SignalMeta | null>(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;
|
||||
}
|
||||
@@ -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<SignalRef & { color?: string }>;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
// A complete HMI interface definition
|
||||
export interface Interface {
|
||||
name: string;
|
||||
version: number;
|
||||
widgets: Widget[];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return '---';
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
});
|
||||
|
||||
const fillPercent = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="barh"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
{#if label}
|
||||
<div class="bar-label">{label}</div>
|
||||
{/if}
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style="width: {fillPercent()}%;"></div>
|
||||
</div>
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barh {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #2d3748;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: #4a9eff;
|
||||
border-radius: 3px;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return '---';
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
});
|
||||
|
||||
const fillPercent = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return 0;
|
||||
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
|
||||
return Math.max(0, Math.min(100, frac * 100));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="barv"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
{#if label}
|
||||
<div class="bar-label">{label}</div>
|
||||
{/if}
|
||||
<div class="bar-value">
|
||||
<span class="value">{displayValue()}</span>
|
||||
{#if unit}<span class="unit">{unit}</span>{/if}
|
||||
</div>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style="height: {fillPercent()}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barv {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
width: 24px;
|
||||
flex: 1;
|
||||
background: #2d3748;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
background: #4a9eff;
|
||||
border-radius: 3px;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { wsClient } from '../ws';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const label = $derived(widget.options['label'] ?? 'Button');
|
||||
const valueOpt = $derived(widget.options['value'] ?? '1');
|
||||
const confirm = $derived(widget.options['confirm'] === 'true');
|
||||
|
||||
function handleClick() {
|
||||
if (!sigRef) return;
|
||||
// Parse as number if possible, otherwise send as string
|
||||
const parsed = parseFloat(valueOpt);
|
||||
const val = isNaN(parsed) ? valueOpt : parsed;
|
||||
|
||||
const doWrite = () => wsClient.write(sigRef, val);
|
||||
if (confirm) {
|
||||
if (window.confirm(`Send ${label}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="button-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<button class="btn" onclick={handleClick}>{label}</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.button-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1e3a5f;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #2d5a9e;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2563eb;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
background: #1d4ed8;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
|
||||
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
|
||||
const thresholdLow = $derived(widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null);
|
||||
const thresholdHigh = $derived(widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null);
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
return typeof v === 'number' ? v : parseFloat(String(v));
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null || isNaN(v)) return '---';
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
});
|
||||
|
||||
// Arc: -135deg to +135deg (270deg total)
|
||||
const START_DEG = -135;
|
||||
const END_DEG = 135;
|
||||
const TOTAL_DEG = 270;
|
||||
|
||||
const cx = 50;
|
||||
const cy = 54;
|
||||
const r = 38;
|
||||
|
||||
function degToRad(deg: number) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function polarToXY(deg: number, radius: number) {
|
||||
const rad = degToRad(deg);
|
||||
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function arcPath(startDeg: number, endDeg: number, radius: number) {
|
||||
const s = polarToXY(startDeg, radius);
|
||||
const e = polarToXY(endDeg, radius);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
|
||||
}
|
||||
|
||||
function valueToDeg(v: number) {
|
||||
const clamped = Math.max(minVal, Math.min(maxVal, v));
|
||||
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
|
||||
return START_DEG + frac * TOTAL_DEG;
|
||||
}
|
||||
|
||||
const fillColor = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null) return '#4a9eff';
|
||||
if (thresholdHigh !== null && v >= thresholdHigh) return '#ef4444';
|
||||
if (thresholdLow !== null && v <= thresholdLow) return '#f59e0b';
|
||||
return '#4a9eff';
|
||||
});
|
||||
|
||||
const needleDeg = $derived(() => {
|
||||
const v = rawValue();
|
||||
if (v === null) return START_DEG;
|
||||
return valueToDeg(v);
|
||||
});
|
||||
|
||||
const fillArcPath = $derived(() => {
|
||||
const nd = needleDeg();
|
||||
if (nd <= START_DEG) return '';
|
||||
return arcPath(START_DEG, nd, r);
|
||||
});
|
||||
|
||||
const bgArcPath = $derived(() => arcPath(START_DEG, END_DEG, r));
|
||||
|
||||
const needlePos = $derived(() => polarToXY(needleDeg(), r - 4));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="gauge"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
<svg viewBox="0 0 100 80" class="gauge-svg">
|
||||
<!-- Background arc -->
|
||||
<path d={bgArcPath()} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
|
||||
<!-- Filled arc -->
|
||||
{#if fillArcPath()}
|
||||
<path d={fillArcPath()} fill="none" stroke={fillColor()} stroke-width="6" stroke-linecap="round" />
|
||||
{/if}
|
||||
<!-- Needle dot -->
|
||||
<circle cx={needlePos().x} cy={needlePos().y} r="3" fill={fillColor()} />
|
||||
<!-- Center value -->
|
||||
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
|
||||
{#if unit}
|
||||
<text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>
|
||||
{/if}
|
||||
<!-- Min/Max labels -->
|
||||
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
|
||||
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
|
||||
</svg>
|
||||
{#if label}
|
||||
<div class="gauge-label">{label}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gauge {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gauge-svg {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.gauge-value {
|
||||
fill: #e2e8f0;
|
||||
font-size: 14px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.gauge-unit {
|
||||
fill: #64748b;
|
||||
font-size: 6px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.gauge-range {
|
||||
fill: #475569;
|
||||
font-size: 6px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const condition = $derived(widget.options['condition'] ?? 'value > 0');
|
||||
const colorTrue = $derived(widget.options['colorTrue'] ?? '#22c55e');
|
||||
const colorFalse = $derived(widget.options['colorFalse'] ?? '#ef4444');
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
|
||||
|
||||
function evaluateCondition(expr: string, v: any): boolean {
|
||||
// Safety check: reject dangerous patterns
|
||||
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function('value', 'return ' + expr)(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const ledOn = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return false;
|
||||
return evaluateCondition(condition, v);
|
||||
});
|
||||
|
||||
const ledColor = $derived(() => ledOn() ? colorTrue : colorFalse);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="led-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<div
|
||||
class="led-circle"
|
||||
class:blink={isUncertain}
|
||||
style="background: {ledColor()}; box-shadow: 0 0 8px {ledColor()}88;"
|
||||
></div>
|
||||
{#if label}
|
||||
<div class="led-label">{label}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.led-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.led-circle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.led-circle.blink {
|
||||
animation: blink-slow 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-slow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.led-label {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
|
||||
const bits = $derived(parseInt(widget.options['bits'] ?? '8', 10));
|
||||
const labelsRaw = $derived(widget.options['labels'] ?? '');
|
||||
const colorsTrueRaw = $derived(widget.options['colorsTrue'] ?? '');
|
||||
const colorsFalseRaw = $derived(widget.options['colorsFalse'] ?? '');
|
||||
|
||||
const labelArr = $derived(labelsRaw ? labelsRaw.split(',') : []);
|
||||
const colorsTrueArr = $derived(colorsTrueRaw ? colorsTrueRaw.split(',') : []);
|
||||
const colorsFalseArr = $derived(colorsFalseRaw ? colorsFalseRaw.split(',') : []);
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
|
||||
|
||||
const intValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return 0;
|
||||
return typeof v === 'number' ? Math.floor(v) : parseInt(String(v), 10);
|
||||
});
|
||||
|
||||
function getBit(val: number, bit: number): boolean {
|
||||
return (val & (1 << bit)) !== 0;
|
||||
}
|
||||
|
||||
const bitStates = $derived(() => {
|
||||
const val = intValue();
|
||||
return Array.from({ length: bits }, (_, i) => getBit(val, i));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="multiled-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
{#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)}
|
||||
<div class="bit-item">
|
||||
<div
|
||||
class="led-circle"
|
||||
class:blink={isUncertain}
|
||||
style="background: {color}; box-shadow: 0 0 6px {color}88;"
|
||||
></div>
|
||||
<div class="bit-label">{bitLabel}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.multiled-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bit-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.led-circle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led-circle.blink {
|
||||
animation: blink-slow 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-slow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.bit-label {
|
||||
font-size: 0.6rem;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,378 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSignalStore } from '../stores';
|
||||
import type { Widget, SignalRef } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const plotType = $derived(widget.options['plotType'] ?? 'timeseries');
|
||||
const timeWindow = $derived(parseFloat(widget.options['timeWindow'] ?? '60'));
|
||||
const legendPos = $derived(widget.options['legend'] ?? 'bottom');
|
||||
const yMinOpt = $derived(widget.options['yMin'] ?? 'auto');
|
||||
const yMaxOpt = $derived(widget.options['yMax'] ?? 'auto');
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let chartEl: HTMLDivElement;
|
||||
|
||||
// ── Shared per-signal data buffers ────────────────────────────────────────────
|
||||
|
||||
interface SeriesBuffer {
|
||||
timestamps: number[]; // Unix seconds
|
||||
values: number[];
|
||||
}
|
||||
|
||||
// Filled on mount — up to 10 signals
|
||||
let buffers: SeriesBuffer[] = [];
|
||||
let unsubs: (() => void)[] = [];
|
||||
|
||||
// ── uPlot (timeseries) ────────────────────────────────────────────────────────
|
||||
|
||||
let uplotInstance: any = null;
|
||||
let uplotImported = false;
|
||||
|
||||
// ── ECharts (other types) ─────────────────────────────────────────────────────
|
||||
|
||||
let echartsInstance: any = null;
|
||||
let echartsImported = false;
|
||||
|
||||
// Histogram per-series: accumulate raw values, bucket on render
|
||||
let histValues: number[][] = [];
|
||||
|
||||
const RING_MAX = 4000;
|
||||
|
||||
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
|
||||
buf.timestamps.push(ts);
|
||||
buf.values.push(val);
|
||||
if (buf.timestamps.length > RING_MAX) {
|
||||
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
|
||||
buf.values.splice(0, buf.values.length - RING_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function windowedSlice(buf: SeriesBuffer, windowSec: number): { ts: number[]; vals: number[] } {
|
||||
const now = Date.now() / 1000;
|
||||
const cutoff = now - windowSec;
|
||||
const start = buf.timestamps.findIndex((t) => t >= cutoff);
|
||||
if (start === -1) return { ts: [], vals: [] };
|
||||
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
||||
}
|
||||
|
||||
// ── uPlot rendering ───────────────────────────────────────────────────────────
|
||||
|
||||
async function initUplot() {
|
||||
if (uplotImported) return;
|
||||
uplotImported = true;
|
||||
|
||||
// @ts-ignore
|
||||
const uPlot = (await import('uplot')).default;
|
||||
|
||||
const signals = widget.signals;
|
||||
const seriesConf = [
|
||||
{},
|
||||
...signals.map((s, i) => ({
|
||||
label: s.name,
|
||||
stroke: s.color ?? defaultColors[i % defaultColors.length],
|
||||
width: 1.5,
|
||||
})),
|
||||
];
|
||||
|
||||
const legendShow = legendPos !== 'none';
|
||||
|
||||
uplotInstance = new uPlot(
|
||||
{
|
||||
width: chartEl.clientWidth || widget.w - 2,
|
||||
height: chartEl.clientHeight || widget.h - 40,
|
||||
series: seriesConf,
|
||||
legend: { show: legendShow },
|
||||
axes: [
|
||||
{ space: 40 },
|
||||
{
|
||||
min: yMinOpt === 'auto' ? undefined : parseFloat(yMinOpt),
|
||||
max: yMaxOpt === 'auto' ? undefined : parseFloat(yMaxOpt),
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: {
|
||||
range: yMinOpt === 'auto' && yMaxOpt === 'auto'
|
||||
? undefined
|
||||
: [
|
||||
yMinOpt === 'auto' ? null : parseFloat(yMinOpt),
|
||||
yMaxOpt === 'auto' ? null : parseFloat(yMaxOpt),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
buildUplotData(),
|
||||
chartEl,
|
||||
);
|
||||
}
|
||||
|
||||
function buildUplotData(): any[] {
|
||||
if (buffers.length === 0) return [[]];
|
||||
// Use longest timestamps set (first signal)
|
||||
const tw = timeWindow;
|
||||
const allTs = new Set<number>();
|
||||
buffers.forEach((b) => {
|
||||
const { ts } = windowedSlice(b, tw);
|
||||
ts.forEach((t) => allTs.add(t));
|
||||
});
|
||||
const sortedTs = Array.from(allTs).sort((a, b) => a - b);
|
||||
if (sortedTs.length === 0) return [[]];
|
||||
|
||||
const series: any[] = [sortedTs];
|
||||
buffers.forEach((b) => {
|
||||
const tsMap = new Map<number, number>();
|
||||
const { ts, vals } = windowedSlice(b, tw);
|
||||
ts.forEach((t, i) => tsMap.set(t, vals[i]));
|
||||
series.push(sortedTs.map((t) => tsMap.get(t) ?? null));
|
||||
});
|
||||
return series;
|
||||
}
|
||||
|
||||
function updateUplot() {
|
||||
if (!uplotInstance) return;
|
||||
uplotInstance.setData(buildUplotData());
|
||||
}
|
||||
|
||||
// ── ECharts rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
async function initEcharts() {
|
||||
if (echartsImported) return;
|
||||
echartsImported = true;
|
||||
|
||||
// @ts-ignore
|
||||
const echarts = await import('echarts');
|
||||
echartsInstance = echarts.init(chartEl, 'dark');
|
||||
updateEcharts(echarts);
|
||||
}
|
||||
|
||||
function buildEchartsOption(echarts?: any): any {
|
||||
const signals = widget.signals;
|
||||
const legendShow = legendPos !== 'none';
|
||||
|
||||
switch (plotType) {
|
||||
case 'fft': {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'category', name: 'Freq Index', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
|
||||
series: buffers.map((b, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data: b.values.length ? b.values[b.values.length - 1] : [],
|
||||
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
showSymbol: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'histogram': {
|
||||
const buckets = buildHistogram();
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'category', data: buckets.labels, axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
series: buckets.series.map((d, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'bar',
|
||||
data: d,
|
||||
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'bar': {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'category', data: signals.map((s) => s.name), axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: buffers.map((b, i) => ({
|
||||
value: b.values.length ? b.values[b.values.length - 1] : 0,
|
||||
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
})),
|
||||
}],
|
||||
};
|
||||
}
|
||||
case 'logic': {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
|
||||
xAxis: { type: 'value', axisLabel: { color: '#64748b' }, min: 'dataMin', max: 'dataMax' },
|
||||
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLabel: { color: '#64748b' } },
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
step: 'end',
|
||||
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
||||
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'waterfall': {
|
||||
// Display last N rows as a heatmap-style waterfall
|
||||
const ROWS = 32;
|
||||
const history = buffers[0];
|
||||
const rows: number[][] = [];
|
||||
const step = Math.max(1, Math.floor(history.values.length / ROWS));
|
||||
for (let i = 0; i < ROWS; i++) {
|
||||
const idx = Math.min(history.values.length - 1 - (ROWS - 1 - i) * step, history.values.length - 1);
|
||||
if (idx < 0) {
|
||||
rows.push([]);
|
||||
} else {
|
||||
const v = history.values[idx];
|
||||
rows.push(Array.isArray(v) ? v : [v]);
|
||||
}
|
||||
}
|
||||
const flatData: [number, number, number][] = [];
|
||||
rows.forEach((row, ri) => row.forEach((val, ci) => flatData.push([ci, ri, val])));
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
||||
xAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
series: [{ type: 'heatmap', data: flatData }],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function buildHistogram(): { labels: string[]; series: number[][] } {
|
||||
const BUCKETS = 20;
|
||||
const result: number[][] = buffers.map(() => new Array(BUCKETS).fill(0));
|
||||
let globalMin = Infinity, globalMax = -Infinity;
|
||||
histValues.forEach((vals) => {
|
||||
vals.forEach((v) => {
|
||||
if (v < globalMin) globalMin = v;
|
||||
if (v > globalMax) globalMax = v;
|
||||
});
|
||||
});
|
||||
if (!isFinite(globalMin) || !isFinite(globalMax) || globalMin === globalMax) {
|
||||
return { labels: [], series: result };
|
||||
}
|
||||
const range = globalMax - globalMin;
|
||||
const bucketSize = range / BUCKETS;
|
||||
histValues.forEach((vals, si) => {
|
||||
vals.forEach((v) => {
|
||||
const idx = Math.min(BUCKETS - 1, Math.floor((v - globalMin) / bucketSize));
|
||||
result[si][idx]++;
|
||||
});
|
||||
});
|
||||
const labels = Array.from({ length: BUCKETS }, (_, i) =>
|
||||
(globalMin + i * bucketSize).toPrecision(3),
|
||||
);
|
||||
return { labels, series: result };
|
||||
}
|
||||
|
||||
function updateEcharts(echarts?: any) {
|
||||
if (!echartsInstance) return;
|
||||
echartsInstance.setOption(buildEchartsOption(echarts), { notMerge: true });
|
||||
}
|
||||
|
||||
// ── Mount & cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
const defaultColors = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||
|
||||
onMount(async () => {
|
||||
const signals = widget.signals;
|
||||
buffers = signals.map(() => ({ timestamps: [], values: [] }));
|
||||
histValues = signals.map(() => []);
|
||||
|
||||
signals.forEach((sig: SignalRef & { color?: string }, i: number) => {
|
||||
const store = getSignalStore(sig);
|
||||
const unsub = store.subscribe((sv) => {
|
||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||
const ts = new Date(sv.ts).getTime() / 1000;
|
||||
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
|
||||
if (!isNaN(v)) {
|
||||
pushSample(buffers[i], ts, v);
|
||||
if (plotType === 'histogram') {
|
||||
histValues[i].push(v);
|
||||
if (histValues[i].length > RING_MAX) histValues[i].splice(0, histValues[i].length - RING_MAX);
|
||||
}
|
||||
}
|
||||
// Trigger render
|
||||
if (plotType === 'timeseries') {
|
||||
updateUplot();
|
||||
} else {
|
||||
updateEcharts();
|
||||
}
|
||||
});
|
||||
unsubs.push(unsub);
|
||||
});
|
||||
|
||||
if (plotType === 'timeseries') {
|
||||
await initUplot();
|
||||
} else {
|
||||
await initEcharts();
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (uplotInstance) {
|
||||
uplotInstance.setSize({ width: chartEl.clientWidth, height: chartEl.clientHeight });
|
||||
}
|
||||
if (echartsInstance) {
|
||||
echartsInstance.resize();
|
||||
}
|
||||
});
|
||||
ro.observe(containerEl);
|
||||
unsubs.push(() => ro.disconnect());
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubs.forEach((u) => u());
|
||||
if (uplotInstance) uplotInstance.destroy();
|
||||
if (echartsInstance) echartsInstance.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if plotType === 'timeseries'}
|
||||
<link rel="stylesheet" href="/node_modules/uplot/dist/uPlot.min.css" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="plot-widget"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
bind:this={containerEl}
|
||||
>
|
||||
<div class="chart-area" bind:this={chartEl}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plot-widget {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Ensure uPlot fills the container */
|
||||
.chart-area :global(.uplot) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import { wsClient } from '../ws';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
|
||||
const confirm = $derived(widget.options['confirm'] === 'true');
|
||||
const isNumeric = $derived(signalMeta ? signalMeta.type !== 'TypeString' : true);
|
||||
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
const v = signalValue?.value;
|
||||
if (v === null || v === undefined) return '---';
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
let inputValue = $state('');
|
||||
|
||||
function handleSet() {
|
||||
if (!sigRef) return;
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const doWrite = () => {
|
||||
const val = isNumeric ? parseFloat(raw) : raw;
|
||||
wsClient.write(sigRef, val);
|
||||
inputValue = '';
|
||||
};
|
||||
|
||||
if (confirm) {
|
||||
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
|
||||
} else {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSet();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="setvalue"
|
||||
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
<span class="sv-label">{label}:</span>
|
||||
{#if isNumeric}
|
||||
<input
|
||||
class="sv-input"
|
||||
type="number"
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="sv-input"
|
||||
type="text"
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
{/if}
|
||||
<span class="sv-current">{displayValue()}</span>
|
||||
{#if unit}<span class="sv-unit">{unit}</span>{/if}
|
||||
<button class="sv-btn" onclick={handleSet}>Set</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setvalue {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 8px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-input {
|
||||
width: 80px;
|
||||
background: #0f1117;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 5px;
|
||||
flex-shrink: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.sv-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.sv-current {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-unit {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sv-btn {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.sv-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.sv-btn:active {
|
||||
background: #1e40af;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { getSignalStore, getMetaStore } from '../stores';
|
||||
import type { Widget } from '../types';
|
||||
|
||||
let { widget }: { widget: Widget } = $props();
|
||||
|
||||
const sigRef = $derived(widget.signals[0]);
|
||||
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
|
||||
|
||||
// Reactive stores — re-created when sigRef changes
|
||||
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
|
||||
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
|
||||
|
||||
let signalValue = $derived(valueStore ? $valueStore : null);
|
||||
let signalMeta = $derived(metaStore ? $metaStore : null);
|
||||
|
||||
const displayValue = $derived(() => {
|
||||
if (!signalValue || signalValue.value === null || signalValue.value === undefined) {
|
||||
return '---';
|
||||
}
|
||||
const v = signalValue.value;
|
||||
if (typeof v === 'number') {
|
||||
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
const unit = $derived(signalMeta?.unit ?? '');
|
||||
const quality = $derived(signalValue?.quality ?? 'unknown');
|
||||
|
||||
const qualityColor = $derived(() => {
|
||||
switch (quality) {
|
||||
case 'good': return '#4ade80';
|
||||
case 'uncertain': return '#fbbf24';
|
||||
case 'bad': return '#f87171';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="textview"
|
||||
style="
|
||||
left: {widget.x}px;
|
||||
top: {widget.y}px;
|
||||
width: {widget.w}px;
|
||||
height: {widget.h}px;
|
||||
"
|
||||
>
|
||||
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
|
||||
<span class="label">{label}:</span>
|
||||
<span class="value">{displayValue()}</span>
|
||||
{#if unit}
|
||||
<span class="unit">{unit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.textview {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0 0.6em;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quality-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #e2e8f0;
|
||||
min-width: 4ch;
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
private intentionalClose = false;
|
||||
|
||||
// Status store
|
||||
private _statusStore = writable<WsStatus>('connecting');
|
||||
readonly status: Readable<WsStatus> = { subscribe: this._statusStore.subscribe };
|
||||
|
||||
// Subscription tracking: key is "ds\0name"
|
||||
// ref-count: how many callers are subscribed
|
||||
private refCounts = new Map<string, number>();
|
||||
// subscriber callbacks per signal
|
||||
private subscribers = new Map<string, Set<SubscriberEntry>>();
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Interface, Widget, SignalRef } from './types';
|
||||
|
||||
/**
|
||||
* Parse an interface XML string into an Interface object.
|
||||
*
|
||||
* Expected XML format:
|
||||
* <interface name="..." version="1">
|
||||
* <widget id="w1" type="textview" x="100" y="50" w="200" h="60">
|
||||
* <signal ds="stub" name="sine_1hz" color="#ff0000"/>
|
||||
* <option key="label" value="Sine 1Hz"/>
|
||||
* </widget>
|
||||
* </interface>
|
||||
*/
|
||||
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 <interface>, 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<SignalRef & { color?: string }> = [];
|
||||
const options: Record<string, string> = {};
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -7,6 +7,10 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/healthz': 'http://localhost:8080',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user