Initial commit

This commit is contained in:
Martino Ferrari
2026-04-24 15:09:14 +02:00
commit 9aa89cc0cf
34 changed files with 3378 additions and 0 deletions
+385
View File
@@ -0,0 +1,385 @@
# 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
<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`)
```typescript
// 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
```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).