Files
uopi/docs/WORK_PLAN.md
T
Martino Ferrari 986f6cd6d8 Phase 6
2026-04-25 22:56:09 +02:00

17 KiB
Raw Blame History

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/uopi/uopi)
  • Create directory layout: cmd/uopi/, internal/, web/
  • Svelte + Vite + TypeScript project in web/
  • //go:embed web/dist in backend via web/embed.go; Makefile builds frontend then backend
  • HTTP server on configurable port; serves embedded frontend
  • TOML config loading (BurntSushi/toml) with UOPI_* env var overrides
  • GitHub Actions CI: runs go test ./... and npm run check on push
  • 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.

  • Define DataSource interface (internal/datasource/iface.go)
  • Implement in-memory stub data source: sine_1hz, sine_01hz, ramp_10s, toggle_1hz, noise, writable setpoint — all at 10 Hz
  • Implement Broker (internal/broker/broker.go):
    • 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 (all subscriptions cancelled)
  • REST API skeleton (internal/api/api.go):
    • GET /api/v1/datasources — lists registered sources
    • GET /api/v1/signals?ds= — lists signals for a source
    • Interface endpoints return 501 stubs (storage in Phase 6)
  • 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.

  • CGo wrapper for EPICS libca (internal/datasource/epics/):
    • Context init with ca_enable_preemptive_callback (no polling loop)
    • ca_create_channel with connection callback shim
    • ca_add_event monitor with value callback (DBR_TIME_* types)
    • ca_put for writes (float64, int64, string, bool)
    • ca_get for DBR_CTRL metadata (units, limits, enum strings)
  • Map CA DBR types to internal DataType enum; CA severity → Quality
  • Implement DataSource interface for EPICS (epics.go)
  • Config: ca_addr_list (overrides EPICS_CA_ADDR_LIST); archive URL
  • Handle disconnected channels gracefully (quality = Bad via connection callback)
  • Integration test with SoftIOC (gated on EPICS_BASE + TEST_PV env vars)
  • noop.go (//go:build !epics) — package compiles without EPICS; epics.Available() returns false
  • archive.go — Archive Appliance JSON HTTP client for History()
  • make backend-epics and make test-epics targets added to Makefile
  • 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) — internal/datasource/synthetic/definition.go
  • Implement DAG evaluator: nodes re-evaluated on upstream change — synthetic.go Subscribe goroutine
  • Built-in node types (internal/dsp/nodes.go):
    • Arithmetic: gain, offset, add, subtract, multiply, divide
    • Statistics: moving average (by count, by time), RMS
    • Filters: IIR biquad lowpass/highpass/bandpass
    • Calculus: finite-difference derivative, cumulative integral
    • Threshold / clamp
    • Custom expression (formula parser)
  • Lua node: sandboxed gopher-lua state per signal; inputs as globals
  • Load synthetic definitions from synthetic.json at startup
  • REST endpoints: GET/POST /api/v1/synthetic, DELETE /api/v1/synthetic/{name}
  • Synthetic DS registered in main.go; storePath = cfg.Server.StorageDir
  • Unit tests for DSP node types and synthetic subscribe logic

Done when: a synthetic moving-average of an EPICS PV is visible in the WebSocket stream.

Notes:

  • Stub DS is always registered; synthetic DS is conditional on cfg.Synthetic.Enabled
  • Pipeline is re-evaluated on every upstream update; no polling
  • REST CRUD routes added to internal/api/api.go; Handler now holds optional *synthetic.Synthetic

Phase 4 — Frontend Foundation

Goal: Svelte app connects to WebSocket and reactively displays values.

  • App shell (App.svelte): mode state (view | edit), WS connect on init, disconnect banner, --dpr CSS variable
  • WebSocket client (ws.ts): singleton WsClient, exponential back-off reconnect (500 ms→30 s), re-subscribe all signals on reconnect, status Svelte readable store
  • Reference-counted subscriptions in WsClient: one WS message per unique signal regardless of number of callers; unsubscribe sent when ref-count hits zero
  • Signal store factory (stores.ts): getSignalStore(ref) and getMetaStore(ref) — lazily created Svelte readable stores, WS subscription started on first use
  • types.ts: SignalRef, SignalValue, SignalMeta, Widget, Interface
  • xml.ts: parseInterface(xml)DOMParser-based XML→Interface converter
  • View mode shell (ViewMode.svelte): collapsible InterfaceList pane (260 px) + Canvas, toolbar with WS status chip
  • InterfaceList.svelte: fetches /api/v1/interfaces, Import XML file picker (calls onLoad prop), "New" stub button, collapse toggle
  • Canvas.svelte: absolute-position widget rendering, --dpr CSS variable, placeholder for null interface
  • TextView.svelte: live {label}: {value} {unit} with quality dot (green/amber/red/grey), positioned absolutely
  • EditMode.svelte: stub ("Edit mode — coming in Phase 6")
  • vite.config.ts: /ws WebSocket proxy added for dev server
  • 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)
  • Frontend build pipeline migrated from npm/Vite/Svelte → pure Go (esbuild API + Preact): Svelte components rewritten as .tsx Preact components; tools/buildfrontend/main.go bundles web/src/main.tsx via github.com/evanw/esbuild; vendor libraries (Preact, uPlot, ECharts) committed as ESM files in web/vendor/; //go:generate go run ../tools/buildfrontend/main.go in web/embed.go; make frontend now runs go generate ./web/... — no Node/npm required

Phase 5 — View Mode Widgets

Goal: all widget types rendered and interactive in view mode.

  • Text view widget (TextView.tsx) — live value + quality dot + unit
  • Gauge widget (Gauge.tsx) — SVG arc, configurable range/thresholds, colour zones
  • Horizontal / vertical bar widget (BarH.tsx, BarV.tsx)
  • LED widget (Led.tsx) — condition evaluator, configurable colours, blink on uncertain quality
  • Multi-LED widget (MultiLed.tsx) — bitset, per-bit labels and colours
  • Set-value widget (SetValue.tsx) — input + Set button; sends write over WS
  • Button widget (Button.tsx) — sends fixed value on click, optional confirm dialog
  • Plot widget (PlotWidget.tsx):
    • Time-series (uPlot, streaming ring buffer, dark axis styling)
    • Histogram, bar chart, FFT, waterfall, logic analyser (ECharts)
    • Multi-signal support; legend; ResizeObserver-based resize
  • Text label (TextLabel.tsx) — static text, configurable font size/colour/align
  • Image widget (ImageWidget.tsx) — renders image from URL, configurable object-fit
  • Link widget (LinkWidget.tsx) — navigates to another interface via onNavigate callback
  • Right-click context menu — signal info, copy signal name, export CSV stub
  • Component tests (deferred to Phase 10 hardening)

Done when: a complete HMI panel with multiple widget types runs smoothly at 60 fps.

Notes:

  • Frontend build pipeline migrated to pure Go (esbuild + Preact); no npm/Node required
  • Canvas.tsx dispatches onNavigate(interfaceId)ViewMode.tsxGET /api/v1/interfaces/{id}
  • Plot widget uses static imports (not await import()) to avoid async race with signal subscriptions

Phase 6 — Edit Mode (Core)

Goal: users can build and save an interface from scratch.

  • internal/storage/store.go — file-based XML storage ({storage_dir}/interfaces/)
  • Interface CRUD REST endpoints: GET/POST /interfaces, GET/PUT/DELETE/POST(clone) /interfaces/{id}
  • Signal tree pane (SignalTree.tsx): fetches all DS/signal lists, collapsible groups, filter/search, drag source
  • Drag signal from tree → drop on canvas → WidgetTypePicker popup (12 widget types)
  • Place widget at drop coordinates with type-appropriate default size
  • EditCanvas.tsx: widget live-preview + transparent overlay divs for interaction; pure DOM mouse events (no Konva)
  • Single selection: 2px blue bounding box + 8 resize handles (N/S/E/W/NE/NW/SE/SW)
  • Move widget by dragging body; resize by dragging handles
  • Delete widget (✕ button on selection or Del/Backspace key)
  • PropertiesPane.tsx: canvas size/name + per-widget position/size/signal/options; type-specific fields
  • Save interface to server (POST creates, PUT updates; XML body)
  • Export / import local XML file
  • Edit mode toolbar: name editor, dirty indicator, Save/Export/Import/Close buttons
  • InterfaceList shows server-persisted interfaces with click-to-view, edit/clone/delete actions
  • Edit button enabled in view mode toolbar

Done when: an engineer can create a panel with 5+ widgets, save it, reload it, and see live data.

Notes:

  • Pure DOM drag/resize (no Konva): overlay div per widget, document.addEventListener('mousemove/mouseup') during drag/resize
  • HTML5 drag-and-drop transfers SignalRef JSON via dataTransfer
  • serializeInterface() in xml.ts serializes Interface → XML; parseInterface() deserializes back
  • Storage IDs are slugified names; uniqueness guaranteed by millisecond timestamp suffix
  • No Konva dependency — Konva deferred to Phase 7 if multi-select area-select is needed

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 (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
  • 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 Complete
4 35 days Complete
5 23 weeks Complete
6 23 weeks Complete
7 12 weeks
8 1 week
9 1 week
10 12 weeks
Total ~1420 weeks