# Technical Specification — uopi ## 1. Technology Choices ### 1.1 Backend — Go **Rationale:** - Compiles to a single static binary with no runtime dependencies, trivially portable to old Linux targets. - `//go:embed` packs the compiled frontend assets into the binary at build time. - Goroutine-per-connection model maps naturally onto the fan-out data broker pattern. - CGo bindings to EPICS `libca` / `libCom` are straightforward. - `gopher-lua` provides an embedded Lua 5.1-compatible interpreter for synthetic signals with zero additional dependencies. - Strong standard library: `net/http`, `encoding/xml`, `encoding/json`. **Go version:** 1.22+ **Key dependencies:** | Package | Purpose | | ---------------------------- | ------------------------------------------------------ | | `nhooyr.io/websocket` | WebSocket server (no CGo, more ergonomic than gorilla) | | `go-epics/ca` or CGo wrapper | EPICS Channel Access | | `yuin/gopher-lua` | Lua 5.1 runtime for synthetic signals | | `gonum.org/v1/gonum` | DSP and math functions (FFT, filters) | | `encoding/xml` (stdlib) | Interface file serialisation | | `net/http` (stdlib) | HTTP server and static file serving | ### 1.2 Frontend — Svelte + TypeScript **Rationale:** - Svelte compiles to vanilla JS with no virtual DOM, giving the smallest bundle and lowest runtime overhead — essential for the 60 fps reactive feel required. - Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival. - TypeScript catches signal subscription and widget property type errors at build time. **Key dependencies:** | Package | Purpose | | ----------------- | ------------------------------------------------------------------------------ | | `svelte` + `vite` | Framework and build toolchain | | `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) | | `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots | | `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) | | `svelte-konva` | Svelte bindings for Konva | **Intentionally excluded:** React, Vue, WebGPU, jQuery. --- ## 2. Repository Layout ``` uopi/ ├── cmd/uopi/ # main package — CLI flags, wiring ├── internal/ │ ├── server/ # HTTP + WebSocket handlers │ ├── broker/ # signal fan-out to clients │ ├── datasource/ │ │ ├── iface.go # DataSource interface │ │ ├── epics/ # EPICS CA/PVA implementation │ │ └── synthetic/ # synthetic signal engine │ ├── lua/ # Lua sandbox helpers │ ├── dsp/ # DSP functions (wraps gonum + custom) │ ├── storage/ # interface XML read/write │ └── api/ # REST handler functions ├── web/ # Svelte source │ ├── src/ │ │ ├── lib/ │ │ │ ├── ws.ts # WebSocket client + subscription manager │ │ │ ├── stores.ts # Svelte stores for signal values │ │ │ ├── widgets/ # one .svelte file per widget type │ │ │ └── editor/ # edit-mode canvas, toolbar, properties pane │ │ ├── routes/ │ │ │ ├── +page.svelte # view mode │ │ │ └── edit/+page.svelte # edit mode │ │ └── app.html │ ├── package.json │ └── vite.config.ts ├── docs/ # specs, work plan ├── CLAUDE.md └── README.md ``` --- ## 3. Backend Architecture ### 3.1 DataSource Interface ```go type Value struct { Timestamp time.Time Data any // float64 | []float64 | string | int64 | bool Quality Quality // Good | Bad | Uncertain } type Metadata struct { Name string Type DataType Unit string DisplayLow float64 DisplayHigh float64 DriveHigh float64 DriveLow float64 EnumStrings []string Writable bool } type DataSource interface { Name() string Connect(ctx context.Context) error ListSignals(ctx context.Context) ([]Metadata, error) GetMetadata(ctx context.Context, signal string) (Metadata, error) Subscribe(ctx context.Context, signal string, ch chan<- Value) (CancelFunc, error) Write(ctx context.Context, signal string, value any) error History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]Value, error) } ``` New data sources are registered at startup via `datasource.Register(name string, ds DataSource)`. ### 3.2 Signal Broker The broker is the central fan-out component: ``` DataSource ──subscribe──► rawCh ──► Broker ──► [clientCh1, clientCh2, ...] ``` - One goroutine per active signal subscription to the underlying data source. - Per-signal subscriber list protected by a sync.RWMutex. - When the last client unsubscribes, the broker cancels the upstream subscription. - No data is buffered in the broker; clients receive the latest value at the moment they subscribe and all subsequent updates. ### 3.3 WebSocket Protocol Framing: JSON messages over a single persistent WebSocket connection per client. **Client → Server messages:** ```jsonc // Subscribe to one or more signals { "type": "subscribe", "signals": ["EPICS:PV1", "synth:mySignal"] } // Unsubscribe { "type": "unsubscribe", "signals": ["EPICS:PV1"] } // Write a value { "type": "write", "signal": "EPICS:PV1", "value": 3.14 } // Request historical data { "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 } ``` **Server → Client messages:** ```jsonc // Live value update { "type": "update", "signal": "EPICS:PV1", "ts": "2026-04-24T12:00:00.123Z", "value": 42.7, "quality": "good" } // Metadata (sent once on first subscribe) { "type": "meta", "signal": "EPICS:PV1", "meta": { "unit": "A", "displayLow": 0, "displayHigh": 100, ... } } // Historical data response { "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] } // Error { "type": "error", "code": "NOT_FOUND", "message": "Signal not found" } ``` ### 3.4 REST API Base path: `/api/v1` | Method | Path | Description | | ------ | ------------------------- | -------------------------------------------- | | GET | `/datasources` | List connected data sources and their status | | GET | `/signals?ds=epics` | List signals for a data source | | GET | `/signals/:ds/:name/meta` | Get full metadata for a signal | | GET | `/interfaces` | List saved interfaces | | POST | `/interfaces` | Create a new interface (body: XML) | | GET | `/interfaces/:id` | Download interface XML | | PUT | `/interfaces/:id` | Update interface XML | | DELETE | `/interfaces/:id` | Delete interface | | POST | `/interfaces/:id/clone` | Clone an interface | ### 3.5 EPICS Data Source - Uses CGo bindings to EPICS Base `libca` (Channel Access). PVAccess support via `p4p` C library or a pure-Go PVA client if available. - Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it. - On connect, a `ca_get` retrieves full DBR_CTRL metadata (units, limits, enum strings). - `ca_add_event` sets up the monitor. Update callbacks push into the broker's raw channel. - Multiple PV subscriptions share one CA context per data source instance (thread-safe with `ca_attach_context`). - EPICS Archive Appliance is queried via its JSON HTTP API for history requests. ### 3.6 Synthetic Data Source - Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes. - Processing nodes are re-evaluated whenever any upstream signal emits a new value. - Built-in node types implemented on top of `gonum/dsp` and custom code. - Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table. - Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files. ### 3.7 Interface Storage Interfaces are stored as XML files in a configurable directory on the server. ```xml ``` --- ## 4. Frontend Architecture ### 4.1 WebSocket Client (`ws.ts`) - Singleton WebSocket connection, reconnects with exponential back-off. - Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message. - Incoming updates are dispatched to signal stores. ### 4.2 Signal Stores (`stores.ts`) ```typescript // One writable store per subscribed signal const signalStores = new Map>(); function getStore(signal: string): Readable { ... } ``` Widgets import `getStore(signalName)` and bind to it reactively. Svelte's fine-grained reactivity ensures only the relevant widgets re-render on each update. ### 4.3 Edit Mode Canvas The edit-mode canvas is implemented with **Konva.js** via `svelte-konva`: - Each widget is a Konva Group containing its visual elements. - A `Transformer` node provides resize handles and enforces minimum sizes. - Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from `stage.getPointerPosition()`. - Undo/redo uses a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100). - Align/distribute operations compute target positions geometrically and generate a single grouped undo entry. ### 4.4 Widget Rendering in View Mode In view mode the Konva canvas is replaced with a lightweight SVG/HTML layer. Each widget is a Svelte component that: 1. Subscribes to its signal store(s) in `onMount`. 2. Receives reactive updates and re-renders only its own DOM subtree. Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own canvas elements inside the Svelte component. ### 4.5 DPI Adaptation - CSS uses `rem` units throughout for text. - Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed. - Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`. --- ## 5. Build System ### 5.1 Backend ```makefile # Build static binary (requires EPICS base installed or cross-compiled libca) CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi # Run tests go test ./... # Run a single test go test ./internal/broker/... -run TestFanOut ``` EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`. ### 5.2 Frontend ```bash cd web npm install npm run dev # dev server at http://localhost:5173 (proxies /api to backend) npm run build # outputs to web/dist/ npm run check # svelte-check type checking npm run lint # eslint + prettier ``` ### 5.3 Combined Build A `Makefile` at the repo root: ```makefile .PHONY: all frontend backend clean all: frontend backend frontend: cd web && npm ci && npm run build backend: frontend go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi test: go test ./... cd web && npm run check clean: rm -rf dist/ web/dist/ ``` The backend's `//go:embed web/dist` directive picks up the built frontend automatically. --- ## 6. Configuration Server is configured via a TOML file (default: `uopi.toml`, overridable via `--config` flag): ```toml [server] listen = ":8080" storage_dir = "./interfaces" [datasource.epics] enabled = true ca_addr_list = "" # EPICS_CA_ADDR_LIST override archive_url = "" # EPICS Archive Appliance URL [datasource.synthetic] enabled = true definitions_file = "./synthetic.json" ``` All settings can also be overridden with environment variables: `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`, etc. --- ## 7. Testing Strategy | Layer | Approach | | ------------------ | --------------------------------------------------------------------------------- | | Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle | | Synthetic DSP | Table-driven unit tests against known signal inputs/outputs | | Lua sandbox | Unit tests for sandbox isolation and API surface | | REST API | `httptest` integration tests | | WebSocket protocol | Integration tests with a test client | | EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) | | Frontend | Svelte component tests via `vitest` + `@testing-library/svelte` | --- ## 8. Security Considerations - Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets. - WebSocket write operations: validate that the target signal is writable before forwarding to the data source. - Interface XML parsing: use strict schema validation to prevent XXE. - No authentication in v1; intended for trusted LAN / SSH-tunnel deployment. --- ## 9. Non-goals (v1) - User authentication and authorisation. - TLS termination (expected to be handled by SSH tunnel or a reverse proxy). - Windows or macOS server binary. - Mobile-optimised frontend layout. - Remote plugin loading (plugins compiled in at build time only).