Phase 2/4 done, working on phase 3/5

This commit is contained in:
Martino Ferrari
2026-04-24 15:46:04 +02:00
parent 9aa89cc0cf
commit 8b548ba1c2
43 changed files with 6800 additions and 156 deletions
+94 -64
View File
@@ -18,61 +18,82 @@
---
## Phase 0 — Scaffold
## 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
- [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.
**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
## 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
- [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.
**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
## 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)
- [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.
**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
---
@@ -99,21 +120,30 @@
---
## Phase 4 — Frontend Foundation
## 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
- [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.
**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)
---
@@ -191,7 +221,7 @@
- [ ] 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
- [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
@@ -225,7 +255,7 @@
- [ ] 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`)
- [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
@@ -238,17 +268,17 @@
These are rough single-developer estimates. Parallel work across backend and frontend where possible will reduce calendar time.
| Phase | Effort |
| --------- | ---------------- |
| 0 | 12 days |
| 1 | 35 days |
| 2 | 12 weeks |
| 3 | 12 weeks |
| 4 | 35 days |
| 5 | 23 weeks |
| 6 | 23 weeks |
| 7 | 12 weeks |
| 8 | 1 week |
| 9 | 1 week |
| 10 | 12 weeks |
| **Total** | **~1420 weeks** |
| 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** | |