Files
uopi/docs/WORK_PLAN.md
T
2026-04-24 15:46:04 +02:00

285 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- [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. ✅
**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 ✅
**Goal:** signal broker and WebSocket protocol working with a stub data source.
- [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. ✅
**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 ✅
**Goal:** real EPICS PVs readable and writable through the broker.
- [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. ✅ (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
---
## 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.
- [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. ✅
**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)
---
## 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
- [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
- [ ] 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
- [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
**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 | Status |
| --------- | ---------------- | ----------- |
| 0 | 12 days | ✅ Complete |
| 1 | 35 days | ✅ Complete |
| 2 | 12 weeks | ✅ Complete |
| 3 | 12 weeks | — |
| 4 | 35 days | ✅ Complete |
| 5 | 23 weeks | — |
| 6 | 23 weeks | — |
| 7 | 12 weeks | — |
| 8 | 1 week | — |
| 9 | 1 week | — |
| 10 | 12 weeks | — |
| **Total** | **~1420 weeks** | |