Initial commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# Functional Specification — uopi
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling industrial/scientific systems, primarily EPICS-based control systems. It runs as a single server process and is accessed entirely through a web browser, making it suitable for SSH-tunnelled remote access.
|
||||
|
||||
---
|
||||
|
||||
## 2. Users and Roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts |
|
||||
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
|
||||
| Administrator | Manages server configuration, data sources, and saved interfaces |
|
||||
|
||||
In the initial version all users share the same access level. Role-based access control is deferred to a future release.
|
||||
|
||||
---
|
||||
|
||||
## 3. System Modes
|
||||
|
||||
### 3.1 View Mode (default)
|
||||
|
||||
The default mode when opening the application.
|
||||
|
||||
**Interface list pane (left, collapsible)**
|
||||
- Displays all interfaces saved on the server, grouped into a tree by folder.
|
||||
- Right-click on an interface: options to open in Edit mode or clone it.
|
||||
- "New interface" button opens Edit mode with a blank canvas.
|
||||
- "Import" option loads an interface from a local XML file.
|
||||
|
||||
**HMI canvas (center)**
|
||||
- Renders the selected interface as a live, interactive panel.
|
||||
- Widgets display real-time data; controls (set-value, buttons) are active.
|
||||
- No drag, resize, or layout operations are possible in this mode.
|
||||
- Right-clicking any widget opens a context menu:
|
||||
- **Signal info** — floating window showing DS name, type, unit, range, current value and timestamp.
|
||||
- **Copy signal name** — copies the signal identifier to the clipboard.
|
||||
- **Export data to CSV** — downloads buffered/historical data for the signal(s) used by the widget.
|
||||
|
||||
**Top toolbar**
|
||||
- Show/hide interface list pane.
|
||||
- Time selector: navigate to a past timestamp (enabled only when the server has archive access for all signals on the canvas).
|
||||
- "Live" button: return to real-time data after historical navigation.
|
||||
|
||||
### 3.2 Edit Mode
|
||||
|
||||
Activated via the "New interface" button or by right-clicking an existing interface.
|
||||
|
||||
**Signal tree pane (left, resizable and collapsible)**
|
||||
- Shows all signals known to each connected data source.
|
||||
- Sources are shown as top-level nodes; signals are nested within.
|
||||
- User can add custom entries:
|
||||
- For EPICS: manually enter a PV name.
|
||||
- For Synthetic: define a new synthetic signal (see §5.2).
|
||||
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
|
||||
- Filter/search box to narrow the list.
|
||||
|
||||
**Widget canvas (center)**
|
||||
- Free-form canvas where widgets can be placed at arbitrary pixel positions.
|
||||
- Background grid with optional snap-to-grid.
|
||||
|
||||
**Properties pane (right, collapsible)**
|
||||
- Appears when one or more widgets are selected.
|
||||
- Displays and edits all options for the selected widget (see §4).
|
||||
|
||||
**Top toolbar**
|
||||
- Show/hide signal pane.
|
||||
- Show/hide properties pane.
|
||||
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
|
||||
- Save interface to server.
|
||||
- Load interface from server.
|
||||
- Export interface to local XML file.
|
||||
- Import interface from local XML file.
|
||||
- "Add text" tool — inserts a static text label.
|
||||
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
|
||||
- "Add link" tool — inserts a button that opens another interface.
|
||||
|
||||
---
|
||||
|
||||
## 4. Widgets
|
||||
|
||||
### 4.1 Creating Widgets
|
||||
|
||||
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type (iconised). The user selects one and the widget is placed at the drop location with default size.
|
||||
|
||||
### 4.2 Selecting Widgets
|
||||
|
||||
- Single click: select one widget (deselects others).
|
||||
- Ctrl+click: add/remove a widget from the current selection.
|
||||
- Click-drag on empty canvas area: rubber-band area select.
|
||||
|
||||
When a widget is selected, a bounding box appears with:
|
||||
- 8 resize handles (corners + midpoints).
|
||||
- A delete button (×) in the top-right corner.
|
||||
- The widget can be moved by dragging its body.
|
||||
|
||||
### 4.3 Multi-selection Operations
|
||||
|
||||
When multiple widgets are selected:
|
||||
- Drag any selected widget to move them all together.
|
||||
- Del key deletes all selected widgets.
|
||||
- An align/distribute toolbar appears above the canvas with:
|
||||
- Align left / center horizontal / right.
|
||||
- Align top / center vertical / bottom.
|
||||
- Distribute evenly — by center spacing (horizontal/vertical).
|
||||
- Distribute evenly — by gap size (horizontal/vertical).
|
||||
|
||||
### 4.4 Widget Catalogue
|
||||
|
||||
| Widget | Compatible signal types | Description |
|
||||
|--------|-------------------------|-------------|
|
||||
| Text view | any scalar | Displays `name: value unit` |
|
||||
| Gauge | numeric scalar | Circular or arc gauge with configurable range |
|
||||
| Vertical bar | numeric scalar | Vertical level indicator |
|
||||
| Horizontal bar | numeric scalar | Horizontal level indicator |
|
||||
| Set value | numeric or string, writable | Shows `name: [input field] current_value unit` + Set button |
|
||||
| LED | boolean / numeric | Coloured indicator with configurable condition and label |
|
||||
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
|
||||
| Button | writable | Sends a fixed value or command on click |
|
||||
| Plot | numeric scalar or array | Multi-signal plot; sub-types below |
|
||||
| Text label | — | Static text annotation |
|
||||
| Image | — | Static image |
|
||||
| Link | — | Button navigating to another interface |
|
||||
|
||||
**Plot sub-types:**
|
||||
|
||||
| Sub-type | Signal requirement |
|
||||
|----------|--------------------|
|
||||
| Time series | numeric scalar(s) |
|
||||
| FFT | 1-D numeric array |
|
||||
| Waterfall | 1-D numeric array (repeated) |
|
||||
| Histogram | numeric scalar(s) |
|
||||
| Bar chart | numeric scalar(s) |
|
||||
| Logic analyser | boolean / integer (bitset) |
|
||||
|
||||
### 4.5 Widget Properties (Properties Pane)
|
||||
|
||||
Common to all:
|
||||
- Label text, font size, text colour.
|
||||
- Position (X, Y) and size (W, H) — editable numerically.
|
||||
- Data source and signal name (read-only after creation; reassignable via drag).
|
||||
|
||||
Per type:
|
||||
- **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label.
|
||||
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states.
|
||||
- **Plot**: plot sub-type selector, Y-axis range (auto or manual), time window duration, legend position, colour per signal, line style.
|
||||
- **Set value**: input type (numeric / string / enum), confirmation prompt toggle.
|
||||
- **Link**: target interface name.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Sources
|
||||
|
||||
### 5.1 EPICS
|
||||
|
||||
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA).
|
||||
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range (DRVL/DRVH, LOPR/HOPR), alarm limits, enum strings (for mbbi/mbbo records), read/write mode, acquisition mode.
|
||||
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable.
|
||||
- Attempts to enumerate available PV names from the IOC (e.g. via PV lists or Channel Finder). Unknown PVs can still be manually added by the user.
|
||||
- When an EPICS Archive Appliance or Channel Archiver is configured, the server can satisfy historical data requests.
|
||||
|
||||
### 5.2 Synthetic
|
||||
|
||||
A signal defined by composing one or more input signals (from any data source) through a chain of processing functions.
|
||||
|
||||
**Built-in processing functions (non-exhaustive):**
|
||||
- Arithmetic: gain, offset, add, subtract, multiply, divide.
|
||||
- Signal processing: moving average (N samples or time window), RMS, bandpass filter (IIR/FIR), lowpass / highpass filter, derivative, integral.
|
||||
- FFT, inverse FFT.
|
||||
- Peak detection, threshold crossing.
|
||||
- Custom formula: inline expression (`a * sin(b) + c`).
|
||||
- Lua script block: arbitrary Lua code with access to input values and state.
|
||||
|
||||
**User workflow:**
|
||||
1. Click "New synthetic signal" in the signal tree.
|
||||
2. Name the signal and choose input signals.
|
||||
3. Build a processing pipeline by chaining function blocks (UI similar to a node graph or an ordered list).
|
||||
4. Optionally write a Lua snippet for custom logic.
|
||||
5. The synthetic signal appears in the tree and can be used like any other signal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface Persistence
|
||||
|
||||
- Interfaces are saved to the server in XML format and are available to all connected clients.
|
||||
- Export/Import allows local file exchange of XML files.
|
||||
- The XML schema records: widget type, position, size, signal bindings, and all property values.
|
||||
|
||||
---
|
||||
|
||||
## 7. Historical Data Navigation
|
||||
|
||||
When the server has archive access for all signals on the current canvas:
|
||||
- The top toolbar shows a date/time picker.
|
||||
- Selecting a past time replays data from the archive into all widgets.
|
||||
- The "Live" button resumes real-time streaming.
|
||||
- Widgets that support time-axis (plots) show the historical range; point-value widgets show the value at the selected time.
|
||||
|
||||
---
|
||||
|
||||
## 8. Non-functional Requirements
|
||||
|
||||
| Requirement | Target |
|
||||
|-------------|--------|
|
||||
| Server binary | Single statically-linked executable; no runtime dependencies |
|
||||
| Target platform | Linux x86-64; also aarch64 optional |
|
||||
| Minimum server OS | RHEL/CentOS 7 (glibc 2.17) or equivalent |
|
||||
| Concurrent clients | ≥ 20 simultaneous browser clients |
|
||||
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
|
||||
| Frontend responsiveness | 60 fps canvas rendering during live updates |
|
||||
| Screen DPI | Frontend adapts to device pixel ratio |
|
||||
@@ -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 > 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).
|
||||
@@ -0,0 +1,254 @@
|
||||
# Work Plan — uopi
|
||||
|
||||
## Phases Overview
|
||||
|
||||
| Phase | Name | Focus | Milestone |
|
||||
| ----- | --------------------- | ---------------------------------------------------------- | ----------------------------------------- |
|
||||
| 0 | Scaffold | Repo structure, build toolchain, CI | Empty binary serves embedded index page |
|
||||
| 1 | Core Backend | Broker, WebSocket protocol, REST skeleton | Live signal fan-out working end-to-end |
|
||||
| 2 | EPICS Data Source | CA connect, monitor, metadata, write | Real EPICS PVs visible in browser console |
|
||||
| 3 | Synthetic Data Source | DSP engine, Lua sandbox, signal composition | Synthetic signals computed and streamed |
|
||||
| 4 | Frontend Foundation | Svelte skeleton, WS client, signal stores, view mode shell | Subscribed values rendered in browser |
|
||||
| 5 | View Mode Widgets | All widget types rendered in view mode | Full interactive HMI panel |
|
||||
| 6 | Edit Mode | Konva canvas, drag-and-drop, resize, properties pane | Can build and save a panel |
|
||||
| 7 | Edit Mode — Advanced | Multi-select, align/distribute, undo/redo | Complete editor UX |
|
||||
| 8 | Historical Data | Archive integration, time navigation UI | Replay past data in widgets |
|
||||
| 9 | Signal Discovery | Signal tree, CSV import, EPICS Channel Finder | Usable without manual PV entry |
|
||||
| 10 | Hardening | Docs, packaging, integration tests, performance | Production-ready binary |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**Done when:** `./dist/uopi` starts and serves an "under construction" page.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**Done when:** a `wscat` client can subscribe to the stub source and receive timed updates.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
**Done when:** a PV subscription round-trips from IOC → broker → WebSocket client with correct timestamp and units.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Synthetic Data Source
|
||||
|
||||
**Goal:** users can define computed signals from existing signals.
|
||||
|
||||
- [ ] Define synthetic signal definition schema (JSON)
|
||||
- [ ] Implement DAG evaluator: nodes re-evaluated on upstream change
|
||||
- [ ] Built-in node types (`internal/dsp/`):
|
||||
- Arithmetic: gain, offset, add, subtract, multiply, divide
|
||||
- Statistics: moving average (by count, by time), RMS
|
||||
- Filters: IIR lowpass/highpass/bandpass (via gonum or biquad)
|
||||
- Calculus: finite-difference derivative, cumulative integral
|
||||
- FFT / inverse FFT (gonum/dft)
|
||||
- Threshold / clamp
|
||||
- Custom expression (simple formula parser)
|
||||
- [ ] Lua node: sandboxed `gopher-lua` state per signal; inputs as globals
|
||||
- [ ] Load synthetic definitions from `synthetic.json` at startup
|
||||
- [ ] REST endpoint to CRUD synthetic signal definitions (persists to JSON file)
|
||||
- [ ] Unit tests for each DSP node type
|
||||
|
||||
**Done when:** a synthetic moving-average of an EPICS PV is visible in the WebSocket stream.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
**Done when:** loading a manually crafted XML interface displays live PV values.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — View Mode Widgets
|
||||
|
||||
**Goal:** all widget types rendered and interactive in view mode.
|
||||
|
||||
- [ ] Text view widget
|
||||
- [ ] Gauge widget (SVG arc, configurable range/thresholds)
|
||||
- [ ] Vertical / horizontal bar widget
|
||||
- [ ] LED widget (condition evaluator, configurable colours)
|
||||
- [ ] Multi-LED widget (bitset, per-bit labels)
|
||||
- [ ] Set-value widget (input + Set button; sends `write` over WS)
|
||||
- [ ] Button widget (sends fixed value on click)
|
||||
- [ ] Plot widget:
|
||||
- Time-series (uPlot, streaming buffer of N points)
|
||||
- FFT, waterfall, histogram, bar chart, logic analyser (ECharts)
|
||||
- Multi-signal support; legend
|
||||
- [ ] Text label (static)
|
||||
- [ ] Image widget (base64 or server URL)
|
||||
- [ ] Link widget (navigates to another interface)
|
||||
- [ ] Right-click context menu: signal info dialog, copy name, export CSV
|
||||
- [ ] Svelte component tests for each widget type (mock store)
|
||||
|
||||
**Done when:** a complete HMI panel with multiple widget types runs smoothly at 60 fps.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Edit Mode (Core)
|
||||
|
||||
**Goal:** users can build and save an interface from scratch.
|
||||
|
||||
- [ ] Konva stage setup in edit mode; `devicePixelRatio` scaling
|
||||
- [ ] Widget renderer adapter: same widget components rendered on Konva layer
|
||||
- [ ] Signal tree pane: fetch signal list, tree display, filter/search
|
||||
- [ ] Drag signal from tree → drop on canvas → widget type picker
|
||||
- [ ] Place widget at drop coordinates with default size
|
||||
- [ ] Single selection: bounding box + resize handles via Konva `Transformer`
|
||||
- [ ] Move widget by dragging body
|
||||
- [ ] Delete widget (× button or Del key)
|
||||
- [ ] Properties pane: common options (label, position, size)
|
||||
- [ ] Per-widget property editors (range, colour, plot type, etc.)
|
||||
- [ ] Save interface to server (POST/PUT XML)
|
||||
- [ ] Load interface from server (GET XML, populate canvas)
|
||||
- [ ] Export / import local XML file
|
||||
|
||||
**Done when:** an engineer can create a panel with 5+ widgets, save it, reload it, and see live data.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Edit Mode (Advanced)
|
||||
|
||||
**Goal:** complete editing UX matching the functional spec.
|
||||
|
||||
- [ ] Multi-select: Ctrl+click toggle; rubber-band area select
|
||||
- [ ] Group move: drag any selected widget to move all
|
||||
- [ ] Group delete: Del key on selection
|
||||
- [ ] Align toolbar: left / center-H / right / top / center-V / bottom
|
||||
- [ ] Distribute toolbar: evenly by center / by gap (H and V)
|
||||
- [ ] Undo / redo: command pattern, Ctrl+Z / Ctrl+Shift+Z
|
||||
- [ ] Snap-to-grid (optional, toggle in toolbar)
|
||||
- [ ] Add text label tool
|
||||
- [ ] Add image tool (upload to server or embed base64)
|
||||
- [ ] Add link tool
|
||||
- [ ] Collapsible signal tree pane and properties pane (toggle buttons)
|
||||
- [ ] Right-click on interface in list: Edit / Clone / Delete
|
||||
|
||||
**Done when:** the editor feels complete and the undo stack works reliably.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Historical Data
|
||||
|
||||
**Goal:** users can navigate to past timestamps using archive data.
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] Point-value widgets: show value at selected timestamp
|
||||
|
||||
**Done when:** a plot can display 24 hours of archived data with a time slider.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Signal Discovery
|
||||
|
||||
**Goal:** users can find signals without knowing their names in advance.
|
||||
|
||||
- [ ] EPICS: attempt Channel Finder or `cainfo` enumeration; fall back to manual entry
|
||||
- [ ] Signal tree: lazy-load children on expand
|
||||
- [ ] Manual add custom PV (EPICS): text input in signal tree
|
||||
- [ ] New synthetic signal wizard: name, inputs, node graph UI
|
||||
- [ ] CSV import: parse `NAME, DataSource, DS_PARAMETERS`; add to tree
|
||||
|
||||
**Done when:** a new user can find and subscribe to a PV without prior knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Hardening
|
||||
|
||||
**Goal:** production-quality binary, docs, performance validation.
|
||||
|
||||
- [ ] End-to-end integration tests (real SoftIOC, headless browser via Playwright)
|
||||
- [ ] Benchmark: 20 clients × 100 signals; verify < 5 ms fan-out latency
|
||||
- [ ] Benchmark: frontend at 10 Hz update rate; verify 60 fps
|
||||
- [ ] 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`)
|
||||
- [ ] `/metrics` endpoint (Prometheus format) for server monitoring
|
||||
- [ ] Complete `README.md` and operator docs
|
||||
- [ ] Release: `goreleaser` config for Linux amd64 + arm64 binaries
|
||||
|
||||
**Done when:** binary passes integration tests on CentOS 7 container with a SoftIOC.
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
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** |
|
||||
Reference in New Issue
Block a user