Files
uopi/docs/TECHNICAL_SPEC.md
T
Martino Ferrari 9aa89cc0cf Initial commit
2026-04-24 15:09:14 +02:00

15 KiB

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

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:

// 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:

// 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.

<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
  <widget id="w1" type="plot" x="100" y="200" w="600" h="300">
    <signal ds="epics" name="EPICS:CURRENT" color="#ff0000"/>
    <signal ds="epics" name="EPICS:VOLTAGE" color="#0000ff"/>
    <option key="plotType" value="timeseries"/>
    <option key="yMin" value="auto"/>
    <option key="yMax" value="auto"/>
    <option key="timeWindow" value="60"/>
  </widget>
  <widget id="w2" type="led" x="50" y="50" w="80" h="80">
    <signal ds="epics" name="EPICS:STATUS"/>
    <option key="condition" value="value &gt; 0"/>
    <option key="colorTrue" value="#00ff00"/>
    <option key="colorFalse" value="#ff0000"/>
    <option key="label" value="OK"/>
  </widget>
</interface>

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)

// One writable store per subscribed signal
const signalStores = new Map<string, Writable<SignalValue>>();

function getStore(signal: string): Readable<SignalValue> { ... }

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

# 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

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:

.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):

[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).