This commit is contained in:
Martino Ferrari
2026-04-25 22:56:09 +02:00
parent 8b548ba1c2
commit 986f6cd6d8
85 changed files with 11479 additions and 5050 deletions
+5 -11
View File
@@ -41,7 +41,7 @@ Two primary modes toggled via toolbar:
## Technology Stack ## Technology Stack
- **Backend:** Go 1.22+, single binary, `//go:embed` for frontend assets - **Backend:** Go 1.22+, single binary, `//go:embed` for frontend assets
- **Frontend:** Svelte 5 + TypeScript + Vite; plots via uPlot (time-series) and ECharts (others); edit canvas via Konva - **Frontend:** Preact 10 + TypeScript; bundled by esbuild Go API (no npm/Node.js); plots via uPlot (time-series) and ECharts (others); all vendor JS/CSS checked into `web/vendor/`
- **Config:** TOML file + `UOPI_*` env var overrides (`github.com/BurntSushi/toml`) - **Config:** TOML file + `UOPI_*` env var overrides (`github.com/BurntSushi/toml`)
- **WebSocket:** `nhooyr.io/websocket` (no CGo) - **WebSocket:** `nhooyr.io/websocket` (no CGo)
- **EPICS:** CGo bindings to `libca`; `gopher-lua` for synthetic signal Lua scripts - **EPICS:** CGo bindings to `libca`; `gopher-lua` for synthetic signal Lua scripts
@@ -57,7 +57,10 @@ internal/datasource/# DataSource interface + EPICS + synthetic (Phases 23)
internal/dsp/ # DSP functions for synthetic (Phase 3) internal/dsp/ # DSP functions for synthetic (Phase 3)
internal/storage/ # interface XML persistence (Phase 6) internal/storage/ # interface XML persistence (Phase 6)
internal/api/ # REST handlers (Phase 1+) internal/api/ # REST handlers (Phase 1+)
web/ # Svelte source (npm); web/embed.go provides embed.FS to Go web/ # Preact/TSX source; web/embed.go provides embed.FS to Go
web/src/ # TypeScript/TSX components (Preact)
web/vendor/ # vendored JS/CSS (preact, uplot, echarts) — no npm required
tools/buildfrontend/# esbuild Go API bundler (invoked by go generate)
web/dist/ # built frontend — generated, not committed web/dist/ # built frontend — generated, not committed
dist/ # compiled binary — generated, not committed dist/ # compiled binary — generated, not committed
``` ```
@@ -79,9 +82,6 @@ make frontend
# Run backend (dev mode, frontend must be running separately) # Run backend (dev mode, frontend must be running separately)
go run ./cmd/uopi go run ./cmd/uopi
# Frontend dev server (proxies /api and /healthz to :8080)
cd web && npm run dev
# All tests # All tests
make test make test
@@ -90,12 +90,6 @@ go test ./internal/broker/... -run TestFanOut
# Go vet # Go vet
go vet ./... go vet ./...
# Svelte type check
cd web && npm run check
# Svelte lint
cd web && npm run lint
``` ```
## Key Architectural Notes ## Key Architectural Notes
+57 -28
View File
@@ -1,43 +1,72 @@
.PHONY: all frontend backend backend-epics test test-epics clean .PHONY: all frontend backend backend-epics backend-debug test lint clean run
all: frontend backend # --------------------------------------------------------------------------- #
# Sources — adding any file here triggers a frontend or backend rebuild #
# --------------------------------------------------------------------------- #
frontend: FRONTEND_SRCS := $(shell find web/src -name '*.tsx' -o -name '*.ts' -o -name '*.css') \
cd web && npm ci && npm run build $(wildcard web/vendor/*.mjs web/vendor/*.js web/vendor/*.css) \
tools/buildfrontend/main.go
backend: GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
# Build backend with EPICS Channel Access support. # --------------------------------------------------------------------------- #
# Requires EPICS Base to be installed and the following environment variables: # Outputs #
# --------------------------------------------------------------------------- #
FRONTEND_OUT := web/dist/main.js
BINARY := dist/uopi
# --------------------------------------------------------------------------- #
# Top-level targets #
# --------------------------------------------------------------------------- #
all: $(BINARY)
frontend: $(FRONTEND_OUT)
backend: $(BINARY)
# --------------------------------------------------------------------------- #
# Build rules #
# --------------------------------------------------------------------------- #
# Bundle TypeScript/TSX → web/dist/ using esbuild (pure Go, no npm)
$(FRONTEND_OUT): $(FRONTEND_SRCS)
@mkdir -p web/dist
go run ./tools/buildfrontend/main.go
# Compile the self-contained binary (embeds web/dist at build time)
$(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
@mkdir -p dist
go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
# Build with EPICS Channel Access support.
# Requires EPICS Base and:
# EPICS_BASE=/path/to/epics/base # EPICS_BASE=/path/to/epics/base
# CGO_CFLAGS="-I${EPICS_BASE}/include -I${EPICS_BASE}/include/os/Linux" # CGO_CFLAGS="-I$$EPICS_BASE/include -I$$EPICS_BASE/include/os/Linux"
# CGO_LDFLAGS="-L${EPICS_BASE}/lib/linux-x86_64 -lca -lCom" # CGO_LDFLAGS="-L$$EPICS_BASE/lib/linux-x86_64 -lca -lCom"
backend-epics: backend-epics: $(FRONTEND_OUT)
CGO_ENABLED=1 go build -tags epics -ldflags="-s -w" -o dist/uopi ./cmd/uopi @mkdir -p dist
CGO_ENABLED=1 go build -tags epics -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
# Build backend without -s -w for debugging # Unstripped binary for debugging
backend-debug: backend-debug: $(FRONTEND_OUT)
go build -o dist/uopi ./cmd/uopi @mkdir -p dist
go build -o $(BINARY) ./cmd/uopi
# --------------------------------------------------------------------------- #
# Dev / CI #
# --------------------------------------------------------------------------- #
test: test:
go test ./... go test ./...
cd web && npm run check
# Run EPICS integration tests (requires EPICS Base; see backend-epics target).
test-epics:
go test -tags epics ./internal/datasource/epics/...
lint: lint:
go vet ./... go vet ./...
cd web && npm run lint
run: $(BINARY)
$(BINARY)
clean: clean:
rm -rf dist/ web/dist/ rm -rf dist/ web/dist/
# Run dev servers (requires two terminals or a process manager)
run-backend:
go run ./cmd/uopi
run-frontend:
cd web && npm run dev
+32 -12
View File
@@ -15,7 +15,9 @@ import (
"github.com/uopi/uopi/internal/config" "github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/server" "github.com/uopi/uopi/internal/server"
"github.com/uopi/uopi/internal/storage"
"github.com/uopi/uopi/web" "github.com/uopi/uopi/web"
) )
@@ -40,6 +42,12 @@ func main() {
os.Exit(1) os.Exit(1)
} }
store, err := storage.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open interface store", "err", err)
os.Exit(1)
}
webFS, err := fs.Sub(web.FS, "dist") webFS, err := fs.Sub(web.FS, "dist")
if err != nil { if err != nil {
log.Error("failed to sub web dist", "err", err) log.Error("failed to sub web dist", "err", err)
@@ -51,18 +59,30 @@ func main() {
brk := broker.New(ctx, log) brk := broker.New(ctx, log)
// Register data sources // Stub data source is always registered — it provides synthetic test signals
if cfg.Synthetic.Enabled { // used by demo interfaces and unit tests.
ds := stub.New() stubDS := stub.New()
if err := ds.Connect(ctx); err != nil { if err := stubDS.Connect(ctx); err != nil {
log.Error("stub connect", "err", err) log.Error("stub connect", "err", err)
os.Exit(1) os.Exit(1)
}
brk.Register(ds)
} }
// Register EPICS Channel Access data source (Phase 2). brk.Register(stubDS)
// epics.Available() returns false when built without -tags epics so that
// machines without EPICS Base installed still compile and run correctly. // Synthetic data source: computes derived signals from upstream sources via
// configurable DSP pipelines defined in synthetic.json inside the storage dir.
var synthDS *synthetic.Synthetic
if cfg.Synthetic.Enabled {
synthDS = synthetic.New(cfg.Server.StorageDir, brk, log)
if err := synthDS.Connect(ctx); err != nil {
log.Warn("synthetic connect failed — continuing without synthetic signals", "err", err)
synthDS = nil
} else {
brk.Register(synthDS)
}
}
// EPICS Channel Access data source (requires -tags epics build).
// epics.Available() returns false when built without the tag.
if cfg.EPICS.Enabled && epics.Available() { if cfg.EPICS.Enabled && epics.Available() {
ds := epics.New(cfg.EPICS.CAAddrList, cfg.EPICS.ArchiveURL) ds := epics.New(cfg.EPICS.CAAddrList, cfg.EPICS.ArchiveURL)
if err := ds.Connect(ctx); err != nil { if err := ds.Connect(ctx); err != nil {
@@ -72,7 +92,7 @@ func main() {
} }
} }
srv := server.New(cfg.Server.Listen, webFS, brk, log) srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, log)
if err := srv.Start(ctx); err != nil { if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err) fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+68 -48
View File
@@ -97,26 +97,31 @@
--- ---
## Phase 3 — Synthetic Data Source ## Phase 3 — Synthetic Data Source
**Goal:** users can define computed signals from existing signals. **Goal:** users can define computed signals from existing signals.
- [ ] Define synthetic signal definition schema (JSON) - [x] Define synthetic signal definition schema (JSON)`internal/datasource/synthetic/definition.go`
- [ ] Implement DAG evaluator: nodes re-evaluated on upstream change - [x] Implement DAG evaluator: nodes re-evaluated on upstream change`synthetic.go` Subscribe goroutine
- [ ] Built-in node types (`internal/dsp/`): - [x] Built-in node types (`internal/dsp/nodes.go`):
- Arithmetic: gain, offset, add, subtract, multiply, divide - Arithmetic: gain, offset, add, subtract, multiply, divide
- Statistics: moving average (by count, by time), RMS - Statistics: moving average (by count, by time), RMS
- Filters: IIR lowpass/highpass/bandpass (via gonum or biquad) - Filters: IIR biquad lowpass/highpass/bandpass
- Calculus: finite-difference derivative, cumulative integral - Calculus: finite-difference derivative, cumulative integral
- FFT / inverse FFT (gonum/dft)
- Threshold / clamp - Threshold / clamp
- Custom expression (simple formula parser) - Custom expression (formula parser)
- [ ] Lua node: sandboxed `gopher-lua` state per signal; inputs as globals - [x] Lua node: sandboxed `gopher-lua` state per signal; inputs as globals
- [ ] Load synthetic definitions from `synthetic.json` at startup - [x] Load synthetic definitions from `synthetic.json` at startup
- [ ] REST endpoint to CRUD synthetic signal definitions (persists to JSON file) - [x] REST endpoints: `GET/POST /api/v1/synthetic`, `DELETE /api/v1/synthetic/{name}`
- [ ] Unit tests for each DSP node type - [x] Synthetic DS registered in `main.go`; `storePath = cfg.Server.StorageDir`
- [x] 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. **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`
--- ---
@@ -144,53 +149,68 @@
- Uses Svelte 5 runes (`$state`, `$props`, `$derived`) throughout, matching existing code style - 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) - 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) - 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 ## Phase 5 — View Mode Widgets
**Goal:** all widget types rendered and interactive in view mode. **Goal:** all widget types rendered and interactive in view mode.
- [ ] Text view widget - [x] Text view widget (`TextView.tsx`) — live value + quality dot + unit
- [ ] Gauge widget (SVG arc, configurable range/thresholds) - [x] Gauge widget (`Gauge.tsx`) — SVG arc, configurable range/thresholds, colour zones
- [ ] Vertical / horizontal bar widget - [x] Horizontal / vertical bar widget (`BarH.tsx`, `BarV.tsx`)
- [ ] LED widget (condition evaluator, configurable colours) - [x] LED widget (`Led.tsx`) — condition evaluator, configurable colours, blink on uncertain quality
- [ ] Multi-LED widget (bitset, per-bit labels) - [x] Multi-LED widget (`MultiLed.tsx`) — bitset, per-bit labels and colours
- [ ] Set-value widget (input + Set button; sends `write` over WS) - [x] Set-value widget (`SetValue.tsx`) — input + Set button; sends `write` over WS
- [ ] Button widget (sends fixed value on click) - [x] Button widget (`Button.tsx`) — sends fixed value on click, optional confirm dialog
- [ ] Plot widget: - [x] Plot widget (`PlotWidget.tsx`):
- Time-series (uPlot, streaming buffer of N points) - Time-series (uPlot, streaming ring buffer, dark axis styling)
- FFT, waterfall, histogram, bar chart, logic analyser (ECharts) - Histogram, bar chart, FFT, waterfall, logic analyser (ECharts)
- Multi-signal support; legend - Multi-signal support; legend; ResizeObserver-based resize
- [ ] Text label (static) - [x] Text label (`TextLabel.tsx`) — static text, configurable font size/colour/align
- [ ] Image widget (base64 or server URL) - [x] Image widget (`ImageWidget.tsx`) — renders image from URL, configurable object-fit
- [ ] Link widget (navigates to another interface) - [x] Link widget (`LinkWidget.tsx`) — navigates to another interface via `onNavigate` callback
- [ ] Right-click context menu: signal info dialog, copy name, export CSV - [x] Right-click context menu signal info, copy signal name, export CSV stub
- [ ] Svelte component tests for each widget type (mock store) - [ ] Component tests (deferred to Phase 10 hardening)
**Done when:** a complete HMI panel with multiple widget types runs smoothly at 60 fps. **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.tsx``GET /api/v1/interfaces/{id}`
- Plot widget uses static imports (not `await import()`) to avoid async race with signal subscriptions
--- ---
## Phase 6 — Edit Mode (Core) ## Phase 6 — Edit Mode (Core)
**Goal:** users can build and save an interface from scratch. **Goal:** users can build and save an interface from scratch.
- [ ] Konva stage setup in edit mode; `devicePixelRatio` scaling - [x] `internal/storage/store.go` — file-based XML storage (`{storage_dir}/interfaces/`)
- [ ] Widget renderer adapter: same widget components rendered on Konva layer - [x] Interface CRUD REST endpoints: `GET/POST /interfaces`, `GET/PUT/DELETE/POST(clone) /interfaces/{id}`
- [ ] Signal tree pane: fetch signal list, tree display, filter/search - [x] Signal tree pane (`SignalTree.tsx`): fetches all DS/signal lists, collapsible groups, filter/search, drag source
- [ ] Drag signal from tree → drop on canvas → widget type picker - [x] Drag signal from tree → drop on canvas → `WidgetTypePicker` popup (12 widget types)
- [ ] Place widget at drop coordinates with default size - [x] Place widget at drop coordinates with type-appropriate default size
- [ ] Single selection: bounding box + resize handles via Konva `Transformer` - [x] `EditCanvas.tsx`: widget live-preview + transparent overlay divs for interaction; pure DOM mouse events (no Konva)
- [ ] Move widget by dragging body - [x] Single selection: 2px blue bounding box + 8 resize handles (N/S/E/W/NE/NW/SE/SW)
- [ ] Delete widget (× button or Del key) - [x] Move widget by dragging body; resize by dragging handles
- [ ] Properties pane: common options (label, position, size) - [x] Delete widget (✕ button on selection or Del/Backspace key)
- [ ] Per-widget property editors (range, colour, plot type, etc.) - [x] `PropertiesPane.tsx`: canvas size/name + per-widget position/size/signal/options; type-specific fields
- [ ] Save interface to server (POST/PUT XML) - [x] Save interface to server (POST creates, PUT updates; XML body)
- [ ] Load interface from server (GET XML, populate canvas) - [x] Export / import local XML file
- [ ] Export / import local XML file - [x] Edit mode toolbar: name editor, dirty indicator, Save/Export/Import/Close buttons
- [x] InterfaceList shows server-persisted interfaces with click-to-view, edit/clone/delete actions
- [x] 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. **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
--- ---
@@ -273,10 +293,10 @@ These are rough single-developer estimates. Parallel work across backend and fro
| 0 | 12 days | ✅ Complete | | 0 | 12 days | ✅ Complete |
| 1 | 35 days | ✅ Complete | | 1 | 35 days | ✅ Complete |
| 2 | 12 weeks | ✅ Complete | | 2 | 12 weeks | ✅ Complete |
| 3 | 12 weeks | | | 3 | 12 weeks | ✅ Complete |
| 4 | 35 days | ✅ Complete | | 4 | 35 days | ✅ Complete |
| 5 | 23 weeks | | | 5 | 23 weeks | ✅ Complete |
| 6 | 23 weeks | | | 6 | 23 weeks | ✅ Complete |
| 7 | 12 weeks | — | | 7 | 12 weeks | — |
| 8 | 1 week | — | | 8 | 1 week | — |
| 9 | 1 week | — | | 9 | 1 week | — |
+3 -4
View File
@@ -5,9 +5,8 @@ go 1.26.2
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
github.com/evanw/esbuild v0.28.0
github.com/yuin/gopher-lua v1.1.2
) )
require ( require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
github.com/yuin/gopher-lua v1.1.2 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
)
+4 -2
View File
@@ -2,7 +2,9 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+137 -12
View File
@@ -3,22 +3,28 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/storage"
) )
// Handler holds the dependencies shared across all API endpoints. // Handler holds the dependencies shared across all API endpoints.
type Handler struct { type Handler struct {
broker *broker.Broker broker *broker.Broker
log *slog.Logger synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
log *slog.Logger
} }
// New creates an API Handler. // New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, log *slog.Logger) *Handler { func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Handler {
return &Handler{broker: b, log: log} return &Handler{broker: b, synthetic: synth, store: store, log: log}
} }
// Register mounts all API routes onto mux under the given prefix. // Register mounts all API routes onto mux under the given prefix.
@@ -32,6 +38,10 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface) mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface) mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface) mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
// Synthetic signal CRUD
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
} }
// ── /datasources ────────────────────────────────────────────────────────────── // ── /datasources ──────────────────────────────────────────────────────────────
@@ -91,30 +101,145 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
} }
// ── /interfaces ─────────────────────────────────────────────────────────────── // ── /interfaces ───────────────────────────────────────────────────────────────
// Stub implementations — full persistence lands in Phase 6.
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
jsonOK(w, []any{}) list, err := h.store.List()
if err != nil {
h.log.Error("list interfaces", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, list)
} }
func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented") body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
id, err := h.store.Create(body)
if err != nil {
h.log.Error("create interface", "err", err)
jsonError(w, http.StatusBadRequest, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
} }
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotFound, "interface not found") id := r.PathValue("id")
data, err := h.store.Get(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write(data)
} }
func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented") id := r.PathValue("id")
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
if err := h.store.Update(id, body); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("update interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented") id := r.PathValue("id")
if err := h.store.Delete(id); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotImplemented, "interface storage not yet implemented") id := r.PathValue("id")
newID, err := h.store.Clone(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("clone interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}
// ── /synthetic ────────────────────────────────────────────────────────────────
func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) {
if h.synthetic == nil {
jsonOK(w, []any{})
return
}
jsonOK(w, h.synthetic.GetDefs())
}
func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := h.synthetic.AddSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusConflict, err.Error())
} else {
jsonError(w, http.StatusBadRequest, err.Error())
}
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, def)
}
func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
if err := h.synthetic.RemoveSignal(name); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,34 @@
// Package synthetic implements a DataSource that computes signals from
// existing upstream signals by running them through configurable DSP pipelines.
package synthetic
// SignalDef describes one synthetic signal.
type SignalDef struct {
Name string `json:"name"`
DS string `json:"ds"` // upstream data source name
Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Meta MetaOverride `json:"meta"` // optional metadata overrides
}
// InputRef names one upstream signal used as input to the pipeline.
type InputRef struct {
DS string `json:"ds"`
Signal string `json:"signal"`
}
// NodeDef is a single node in the pipeline.
type NodeDef struct {
Type string `json:"type"`
Params map[string]any `json:"params,omitempty"`
}
// MetaOverride allows the synthetic signal to override display metadata.
type MetaOverride struct {
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow,omitempty"`
DisplayHigh float64 `json:"displayHigh,omitempty"`
Writable bool `json:"writable,omitempty"`
}
+108
View File
@@ -0,0 +1,108 @@
package synthetic
import (
"fmt"
"github.com/uopi/uopi/internal/dsp"
)
// floatParam extracts a float64 from params; returns 0 if missing or wrong type.
func floatParam(params map[string]any, key string) float64 {
if params == nil {
return 0
}
v, ok := params[key]
if !ok {
return 0
}
f, ok := v.(float64)
if !ok {
return 0
}
return f
}
// stringParam extracts a string from params; returns "" if missing or wrong type.
func stringParam(params map[string]any, key string) string {
if params == nil {
return ""
}
v, ok := params[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
nodes := make([]dsp.Node, 0, len(defs))
for i, d := range defs {
n, err := buildNode(d)
if err != nil {
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
}
nodes = append(nodes, n)
}
return nodes, nil
}
func buildNode(d NodeDef) (dsp.Node, error) {
p := d.Params
switch d.Type {
case "gain":
return &dsp.GainNode{Gain: floatParam(p, "gain")}, nil
case "offset":
return &dsp.OffsetNode{Offset: floatParam(p, "offset")}, nil
case "add":
return &dsp.AddNode{}, nil
case "subtract":
return &dsp.SubtractNode{}, nil
case "multiply":
return &dsp.MultiplyNode{}, nil
case "divide":
return &dsp.DivideNode{}, nil
case "moving_average":
return &dsp.MovingAverageNode{Window: max(1, int(floatParam(p, "window")))}, nil
case "rms":
return &dsp.RMSNode{Window: max(1, int(floatParam(p, "window")))}, nil
case "derivative":
return &dsp.DerivativeNode{}, nil
case "clamp":
return &dsp.ClampNode{
Min: floatParam(p, "min"),
Max: floatParam(p, "max"),
}, nil
case "threshold":
return &dsp.ThresholdNode{
Threshold: floatParam(p, "threshold"),
High: floatParam(p, "high"),
Low: floatParam(p, "low"),
}, nil
case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
default:
return nil, fmt.Errorf("unknown node type %q", d.Type)
}
}
+451
View File
@@ -0,0 +1,451 @@
package synthetic
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
)
const definitionsFile = "synthetic.json"
// signalState holds everything needed to run one synthetic signal.
type signalState struct {
def SignalDef
nodes []dsp.Node
states []map[string]any // one map per node, persistent across calls
// cancel stops the goroutine driving this signal.
cancel context.CancelFunc
}
// Synthetic is a DataSource that computes values from upstream signals via
// configurable DSP pipelines.
type Synthetic struct {
brk *broker.Broker
storePath string
log *slog.Logger
mu sync.RWMutex
signals map[string]*signalState
// root context provided by Connect; used to start per-signal goroutines.
ctx context.Context
}
// New creates a Synthetic data source. storePath is the directory where
// synthetic.json is stored. brk is used to subscribe to upstream signals.
func New(storePath string, brk *broker.Broker, log *slog.Logger) *Synthetic {
return &Synthetic{
brk: brk,
storePath: storePath,
log: log,
signals: make(map[string]*signalState),
}
}
// Name implements datasource.DataSource.
func (s *Synthetic) Name() string { return "synthetic" }
// Connect loads the definitions file and starts all signal goroutines.
func (s *Synthetic) Connect(ctx context.Context) error {
s.ctx = ctx
defs, err := s.loadDefs()
if err != nil {
return fmt.Errorf("synthetic: load definitions: %w", err)
}
for _, def := range defs {
if err := s.startSignal(def); err != nil {
s.log.Warn("synthetic: failed to start signal", "name", def.Name, "err", err)
}
}
return nil
}
// ListSignals returns metadata for all defined synthetic signals.
func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
out = append(out, defToMetadata(st.def))
}
return out, nil
}
// GetMetadata returns metadata for a single named signal.
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
st, ok := s.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return defToMetadata(st.def), nil
}
// Subscribe registers ch to receive computed values for the named signal.
// Values are pushed whenever an upstream signal changes.
func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.RLock()
st, ok := s.signals[signal]
s.mu.RUnlock()
if !ok {
return nil, datasource.ErrNotFound
}
// Collect the upstream references for this signal.
refs := upstreamRefs(st.def)
if len(refs) == 0 {
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
}
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
// Latest value per upstream input index.
latest := make([]float64, len(refs))
ready := make([]bool, len(refs))
// Subscribe to every upstream ref via the broker.
updateCh := make(chan indexedUpdate, 32*len(refs))
unsubs := make([]func(), 0, len(refs))
for i, ref := range refs {
idx := i // capture
perCh := make(chan broker.Update, 16)
unsub, err := s.brk.Subscribe(ref, perCh)
if err != nil {
s.log.Warn("synthetic: upstream subscribe failed",
"signal", signal, "upstream", ref, "err", err)
// Continue; the slot will stay at zero.
} else {
unsubs = append(unsubs, unsub)
go func() {
for {
select {
case u, ok := <-perCh:
if !ok {
return
}
val := toFloat64(u.Value.Data)
select {
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
default:
}
case <-ctx.Done():
return
}
}
}()
}
}
defer func() {
for _, unsub := range unsubs {
unsub()
}
}()
for {
select {
case <-ctx.Done():
return
case upd := <-updateCh:
latest[upd.idx] = upd.val
ready[upd.idx] = true
// Only compute once we have at least one value for every input.
allReady := true
for _, r := range ready {
if !r {
allReady = false
break
}
}
if !allReady {
continue
}
// Run the pipeline.
s.mu.RLock()
cur, stillExists := s.signals[signal]
s.mu.RUnlock()
if !stillExists {
return
}
result, err := runPipeline(cur.nodes, cur.states, latest)
if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue
}
v := datasource.Value{
Timestamp: upd.ts,
Data: result,
Quality: datasource.QualityGood,
}
select {
case ch <- v:
case <-ctx.Done():
return
}
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write is not supported for synthetic signals.
func (s *Synthetic) Write(_ context.Context, _ string, _ any) error {
return datasource.ErrNotWritable
}
// History is not supported for synthetic signals.
func (s *Synthetic) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// AddSignal adds a new synthetic signal definition at runtime and persists it.
func (s *Synthetic) AddSignal(def SignalDef) error {
if def.Name == "" {
return errors.New("signal name must not be empty")
}
nodes, err := BuildPipeline(def.Pipeline)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
}
s.mu.Lock()
if _, exists := s.signals[def.Name]; exists {
s.mu.Unlock()
return fmt.Errorf("signal %q already exists", def.Name)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
st := &signalState{
def: def,
nodes: nodes,
states: states,
}
s.signals[def.Name] = st
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
// Roll back the in-memory addition.
s.mu.Lock()
delete(s.signals, def.Name)
s.mu.Unlock()
return fmt.Errorf("persist definitions: %w", err)
}
return nil
}
// RemoveSignal removes a synthetic signal definition at runtime and persists.
func (s *Synthetic) RemoveSignal(name string) error {
s.mu.Lock()
st, ok := s.signals[name]
if !ok {
s.mu.Unlock()
return datasource.ErrNotFound
}
// Stop the goroutine if it is running.
if st.cancel != nil {
st.cancel()
}
delete(s.signals, name)
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
return fmt.Errorf("persist definitions: %w", err)
}
return nil
}
// GetDefs returns a copy of all current signal definitions (for the REST API).
func (s *Synthetic) GetDefs() []SignalDef {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]SignalDef, 0, len(s.signals))
for _, st := range s.signals {
out = append(out, st.def)
}
return out
}
// ── internal helpers ──────────────────────────────────────────────────────────
func (s *Synthetic) defsFilePath() string {
return filepath.Join(s.storePath, definitionsFile)
}
func (s *Synthetic) loadDefs() ([]SignalDef, error) {
path := s.defsFilePath()
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
// Create an empty definitions file.
if werr := os.WriteFile(path, []byte("[]\n"), 0o644); werr != nil {
s.log.Warn("synthetic: could not create empty definitions file", "path", path, "err", werr)
}
return nil, nil
}
if err != nil {
return nil, err
}
var defs []SignalDef
if err := json.Unmarshal(data, &defs); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return defs, nil
}
func (s *Synthetic) saveDefs() error {
s.mu.RLock()
defs := make([]SignalDef, 0, len(s.signals))
for _, st := range s.signals {
defs = append(defs, st.def)
}
s.mu.RUnlock()
data, err := json.MarshalIndent(defs, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.defsFilePath(), data, 0o644)
}
// startSignal builds the pipeline for def and registers the signalState.
// The actual goroutines are started lazily by Subscribe.
func (s *Synthetic) startSignal(def SignalDef) error {
nodes, err := BuildPipeline(def.Pipeline)
if err != nil {
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
s.mu.Lock()
s.signals[def.Name] = &signalState{
def: def,
nodes: nodes,
states: states,
}
s.mu.Unlock()
s.log.Info("synthetic: signal registered", "name", def.Name)
return nil
}
// runPipeline executes all nodes in sequence. The output of node N becomes
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
if len(nodes) == 0 {
if len(inputs) == 0 {
return 0, nil
}
return inputs[0], nil
}
// First node receives all upstream inputs.
cur := inputs
var result float64
var err error
for i, node := range nodes {
result, err = node.Process(cur, states[i])
if err != nil {
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
}
// Subsequent nodes receive only the single output of the previous node.
cur = []float64{result}
}
return result, nil
}
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
func upstreamRefs(def SignalDef) []broker.SignalRef {
if len(def.Inputs) > 0 {
refs := make([]broker.SignalRef, len(def.Inputs))
for i, inp := range def.Inputs {
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
}
return refs
}
if def.DS != "" && def.Signal != "" {
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
}
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata.
func defToMetadata(def SignalDef) datasource.Metadata {
return datasource.Metadata{
Name: def.Name,
Type: datasource.TypeFloat64,
Unit: def.Meta.Unit,
Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow,
DisplayHigh: def.Meta.DisplayHigh,
Writable: def.Meta.Writable,
}
}
// toFloat64 coerces any numeric value from a datasource.Value.Data to float64.
func toFloat64(v any) float64 {
switch val := v.(type) {
case float64:
return val
case float32:
return float64(val)
case int64:
return float64(val)
case int32:
return float64(val)
case int:
return float64(val)
case bool:
if val {
return 1
}
return 0
default:
return 0
}
}
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
type indexedUpdate struct {
idx int
val float64
ts time.Time
}
@@ -0,0 +1,259 @@
package synthetic
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
func newTestSynthetic(t *testing.T) (*Synthetic, *broker.Broker, context.CancelFunc) {
t.Helper()
dir := t.TempDir()
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
brk := broker.New(ctx, log)
syn := New(dir, brk, log)
return syn, brk, cancel
}
// TestNameAndConnect verifies basic construction and Connect.
func TestNameAndConnect(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if syn.Name() != "synthetic" {
t.Errorf("Name: want %q, got %q", "synthetic", syn.Name())
}
ctx := context.Background()
if err := syn.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
}
// TestConnectCreatesEmptyFile verifies that Connect creates an empty
// synthetic.json when none exists.
func TestConnectCreatesEmptyFile(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
path := filepath.Join(syn.storePath, "synthetic.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("expected file to be created: %v", err)
}
if string(data) == "" {
t.Error("file should not be empty")
}
}
// TestAddAndRemoveSignal exercises CRUD at runtime.
func TestAddAndRemoveSignal(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
def := SignalDef{
Name: "test_gain",
DS: "stub",
Signal: "sine_1hz",
Pipeline: []NodeDef{
{Type: "gain", Params: map[string]any{"gain": 2.0}},
},
Meta: MetaOverride{Unit: "V"},
}
if err := syn.AddSignal(def); err != nil {
t.Fatalf("AddSignal: %v", err)
}
// Duplicate should fail.
if err := syn.AddSignal(def); err == nil {
t.Error("expected error for duplicate signal")
}
// Should be listed.
metas, err := syn.ListSignals(context.Background())
if err != nil {
t.Fatal(err)
}
if len(metas) != 1 || metas[0].Name != "test_gain" {
t.Errorf("unexpected metas: %v", metas)
}
// GetMetadata should work.
meta, err := syn.GetMetadata(context.Background(), "test_gain")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if meta.Unit != "V" {
t.Errorf("unit: want V, got %q", meta.Unit)
}
// Remove it.
if err := syn.RemoveSignal("test_gain"); err != nil {
t.Fatalf("RemoveSignal: %v", err)
}
// Should be gone.
if _, err := syn.GetMetadata(context.Background(), "test_gain"); err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound after remove, got %v", err)
}
// Remove non-existing should return ErrNotFound.
if err := syn.RemoveSignal("nonexistent"); err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
// TestAddSignalEmptyName validates that empty name is rejected.
func TestAddSignalEmptyName(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{}); err == nil {
t.Error("expected error for empty signal name")
}
}
// TestGetMetadataNotFound verifies ErrNotFound for unknown signal.
func TestGetMetadataNotFound(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
_, err := syn.GetMetadata(context.Background(), "no_such_signal")
if err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
// TestWriteNotSupported ensures Write returns ErrNotWritable.
func TestWriteNotSupported(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
err := syn.Write(context.Background(), "any", 1.0)
if err != datasource.ErrNotWritable {
t.Errorf("expected ErrNotWritable, got %v", err)
}
}
// TestHistoryNotSupported ensures History returns ErrHistoryUnavailable.
func TestHistoryNotSupported(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
_, err := syn.History(context.Background(), "any", time.Now(), time.Now(), 10)
if err != datasource.ErrHistoryUnavailable {
t.Errorf("expected ErrHistoryUnavailable, got %v", err)
}
}
// TestPersistence verifies that definitions survive a restart (New+Connect).
func TestPersistence(t *testing.T) {
dir := t.TempDir()
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
brk := broker.New(ctx, log)
syn := New(dir, brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
def := SignalDef{
Name: "persisted",
DS: "stub",
Signal: "sine_1hz",
Pipeline: []NodeDef{
{Type: "offset", Params: map[string]any{"offset": 5.0}},
},
}
if err := syn.AddSignal(def); err != nil {
t.Fatal(err)
}
cancel()
// Reload.
ctx2 := t.Context()
brk2 := broker.New(ctx2, log)
syn2 := New(dir, brk2, log)
if err := syn2.Connect(ctx2); err != nil {
t.Fatal(err)
}
meta, err := syn2.GetMetadata(ctx2, "persisted")
if err != nil {
t.Fatalf("after reload, GetMetadata: %v", err)
}
if meta.Name != "persisted" {
t.Errorf("expected name %q, got %q", "persisted", meta.Name)
}
}
// TestSubscribeUnknownSignal verifies that subscribing to unknown signal returns error.
func TestSubscribeUnknownSignal(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 1)
_, err := syn.Subscribe(context.Background(), "nonexistent", ch)
if err != datasource.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
// TestGetDefs verifies GetDefs returns current definitions.
func TestGetDefs(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
defs := syn.GetDefs()
if len(defs) != 0 {
t.Errorf("expected empty defs, got %d", len(defs))
}
def := SignalDef{
Name: "test",
DS: "stub",
Signal: "noise",
}
if err := syn.AddSignal(def); err != nil {
t.Fatal(err)
}
defs = syn.GetDefs()
if len(defs) != 1 || defs[0].Name != "test" {
t.Errorf("unexpected defs: %v", defs)
}
}
+5 -2
View File
@@ -9,6 +9,8 @@ import (
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/storage"
) )
const apiPrefix = "/api/v1" const apiPrefix = "/api/v1"
@@ -19,7 +21,8 @@ type Server struct {
} }
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
func New(addr string, webFS fs.FS, brk *broker.Broker, log *slog.Logger) *Server { // synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Server {
mux := http.NewServeMux() mux := http.NewServeMux()
// Health check // Health check
@@ -32,7 +35,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, log *slog.Logger) *Server
mux.Handle("/ws", &wsHandler{broker: brk, log: log}) mux.Handle("/ws", &wsHandler{broker: brk, log: log})
// REST API // REST API
api.New(brk, log).Register(mux, apiPrefix) api.New(brk, synth, store, log).Register(mux, apiPrefix)
// Embedded frontend — must be last (catch-all) // Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS)) mux.Handle("/", http.FileServerFS(webFS))
+160
View File
@@ -0,0 +1,160 @@
// Package storage provides file-based persistence for HMI interface definitions.
package storage
import (
"encoding/xml"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ErrNotFound is returned when the requested interface does not exist.
var ErrNotFound = errors.New("interface not found")
// InterfaceMeta is the lightweight representation returned by List.
type InterfaceMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
}
// Store persists interface definitions as XML files under {storageDir}/interfaces/.
type Store struct {
dir string
}
// New opens (and creates, if needed) the storage directory.
func New(storageDir string) (*Store, error) {
dir := filepath.Join(storageDir, "interfaces")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create interfaces dir: %w", err)
}
return &Store{dir: dir}, nil
}
func (s *Store) filePath(id string) string {
return filepath.Join(s.dir, id+".xml")
}
// List returns metadata for every stored interface.
func (s *Store) List() ([]InterfaceMeta, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
var out []InterfaceMeta
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") {
continue
}
id := strings.TrimSuffix(e.Name(), ".xml")
meta, err := s.readMeta(id)
if err != nil {
continue // skip corrupt files silently
}
out = append(out, meta)
}
if out == nil {
out = []InterfaceMeta{}
}
return out, nil
}
// rootAttrs is used to parse only the top-level XML attributes.
type rootAttrs struct {
XMLName xml.Name `xml:"interface"`
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
data, err := os.ReadFile(s.filePath(id))
if err != nil {
return InterfaceMeta{}, err
}
var root rootAttrs
if err := xml.Unmarshal(data, &root); err != nil {
return InterfaceMeta{}, err
}
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
}
// Get returns the raw XML bytes for the interface with the given ID.
func (s *Store) Get(id string) ([]byte, error) {
data, err := os.ReadFile(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to
// ensure uniqueness.
func (s *Store) Create(xmlData []byte) (string, error) {
var root rootAttrs
if err := xml.Unmarshal(xmlData, &root); err != nil {
return "", fmt.Errorf("invalid XML: %w", err)
}
id := root.ID
if id == "" {
id = slugify(root.Name)
}
// Guarantee uniqueness.
if _, err := os.Stat(s.filePath(id)); err == nil {
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
}
return id, os.WriteFile(s.filePath(id), xmlData, 0o644)
}
// Update replaces the XML for an existing interface.
func (s *Store) Update(id string, xmlData []byte) error {
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return os.WriteFile(s.filePath(id), xmlData, 0o644)
}
// Delete removes the interface with the given ID.
func (s *Store) Delete(id string) error {
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
// Clone duplicates an interface, assigning a new unique ID.
func (s *Store) Clone(srcID string) (string, error) {
data, err := s.Get(srcID)
if err != nil {
return "", err
}
newID := srcID + "-copy-" + fmt.Sprintf("%d", time.Now().UnixMilli())
return newID, os.WriteFile(s.filePath(newID), data, 0o644)
}
// slugify converts a human-readable name into a URL-safe identifier.
func slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
b.WriteByte('-')
}
}
}
result := strings.Trim(b.String(), "-")
if result == "" {
return "interface"
}
return result
}
+29
View File
@@ -0,0 +1,29 @@
[
{
"name": "sine_1hz_gain2",
"ds": "stub",
"signal": "sine_1hz",
"pipeline": [
{ "type": "gain", "params": { "gain": 2.0 } }
],
"meta": { "unit": "V", "description": "Sine 1Hz amplified x2", "displayLow": -2, "displayHigh": 2 }
},
{
"name": "sine_avg10",
"ds": "stub",
"signal": "sine_1hz",
"pipeline": [
{ "type": "moving_average", "params": { "window": 10 } }
],
"meta": { "unit": "V", "description": "10-sample moving average of sine_1hz", "displayLow": -1, "displayHigh": 1 }
},
{
"name": "setpoint_clamped",
"ds": "stub",
"signal": "setpoint",
"pipeline": [
{ "type": "clamp", "params": { "min": 10, "max": 80 } }
],
"meta": { "unit": "°C", "description": "Setpoint clamped to [10, 80]", "displayLow": 0, "displayHigh": 100 }
}
]
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/evanw/esbuild/pkg/api"
)
func main() {
// Determine repo root.
// When invoked via `go generate` from web/, cwd is web/ so we look for go.mod
// to find the actual module root.
root, err := findModuleRoot()
if err != nil {
panic(err)
}
// Verify vendor files exist
vendorFiles := []string{
filepath.Join(root, "web", "vendor", "preact.mjs"),
filepath.Join(root, "web", "vendor", "preact-hooks.mjs"),
filepath.Join(root, "web", "vendor", "uplot.esm.js"),
filepath.Join(root, "web", "vendor", "uplot.css"),
filepath.Join(root, "web", "vendor", "echarts.esm.js"),
}
for _, f := range vendorFiles {
if _, err := os.Stat(f); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "error: vendor file missing: %s\n", f)
fmt.Fprintf(os.Stderr, "Run the following to download vendor files:\n")
fmt.Fprintf(os.Stderr, " mkdir -p web/vendor\n")
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/preact@10.24.3/dist/preact.module.js -o web/vendor/preact.mjs\n")
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/preact@10.24.3/hooks/dist/hooks.module.js -o web/vendor/preact-hooks.mjs\n")
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.esm.js -o web/vendor/uplot.esm.js\n")
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.min.css -o web/vendor/uplot.css\n")
fmt.Fprintf(os.Stderr, " curl -sL https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.esm.min.js -o web/vendor/echarts.esm.js\n")
os.Exit(1)
}
}
// Clean + create dist
distDir := filepath.Join(root, "web", "dist")
os.RemoveAll(distDir)
os.MkdirAll(distDir, 0o755)
result := api.Build(api.BuildOptions{
EntryPoints: []string{filepath.Join(root, "web", "src", "main.tsx")},
Bundle: true,
Outdir: distDir,
Format: api.FormatESModule,
Target: api.ES2020,
JSX: api.JSXTransform,
JSXFactory: "h",
JSXFragment: "Fragment",
MinifyWhitespace: true,
MinifyIdentifiers: true,
MinifySyntax: true,
Alias: map[string]string{
"preact": filepath.Join(root, "web", "vendor", "preact.mjs"),
"preact/hooks": filepath.Join(root, "web", "vendor", "preact-hooks.mjs"),
"uplot": filepath.Join(root, "web", "vendor", "uplot.esm.js"),
"echarts": filepath.Join(root, "web", "vendor", "echarts.esm.js"),
},
Loader: map[string]api.Loader{
".css": api.LoaderCSS,
},
Splitting: false,
Write: true,
LogLevel: api.LogLevelInfo,
})
if len(result.Errors) > 0 {
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, "error: %s\n", e.Text)
}
os.Exit(1)
}
// Copy uPlot CSS and favicon
copyFile(filepath.Join(root, "web", "vendor", "uplot.css"), filepath.Join(distDir, "uplot.css"))
copyFile(filepath.Join(root, "web", "public", "favicon.svg"), filepath.Join(distDir, "favicon.svg"))
// Write index.html
html := `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uopi</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/uplot.css" />
<link rel="stylesheet" href="/main.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>`
os.WriteFile(filepath.Join(distDir, "index.html"), []byte(html), 0o644)
fmt.Println("Frontend built successfully →", distDir)
}
func copyFile(src, dst string) {
data, err := os.ReadFile(src)
if err != nil {
return // Skip if missing
}
os.WriteFile(dst, data, 0o644)
}
// findModuleRoot walks up from cwd until it finds a go.mod file.
func findModuleRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("go.mod not found in any parent directory")
}
dir = parent
}
}
Executable
BIN
View File
Binary file not shown.
-47
View File
@@ -1,47 +0,0 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
+2 -2
View File
@@ -1,5 +1,5 @@
// Package web provides the embedded frontend assets built by Vite. // Package web provides the embedded frontend assets.
// Run `npm run build` inside the web/ directory before building the binary. // Run `make frontend` (or `make all`) to rebuild before compiling the binary.
package web package web
import "embed" import "embed"
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-2505
View File
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,29 +0,0 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^24.12.2",
"jsdom": "^29.0.2",
"svelte": "^5.55.4",
"svelte-check": "^4.4.6",
"typescript": "~6.0.2",
"vite": "^8.0.10",
"vitest": "^4.1.5"
},
"dependencies": {
"echarts": "^6.0.0",
"uplot": "^1.6.32"
}
}
-66
View File
@@ -1,66 +0,0 @@
<script lang="ts">
import { wsClient } from './lib/ws';
import ViewMode from './lib/ViewMode.svelte';
import EditMode from './lib/EditMode.svelte';
type AppMode = 'view' | 'edit';
let mode = $state<AppMode>('view');
const wsStatus = wsClient.status;
// Set CSS variable for device pixel ratio on the root element
const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr));
// Connect to WebSocket — called once at app startup
wsClient.connect('/ws');
</script>
{#if $wsStatus === 'disconnected'}
<div class="connection-banner">
WebSocket disconnected — reconnecting…
</div>
{/if}
{#if mode === 'view'}
<ViewMode />
{:else}
<EditMode />
{/if}
<style>
:global(*, *::before, *::after) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(body) {
background: #0f1117;
color: #e2e8f0;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
}
:global(#app) {
width: 100%;
max-width: none;
margin: 0;
text-align: left;
border: none;
min-height: 100vh;
}
.connection-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: #7f1d1d;
color: #fca5a5;
text-align: center;
font-size: 0.8rem;
padding: 0.3rem 1rem;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import ViewMode from './ViewMode';
import EditMode from './EditMode';
import type { Interface } from './lib/types';
type AppMode = 'view' | 'edit';
export default function App() {
const [mode, setMode] = useState<AppMode>('view');
const [wsStatus, setWsStatus] = useState('connecting');
const [editTarget, setEditTarget] = useState<Interface | null>(null);
useEffect(() => {
const unsub = wsClient.status.subscribe(setWsStatus);
return unsub;
}, []);
useEffect(() => {
const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr));
wsClient.connect('/ws');
}, []);
function enterEdit(iface?: Interface) {
setEditTarget(iface ?? null);
setMode('edit');
}
function exitEdit() {
setMode('view');
setEditTarget(null);
}
return (
<div>
{wsStatus === 'disconnected' && (
<div class="connection-banner">WebSocket disconnected reconnecting</div>
)}
{mode === 'view'
? <ViewMode onEdit={enterEdit} />
: <EditMode initial={editTarget} onDone={exitEdit} />
}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView';
import TextLabel from './widgets/TextLabel';
import Gauge from './widgets/Gauge';
import BarH from './widgets/BarH';
import BarV from './widgets/BarV';
import Led from './widgets/Led';
import MultiLed from './widgets/MultiLed';
import SetValue from './widgets/SetValue';
import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import ContextMenu from './ContextMenu';
const COMPONENTS: Record<string, any> = {
textview: TextView,
textlabel: TextLabel,
gauge: Gauge,
barh: BarH,
barv: BarV,
led: Led,
multiled: MultiLed,
setvalue: SetValue,
button: Button,
plot: PlotWidget,
image: ImageWidget,
link: LinkWidget,
};
interface CtxState {
visible: boolean;
x: number;
y: number;
signal: SignalRef | null;
}
interface Props {
iface: Interface | null;
onNavigate?: (interfaceId: string) => void;
}
export default function Canvas({ iface, onNavigate }: Props) {
const [ctxMenu, setCtxMenu] = useState<CtxState>({
visible: false, x: 0, y: 0, signal: null,
});
function onCtxMenu(e: MouseEvent, widget: Widget) {
e.preventDefault();
e.stopPropagation();
setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null });
}
if (!iface) {
return (
<div class="canvas-container">
<div class="placeholder">
<p>Select an interface from the left panel, or import one.</p>
</div>
</div>
);
}
return (
<div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type];
return Comp
? <Comp
key={widget.id}
widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate}
/>
: (
<div
key={widget.id}
class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
title={`Unknown widget type: ${widget.type}`}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
>
<span class="unknown-label">{widget.type}</span>
</div>
);
})}
</div>
<ContextMenu
{...ctxMenu}
onClose={() => setCtxMenu(c => ({ ...c, visible: false }))}
/>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import type { SignalRef, SignalMeta } from './lib/types';
import { getMetaStore } from './lib/stores';
import { useState } from 'preact/hooks';
interface Props {
visible: boolean;
x: number;
y: number;
signal: SignalRef | null;
onClose: () => void;
}
export default function ContextMenu({ visible, x, y, signal, onClose }: Props) {
const [meta, setMeta] = useState<SignalMeta | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!signal) { setMeta(null); return; }
const unsub = getMetaStore(signal).subscribe(setMeta);
return unsub;
}, [signal?.ds, signal?.name]);
useEffect(() => {
if (!visible) return;
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [visible, onClose]);
if (!visible) return null;
function copySignalName() {
if (signal) {
navigator.clipboard.writeText(signal.name).catch(() => {});
}
onClose();
}
function exportCSV() {
// Phase 5+: export CSV data
onClose();
}
return (
<div
class="ctx-menu"
ref={menuRef}
style={`left:${x}px;top:${y}px;`}
>
{signal ? (
<div>
<div class="ctx-signal-info">
<div>{signal.ds} / {signal.name}</div>
{meta && (
<div>{meta.type}{meta.unit ? ` [${meta.unit}]` : ''}</div>
)}
</div>
<div class="ctx-divider" />
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
</div>
) : (
<button class="ctx-item" onClick={onClose}>Close</button>
)}
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
import { h } from 'preact';
import { useRef, useEffect, useState } from 'preact/hooks';
import type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView';
import TextLabel from './widgets/TextLabel';
import Gauge from './widgets/Gauge';
import BarH from './widgets/BarH';
import BarV from './widgets/BarV';
import Led from './widgets/Led';
import MultiLed from './widgets/MultiLed';
import SetValue from './widgets/SetValue';
import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import WidgetTypePicker from './WidgetTypePicker';
const COMPONENTS: Record<string, any> = {
textview: TextView, textlabel: TextLabel, gauge: Gauge,
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
setvalue: SetValue, button: Button, plot: PlotWidget,
image: ImageWidget, link: LinkWidget,
};
const DEFAULT_SIZES: Record<string, [number, number]> = {
textview: [200, 50],
textlabel: [150, 36],
gauge: [120, 120],
barh: [200, 60],
barv: [60, 160],
led: [60, 60],
multiled: [200, 80],
setvalue: [200, 60],
button: [120, 50],
plot: [400, 200],
image: [200, 150],
link: [140, 50],
};
interface PickerState {
visible: boolean;
x: number;
y: number;
canvasX: number;
canvasY: number;
signal: SignalRef | null;
}
interface DragState {
widgetId: string;
startMouseX: number;
startMouseY: number;
startWidgetX: number;
startWidgetY: number;
}
interface ResizeState {
widgetId: string;
handle: string; // 'se' | 'sw' | 'ne' | 'nw' | 'n' | 's' | 'e' | 'w'
startMouseX: number;
startMouseY: number;
startX: number;
startY: number;
startW: number;
startH: number;
}
interface Props {
iface: Interface;
selectedId: string | null;
onSelect: (id: string | null) => void;
onChange: (widgets: Widget[]) => void;
}
let nextId = Date.now();
function genId() { return `w${nextId++}`; }
export default function EditCanvas({ iface, selectedId, onSelect, onChange }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const resizeRef = useRef<ResizeState | null>(null);
const [picker, setPicker] = useState<PickerState>({
visible: false, x: 0, y: 0, canvasX: 0, canvasY: 0, signal: null,
});
// Document-level move/up handlers for drag and resize
useEffect(() => {
function onMouseMove(e: MouseEvent) {
if (dragRef.current) {
const d = dragRef.current;
const dx = e.clientX - d.startMouseX;
const dy = e.clientY - d.startMouseY;
const widgets = iface.widgets.map(w =>
w.id === d.widgetId
? { ...w, x: Math.round(d.startWidgetX + dx), y: Math.round(d.startWidgetY + dy) }
: w
);
onChange(widgets);
}
if (resizeRef.current) {
const r = resizeRef.current;
const dx = e.clientX - r.startMouseX;
const dy = e.clientY - r.startMouseY;
let { startX: x, startY: y, startW: w, startH: h } = r;
const handle = r.handle;
if (handle.includes('e')) w = Math.max(20, w + dx);
if (handle.includes('s')) h = Math.max(20, h + dy);
if (handle.includes('w')) { x = x + dx; w = Math.max(20, w - dx); }
if (handle.includes('n')) { y = y + dy; h = Math.max(20, h - dy); }
const widgets = iface.widgets.map(widget =>
widget.id === r.widgetId
? { ...widget, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }
: widget
);
onChange(widgets);
}
}
function onMouseUp() {
dragRef.current = null;
resizeRef.current = null;
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}, [iface.widgets, onChange]);
function handleCanvasClick(e: MouseEvent) {
if ((e.target as Element).closest('.widget-overlay')) return;
onSelect(null);
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
function handleDrop(e: DragEvent) {
e.preventDefault();
const json = e.dataTransfer?.getData('application/json');
if (!json) return;
let sig: SignalRef;
try { sig = JSON.parse(json); } catch { return; }
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const scrollLeft = containerRef.current?.scrollLeft ?? 0;
const scrollTop = containerRef.current?.scrollTop ?? 0;
const cx = e.clientX - rect.left + scrollLeft;
const cy = e.clientY - rect.top + scrollTop;
setPicker({ visible: true, x: e.clientX, y: e.clientY, canvasX: cx, canvasY: cy, signal: sig });
}
function handlePick(type: string) {
if (!picker.signal) return;
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
const newWidget: Widget = {
id: genId(),
type,
x: Math.round(picker.canvasX - defW / 2),
y: Math.round(picker.canvasY - defH / 2),
w: defW,
h: defH,
signals: [picker.signal],
options: {},
};
onChange([...iface.widgets, newWidget]);
onSelect(newWidget.id);
setPicker(p => ({ ...p, visible: false }));
}
function handleDeleteWidget(id: string) {
onChange(iface.widgets.filter(w => w.id !== id));
if (selectedId === id) onSelect(null);
}
function startDrag(e: MouseEvent, widget: Widget) {
e.stopPropagation();
onSelect(widget.id);
dragRef.current = {
widgetId: widget.id,
startMouseX: e.clientX,
startMouseY: e.clientY,
startWidgetX: widget.x,
startWidgetY: widget.y,
};
}
function startResize(e: MouseEvent, widget: Widget, handle: string) {
e.stopPropagation();
e.preventDefault();
resizeRef.current = {
widgetId: widget.id,
handle,
startMouseX: e.clientX,
startMouseY: e.clientY,
startX: widget.x,
startY: widget.y,
startW: widget.w,
startH: widget.h,
};
}
return (
<div
class="edit-canvas-container"
ref={containerRef}
onClick={handleCanvasClick}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div
class="canvas-area"
style={`width:${iface.w}px;height:${iface.h}px;`}
>
{/* Render live widget previews */}
{iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type];
return Comp
? <Comp key={widget.id} widget={widget} />
: (
<div
key={widget.id}
class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
>
<span class="unknown-label">{widget.type}</span>
</div>
);
})}
{/* Overlay divs for interaction — sit above the live widgets */}
{iface.widgets.map(widget => {
const isSelected = widget.id === selectedId;
return (
<div
key={`overlay-${widget.id}`}
class={`widget-overlay${isSelected ? ' selected' : ''}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onMouseDown={(e: MouseEvent) => startDrag(e, widget)}
onClick={(e: MouseEvent) => { e.stopPropagation(); onSelect(widget.id); }}
>
{isSelected && (
<div>
{/* Delete button */}
<button
class="overlay-delete"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteWidget(widget.id); }}
title="Delete widget"
></button>
{/* Resize handles */}
{(['n','s','e','w','ne','nw','se','sw'] as const).map(dir => (
<div
key={dir}
class={`resize-handle resize-${dir}`}
onMouseDown={(e: MouseEvent) => startResize(e, widget, dir)}
/>
))}
</div>
)}
</div>
);
})}
</div>
{picker.visible && (
<WidgetTypePicker
x={picker.x}
y={picker.y}
onPick={handlePick}
onCancel={() => setPicker(p => ({ ...p, visible: false }))}
/>
)}
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { h } from 'preact';
import { useState, useCallback } from 'preact/hooks';
import type { Interface, Widget } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml';
import SignalTree from './SignalTree';
import EditCanvas from './EditCanvas';
import PropertiesPane from './PropertiesPane';
interface Props {
initial: Interface | null;
onDone: () => void;
}
let _newId = Date.now();
function newIfaceId() { return `iface-${_newId++}`; }
function blankInterface(): Interface {
return {
id: newIfaceId(),
name: 'New Interface',
version: 1,
w: 1200,
h: 800,
widgets: [],
};
}
export default function EditMode({ initial, onDone }: Props) {
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
const [selectedId, setSelectedId] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedWidget = iface.widgets.find(w => w.id === selectedId) ?? null;
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
setIface(f => ({ ...f, widgets }));
setDirty(true);
}, []);
const handleWidgetChange = useCallback((updated: Widget) => {
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
setDirty(true);
}, []);
const handleIfaceChange = useCallback((updated: Interface) => {
setIface(updated);
setDirty(true);
}, []);
async function handleSave() {
setSaving(true);
setError(null);
try {
const xml = serializeInterface(iface);
const url = iface.id
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
: '/api/v1/interfaces';
const method = iface.id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/xml' },
body: xml,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Save failed (${res.status}): ${text}`);
}
if (method === 'POST') {
const json = await res.json();
setIface(f => ({ ...f, id: json.id }));
}
setDirty(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function handleExport() {
const xml = serializeInterface(iface);
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
a.click();
URL.revokeObjectURL(url);
}
function handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.xml,application/xml,text/xml';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
const xml = await file.text();
const parsed = parseInterface(xml);
setIface(parsed);
setSelectedId(null);
setDirty(true);
} catch (err) {
setError(`Import failed: ${err instanceof Error ? err.message : err}`);
}
};
input.click();
}
function handleLeave() {
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
onDone();
}
function handleDeleteSelected() {
if (!selectedId) return;
setIface(f => ({ ...f, widgets: f.widgets.filter(w => w.id !== selectedId) }));
setSelectedId(null);
setDirty(true);
}
function handleKeyDown(e: KeyboardEvent) {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId &&
!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement)) {
handleDeleteSelected();
}
}
return (
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
{/* Toolbar */}
<header class="toolbar">
<div class="toolbar-left">
<span class="app-name">uopi</span>
<span class="edit-badge">Edit</span>
<input
class="iface-name-input"
value={iface.name}
onInput={(e) => handleIfaceChange({ ...iface, name: (e.target as HTMLInputElement).value })}
/>
{dirty && <span class="dirty-dot" title="Unsaved changes"></span>}
</div>
<div class="toolbar-center">
{error && <span class="parse-error" title={error}>Save error check console</span>}
</div>
<div class="toolbar-right">
<button class="toolbar-btn" onClick={handleImport} title="Import XML file">Import</button>
<button class="toolbar-btn" onClick={handleExport} title="Export XML file">Export</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
<button class="toolbar-btn" onClick={handleLeave} title="Back to view mode"> Close</button>
</div>
</header>
{/* Three-panel body */}
<div class="edit-body">
<SignalTree />
<EditCanvas
iface={iface}
selectedId={selectedId}
onSelect={setSelectedId}
onChange={handleWidgetsChange}
/>
<PropertiesPane
selected={selectedWidget}
iface={iface}
onChange={handleWidgetChange}
onIfaceChange={handleIfaceChange}
/>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import type { Interface } from './lib/types';
interface ServerInterface {
id: string;
name: string;
version: number;
}
interface Props {
onLoad: (xml: string) => void;
onSelect?: (id: string) => void;
onEdit?: (iface?: Interface) => void;
}
export default function InterfaceList({ onLoad, onSelect, onEdit }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
const [loading, setLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
function refresh() {
setLoading(true);
fetch('/api/v1/interfaces')
.then(r => r.ok ? r.json() : [])
.then(data => setInterfaces(data))
.catch(() => {})
.finally(() => setLoading(false));
}
useEffect(() => { refresh(); }, []);
function triggerImport() {
fileInputRef.current?.click();
}
async function handleFileChange(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const xml = await file.text();
onLoad(xml);
} catch (err) {
console.error('Failed to read interface file:', err);
} finally {
input.value = '';
}
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete interface "${name}"?`)) return;
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) refresh();
}
async function handleClone(id: string) {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}/clone`, { method: 'POST' });
if (res.ok) refresh();
}
return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`}>
<div class="panel-header">
{!collapsed && <span class="panel-title">Interfaces</span>}
<button
class="icon-btn"
onClick={() => setCollapsed(c => !c)}
title={collapsed ? 'Expand panel' : 'Collapse panel'}
>
{collapsed ? '▶' : '◀'}
</button>
</div>
{!collapsed && (
<div>
<div class="panel-actions">
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
<button class="panel-btn" onClick={triggerImport}>Import</button>
<input
type="file"
accept=".xml,application/xml,text/xml"
style="display:none"
ref={fileInputRef}
onChange={handleFileChange}
/>
</div>
<div class="panel-list">
{loading ? (
<p class="hint">Loading</p>
) : interfaces.length === 0 ? (
<p class="hint">No interfaces yet. Create one or import XML.</p>
) : (
<ul class="iface-list">
{interfaces.map(iface => (
<li key={iface.id} class="iface-item">
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
{iface.name || iface.id}
</span>
<div class="iface-item-actions">
<button class="icon-btn" title="Edit" onClick={() => onEdit?.()}></button>
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}></button>
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}></button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
</aside>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import type { Widget, Interface } from './lib/types';
interface Props {
selected: Widget | null;
iface: Interface;
onChange: (updated: Widget) => void;
onIfaceChange: (updated: Interface) => void;
}
function Field({ label, children }: { label: string; children: any }) {
return (
<div class="prop-field">
<label class="prop-label">{label}</label>
{children}
</div>
);
}
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [local, setLocal] = useState(value);
return (
<input
class="prop-input"
value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
/>
);
}
export default function PropertiesPane({ selected, iface, onChange, onIfaceChange }: Props) {
const [collapsed, setCollapsed] = useState(false);
function setOpt(key: string, value: string) {
if (!selected) return;
onChange({ ...selected, options: { ...selected.options, [key]: value } });
}
function setProp(key: keyof Widget, value: any) {
if (!selected) return;
onChange({ ...selected, [key]: value } as Widget);
}
return (
<aside class={`panel props-panel${collapsed ? ' collapsed' : ''}`} style="border-left:1px solid #2d3748;border-right:none;">
<div class="panel-header">
{!collapsed && <span class="panel-title">Properties</span>}
<button class="icon-btn" onClick={() => setCollapsed(c => !c)} title={collapsed ? 'Expand' : 'Collapse'}>
{collapsed ? '◀' : '▶'}
</button>
</div>
{!collapsed && (
<div class="props-body">
{/* Interface-level properties (always shown) */}
<div class="props-section">
<div class="props-section-title">Canvas</div>
<Field label="Name">
<TextInput
value={iface.name}
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
/>
</Field>
<Field label="Width">
<input
class="prop-input prop-input-num"
type="number"
value={iface.w}
min={100}
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
/>
</Field>
<Field label="Height">
<input
class="prop-input prop-input-num"
type="number"
value={iface.h}
min={100}
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
/>
</Field>
</div>
{selected && (
<div class="props-section">
<div class="props-section-title">{selected.type}</div>
<Field label="X">
<input class="prop-input prop-input-num" type="number" value={selected.x}
onChange={(e) => setProp('x', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field>
<Field label="Y">
<input class="prop-input prop-input-num" type="number" value={selected.y}
onChange={(e) => setProp('y', parseFloat((e.target as HTMLInputElement).value) || 0)} />
</Field>
<Field label="Width">
<input class="prop-input prop-input-num" type="number" value={selected.w} min={10}
onChange={(e) => setProp('w', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field>
<Field label="Height">
<input class="prop-input prop-input-num" type="number" value={selected.h} min={10}
onChange={(e) => setProp('h', parseFloat((e.target as HTMLInputElement).value) || 10)} />
</Field>
{/* Signal */}
{selected.signals.length > 0 && (
<Field label="Signal">
<span class="prop-sig">{selected.signals[0].ds}:{selected.signals[0].name}</span>
</Field>
)}
{/* Common options */}
<Field label="Label">
<TextInput
value={selected.options['label'] ?? ''}
onCommit={(v) => setOpt('label', v)}
/>
</Field>
{/* Type-specific options */}
{(selected.type === 'gauge' || selected.type === 'barh' || selected.type === 'barv') && (
<div>
<Field label="Min">
<TextInput value={selected.options['min'] ?? '0'} onCommit={(v) => setOpt('min', v)} />
</Field>
<Field label="Max">
<TextInput value={selected.options['max'] ?? '100'} onCommit={(v) => setOpt('max', v)} />
</Field>
</div>
)}
{selected.type === 'led' && (
<Field label="Condition">
<TextInput value={selected.options['condition'] ?? '>0'} onCommit={(v) => setOpt('condition', v)} />
</Field>
)}
{selected.type === 'setvalue' && (
<Field label="Min">
<TextInput value={selected.options['min'] ?? ''} onCommit={(v) => setOpt('min', v)} />
</Field>
)}
{selected.type === 'plot' && (
<Field label="Plot type">
<select
class="prop-select"
value={selected.options['plotType'] ?? 'timeseries'}
onChange={(e) => setOpt('plotType', (e.target as HTMLSelectElement).value)}
>
<option value="timeseries">Time-series</option>
<option value="histogram">Histogram</option>
<option value="bar">Bar chart</option>
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
</select>
</Field>
)}
{selected.type === 'textlabel' && (
<div>
<Field label="Font size">
<TextInput value={selected.options['fontSize'] ?? '14'} onCommit={(v) => setOpt('fontSize', v)} />
</Field>
<Field label="Color">
<TextInput value={selected.options['color'] ?? '#e2e8f0'} onCommit={(v) => setOpt('color', v)} />
</Field>
</div>
)}
{selected.type === 'image' && (
<Field label="URL">
<TextInput value={selected.options['url'] ?? ''} onCommit={(v) => setOpt('url', v)} />
</Field>
)}
{selected.type === 'link' && (
<div>
<Field label="Target">
<TextInput value={selected.options['target'] ?? ''} onCommit={(v) => setOpt('target', v)} />
</Field>
<Field label="Label">
<TextInput value={selected.options['label'] ?? ''} onCommit={(v) => setOpt('label', v)} />
</Field>
</div>
)}
</div>
)}
{!selected && (
<p class="hint" style="padding:0.75rem;">Select a widget to edit its properties.</p>
)}
</div>
)}
</aside>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { SignalRef } from './lib/types';
interface SignalInfo {
name: string;
type: string;
unit?: string;
description?: string;
writable: boolean;
}
interface DsGroup {
ds: string;
signals: SignalInfo[];
expanded: boolean;
}
interface Props {
onDragStart?: (sig: SignalRef) => void;
}
export default function SignalTree({ onDragStart }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [groups, setGroups] = useState<DsGroup[]>([]);
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
try {
const res = await fetch('/api/v1/datasources');
if (!res.ok) return;
const dsList: { name: string }[] = await res.json();
const loaded: DsGroup[] = await Promise.all(
dsList.map(async ({ name }) => {
try {
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
const sigs: SignalInfo[] = r.ok ? await r.json() : [];
return { ds: name, signals: sigs, expanded: true };
} catch {
return { ds: name, signals: [], expanded: true };
}
})
);
setGroups(loaded);
} catch (e) {
console.error('Failed to load signals:', e);
} finally {
setLoading(false);
}
}
load();
}, []);
function toggleGroup(ds: string) {
setGroups(gs => gs.map(g => g.ds === ds ? { ...g, expanded: !g.expanded } : g));
}
const lf = filter.toLowerCase();
const filtered = groups.map(g => ({
...g,
signals: lf ? g.signals.filter(s => s.name.toLowerCase().includes(lf) || (s.description ?? '').toLowerCase().includes(lf)) : g.signals,
})).filter(g => g.signals.length > 0 || !lf);
return (
<aside class={`panel signal-tree-panel${collapsed ? ' collapsed' : ''}`}>
<div class="panel-header">
{!collapsed && <span class="panel-title">Signals</span>}
<button
class="icon-btn"
onClick={() => setCollapsed(c => !c)}
title={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? '▶' : '◀'}
</button>
</div>
{!collapsed && (
<div class="signal-tree-body">
<div class="signal-tree-search">
<input
class="signal-filter"
type="search"
placeholder="Filter signals…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
/>
</div>
<div class="panel-list signal-list">
{loading ? (
<p class="hint">Loading signals</p>
) : filtered.length === 0 ? (
<p class="hint">No signals found.</p>
) : (
filtered.map(group => (
<div key={group.ds} class="signal-group">
<div class="signal-group-header" onClick={() => toggleGroup(group.ds)}>
<span class="signal-group-arrow">{group.expanded ? '▾' : '▸'}</span>
<span class="signal-group-name">{group.ds}</span>
<span class="signal-group-count">{group.signals.length}</span>
</div>
{group.expanded && group.signals.map(sig => (
<div
key={sig.name}
class="signal-item"
draggable
title={`${group.ds}:${sig.name}${sig.unit ? ` [${sig.unit}]` : ''}${sig.description ? `${sig.description}` : ''}`}
onDragStart={(e: DragEvent) => {
const ref: SignalRef = { ds: group.ds, name: sig.name };
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
onDragStart?.(ref);
}}
>
<span class="signal-name">{sig.name}</span>
{sig.unit && <span class="signal-unit">{sig.unit}</span>}
</div>
))}
</div>
))
)}
</div>
</div>
)}
</aside>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import InterfaceList from './InterfaceList';
import Canvas from './Canvas';
import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml';
import type { Interface } from './lib/types';
import { useEffect } from 'preact/hooks';
interface Props {
onEdit?: (iface?: Interface) => void;
}
export default function ViewMode({ onEdit }: Props) {
const [currentInterface, setCurrentInterface] = useState<Interface | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [wsStatus, setWsStatus] = useState('connecting');
useEffect(() => {
const unsub = wsClient.status.subscribe(setWsStatus);
return unsub;
}, []);
function handleLoad(xml: string) {
try {
setParseError(null);
setCurrentInterface(parseInterface(xml));
} catch (err) {
setParseError(err instanceof Error ? err.message : 'Failed to parse interface file.');
}
}
async function handleNavigate(interfaceId: string) {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const xml = await res.text();
handleLoad(xml);
} catch (err) {
setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`);
}
}
return (
<div class="view-mode">
<header class="toolbar">
<div class="toolbar-left">
<span class="app-name">uopi</span>
{currentInterface && (
<span class="iface-name">{currentInterface.name}</span>
)}
</div>
<div class="toolbar-center">
{parseError && (
<span class="parse-error" title={parseError}>Parse error check console</span>
)}
</div>
<div class="toolbar-right">
<div class={`status-chip ${wsStatus}`}>
<span class="status-dot"></span>
{wsStatus}
</div>
<button
class="btn-edit"
onClick={() => onEdit?.(currentInterface ?? undefined)}
title="Switch to Edit mode"
>
Edit
</button>
</div>
</header>
<div class="content">
<InterfaceList
onLoad={handleLoad}
onEdit={onEdit}
onSelect={async (id) => {
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
handleLoad(await res.text());
} catch (err) {
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`);
}
}}
/>
<Canvas iface={currentInterface} onNavigate={handleNavigate} />
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { h } from 'preact';
interface Props {
x: number;
y: number;
onPick: (type: string) => void;
onCancel: () => void;
}
const WIDGET_TYPES = [
{ type: 'textview', label: 'Text View', desc: 'Live value display' },
{ type: 'gauge', label: 'Gauge', desc: 'Circular arc gauge' },
{ type: 'barh', label: 'Bar (H)', desc: 'Horizontal bar' },
{ type: 'barv', label: 'Bar (V)', desc: 'Vertical bar' },
{ type: 'led', label: 'LED', desc: 'State indicator' },
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
{ type: 'button', label: 'Button', desc: 'Send command on click' },
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
{ type: 'image', label: 'Image', desc: 'Image from URL' },
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
];
export default function WidgetTypePicker({ x, y, onPick, onCancel }: Props) {
return (
<div
class="widget-picker-backdrop"
onClick={onCancel}
>
<div
class="widget-picker"
style={`left:${x}px;top:${y}px;`}
onClick={(e) => e.stopPropagation()}
>
<div class="widget-picker-header">
<span>Choose widget type</span>
<button class="icon-btn" onClick={onCancel}></button>
</div>
<div class="widget-picker-list">
{WIDGET_TYPES.map(wt => (
<button
key={wt.type}
class="widget-picker-item"
onClick={() => onPick(wt.type)}
>
<span class="wpt-label">{wt.label}</span>
<span class="wpt-desc">{wt.desc}</span>
</button>
))}
</div>
</div>
</div>
);
}
-296
View File
@@ -1,296 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

-98
View File
@@ -1,98 +0,0 @@
<script lang="ts">
import type { Interface, Widget } from './types';
import TextView from './widgets/TextView.svelte';
let { iface }: { iface: Interface | null } = $props();
const dpr = window.devicePixelRatio ?? 1;
function componentForType(type: string): any {
switch (type) {
case 'textview': return TextView;
default: return null;
}
}
</script>
<div
class="canvas-container"
style="--dpr: {dpr};"
>
{#if iface === null}
<div class="placeholder">
<p>Select an interface from the left panel, or import one.</p>
</div>
{:else}
<div class="canvas-area">
{#each iface.widgets as widget (widget.id)}
{#if componentForType(widget.type) !== null}
{@const Comp = componentForType(widget.type)}
<Comp {widget} />
{:else}
<!-- Unknown widget type: grey placeholder box -->
<div
class="unknown-widget"
style="
left: {widget.x}px;
top: {widget.y}px;
width: {widget.w}px;
height: {widget.h}px;
"
title="Unknown widget type: {widget.type}"
>
<span class="unknown-label">{widget.type}</span>
</div>
{/if}
{/each}
</div>
{/if}
</div>
<style>
.canvas-container {
flex: 1;
position: relative;
overflow: auto;
background: #0f1117;
min-width: 0;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #475569;
font-size: 0.95rem;
text-align: center;
padding: 2rem;
}
.canvas-area {
position: relative;
/* Large enough to accommodate absolutely-positioned widgets */
min-width: 100%;
min-height: 100%;
width: max-content;
height: max-content;
padding: 1rem;
box-sizing: border-box;
}
.unknown-widget {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background: #1e2535;
border: 1px dashed #3d4f6e;
border-radius: 4px;
box-sizing: border-box;
}
.unknown-label {
color: #475569;
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
</style>
-10
View File
@@ -1,10 +0,0 @@
<script lang="ts">
let count: number = $state(0)
const increment = () => {
count += 1
}
</script>
<button type="button" class="counter" onclick={increment}>
Count is {count}
</button>
-20
View File
@@ -1,20 +0,0 @@
<script lang="ts">
// Edit mode — coming in Phase 6
</script>
<div class="edit-mode">
<p>Edit mode — coming in Phase 6</p>
</div>
<style>
.edit-mode {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100%;
background: #0f1117;
color: #475569;
font-size: 1.1rem;
}
</style>
-214
View File
@@ -1,214 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
interface ServerInterface {
name: string;
id?: string;
}
let { onLoad }: { onLoad: (xml: string) => void } = $props();
let collapsed = $state(false);
let interfaces = $state<ServerInterface[]>([]);
let loading = $state(true);
let fileInput: HTMLInputElement | undefined = $state();
onMount(async () => {
try {
const resp = await fetch('/api/v1/interfaces');
if (resp.ok) {
interfaces = await resp.json();
}
} catch {
// Server not reachable; leave empty list
} finally {
loading = false;
}
});
function toggleCollapse() {
collapsed = !collapsed;
}
function handleNewInterface() {
// Phase 6: edit mode
}
function triggerImport() {
fileInput?.click();
}
async function handleFileChange(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const xml = await file.text();
onLoad(xml);
} catch (err) {
console.error('Failed to read interface file:', err);
} finally {
// Reset so the same file can be re-imported
input.value = '';
}
}
</script>
<aside class="panel" class:collapsed>
<div class="panel-header">
{#if !collapsed}
<span class="panel-title">Interfaces</span>
{/if}
<button class="icon-btn" onclick={toggleCollapse} title={collapsed ? 'Expand panel' : 'Collapse panel'}>
{collapsed ? '▶' : '◀'}
</button>
</div>
{#if !collapsed}
<div class="panel-actions">
<button class="btn" onclick={handleNewInterface} title="New interface (Edit mode Phase 6)">
+ New
</button>
<button class="btn" onclick={triggerImport} title="Import interface from XML file">
Import
</button>
<!-- Hidden file input -->
<input
type="file"
accept=".xml,application/xml,text/xml"
style="display:none"
bind:this={fileInput}
onchange={handleFileChange}
/>
</div>
<div class="panel-list">
{#if loading}
<p class="hint">Loading…</p>
{:else if interfaces.length === 0}
<p class="hint">No interfaces saved yet. Create one in Edit mode.</p>
{:else}
<ul>
{#each interfaces as iface}
<li class="iface-item">{iface.name ?? iface.id ?? 'Unnamed'}</li>
{/each}
</ul>
{/if}
</div>
{/if}
</aside>
<style>
.panel {
width: 260px;
min-width: 260px;
background: #1a1f2e;
border-right: 1px solid #2d3748;
display: flex;
flex-direction: column;
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
}
.panel.collapsed {
width: 40px;
min-width: 40px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border-bottom: 1px solid #2d3748;
min-height: 40px;
flex-shrink: 0;
}
.collapsed .panel-header {
justify-content: center;
padding: 0.5rem;
}
.panel-title {
font-size: 0.8rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.icon-btn {
background: none;
border: none;
color: #64748b;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
border-radius: 3px;
line-height: 1;
}
.icon-btn:hover {
background: #2d3748;
color: #e2e8f0;
}
.panel-actions {
display: flex;
gap: 0.4rem;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.btn {
flex: 1;
background: #2d3748;
border: none;
color: #e2e8f0;
font-size: 0.78rem;
padding: 0.3rem 0.5rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.btn:hover {
background: #374151;
}
.panel-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0;
}
.hint {
color: #475569;
font-size: 0.8rem;
padding: 0.75rem 0.75rem;
line-height: 1.5;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.iface-item {
padding: 0.5rem 0.75rem;
color: #cbd5e1;
font-size: 0.85rem;
cursor: pointer;
border-radius: 3px;
margin: 1px 4px;
}
.iface-item:hover {
background: #2d3748;
color: #e2e8f0;
}
</style>
-175
View File
@@ -1,175 +0,0 @@
<script lang="ts">
import InterfaceList from './InterfaceList.svelte';
import Canvas from './Canvas.svelte';
import { wsClient } from './ws';
import { parseInterface } from './xml';
import type { Interface } from './types';
let currentInterface = $state<Interface | null>(null);
let parseError = $state<string | null>(null);
const wsStatus = wsClient.status;
function handleLoad(xml: string) {
try {
parseError = null;
currentInterface = parseInterface(xml);
} catch (err) {
parseError = err instanceof Error ? err.message : 'Failed to parse interface file.';
}
}
</script>
<div class="view-mode">
<!-- Top toolbar -->
<header class="toolbar">
<div class="toolbar-left">
<span class="app-name">uopi</span>
{#if currentInterface}
<span class="iface-name">{currentInterface.name}</span>
{/if}
</div>
<div class="toolbar-center">
{#if parseError}
<span class="parse-error" title={parseError}>Parse error check console</span>
{/if}
</div>
<div class="toolbar-right">
<div class="status-chip" class:connected={$wsStatus === 'connected'} class:disconnected={$wsStatus === 'disconnected'} class:connecting={$wsStatus === 'connecting'}>
<span class="status-dot"></span>
{$wsStatus}
</div>
<button class="btn-edit" disabled title="Edit mode — coming in Phase 6">Edit</button>
</div>
</header>
<!-- Main content row -->
<div class="content">
<InterfaceList onLoad={handleLoad} />
<Canvas iface={currentInterface} />
</div>
</div>
<style>
.view-mode {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden;
background: #0f1117;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
height: 44px;
background: #1a1f2e;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
gap: 1rem;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.toolbar-center {
flex: 1;
text-align: center;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1;
justify-content: flex-end;
}
.app-name {
font-size: 1rem;
font-weight: 800;
color: #60a5fa;
letter-spacing: -0.5px;
}
.iface-name {
font-size: 0.85rem;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parse-error {
font-size: 0.78rem;
color: #f87171;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 4px;
padding: 0.15rem 0.5rem;
}
.status-chip {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
background: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
text-transform: capitalize;
}
.status-chip.connected {
background: #14532d;
color: #86efac;
border-color: #166534;
}
.status-chip.disconnected {
background: #7f1d1d;
color: #fca5a5;
border-color: #991b1b;
}
.status-chip.connecting {
background: #1e293b;
color: #fbbf24;
border-color: #92400e;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.btn-edit {
background: #2d3748;
border: none;
color: #94a3b8;
font-size: 0.8rem;
padding: 0.25rem 0.7rem;
border-radius: 4px;
cursor: not-allowed;
opacity: 0.6;
}
.content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
// Minimal reactive store — Svelte-compatible interface, no Svelte dependency.
export interface Readable<T> {
subscribe(run: (value: T) => void): () => void;
}
export interface Writable<T> extends Readable<T> {
set(value: T): void;
update(fn: (value: T) => T): void;
get(): T;
}
export function writable<T>(initial: T): Writable<T> {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
subscribe(fn) { subs.add(fn); fn(value); return () => subs.delete(fn); },
set(v) { value = v; subs.forEach(fn => fn(v)); },
update(fn) { this.set(fn(value)); },
get() { return value; },
};
}
export function readable<T>(initial: T, start?: (set: (v: T) => void) => () => void): Readable<T> {
const w = writable(initial);
if (start) {
let stop: (() => void) | null = null;
let subscribers = 0;
return {
subscribe(fn) {
subscribers++;
if (subscribers === 1) stop = start(w.set.bind(w));
const unsub = w.subscribe(fn);
return () => {
unsub();
subscribers--;
if (subscribers === 0 && stop) { stop(); stop = null; }
};
}
};
}
return w;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { readable, writable, type Readable } from 'svelte/store'; import { readable, writable, type Readable } from './store';
import { wsClient } from './ws'; import { wsClient } from './ws';
import type { SignalRef, SignalValue, SignalMeta } from './types'; import type { SignalRef, SignalValue, SignalMeta } from './types';
+3
View File
@@ -36,7 +36,10 @@ export interface Widget {
// A complete HMI interface definition // A complete HMI interface definition
export interface Interface { export interface Interface {
id: string;
name: string; name: string;
version: number; version: number;
w: number;
h: number;
widgets: Widget[]; widgets: Widget[];
} }
-132
View File
@@ -1,132 +0,0 @@
<script lang="ts">
import { getSignalStore, getMetaStore } from '../stores';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
let signalValue = $derived(valueStore ? $valueStore : null);
let signalMeta = $derived(metaStore ? $metaStore : null);
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
const quality = $derived(signalValue?.quality ?? 'unknown');
const qualityColor = $derived(() => {
switch (quality) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
});
const rawValue = $derived(() => {
const v = signalValue?.value;
if (v === null || v === undefined) return null;
return typeof v === 'number' ? v : parseFloat(String(v));
});
const displayValue = $derived(() => {
const v = rawValue();
if (v === null || isNaN(v)) return '---';
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
});
const fillPercent = $derived(() => {
const v = rawValue();
if (v === null || isNaN(v)) return 0;
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
return Math.max(0, Math.min(100, frac * 100));
});
</script>
<div
class="barh"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
>
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
{#if label}
<div class="bar-label">{label}</div>
{/if}
<div class="bar-track">
<div class="bar-fill" style="width: {fillPercent()}%;"></div>
</div>
<div class="bar-value">
<span class="value">{displayValue()}</span>
{#if unit}<span class="unit">{unit}</span>{/if}
</div>
</div>
<style>
.barh {
position: absolute;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 3px;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
padding: 6px 10px;
font-family: system-ui, sans-serif;
overflow: hidden;
}
.quality-dot {
position: absolute;
top: 5px;
right: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
}
.bar-label {
font-size: 0.75rem;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
width: 100%;
height: 12px;
background: #2d3748;
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #4a9eff;
border-radius: 3px;
transition: width 0.15s ease;
}
.bar-value {
display: flex;
gap: 4px;
align-items: baseline;
justify-content: flex-end;
}
.value {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #e2e8f0;
}
.unit {
font-size: 0.7rem;
color: #64748b;
}
</style>
-136
View File
@@ -1,136 +0,0 @@
<script lang="ts">
import { getSignalStore, getMetaStore } from '../stores';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
let signalValue = $derived(valueStore ? $valueStore : null);
let signalMeta = $derived(metaStore ? $metaStore : null);
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
const quality = $derived(signalValue?.quality ?? 'unknown');
const qualityColor = $derived(() => {
switch (quality) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
});
const rawValue = $derived(() => {
const v = signalValue?.value;
if (v === null || v === undefined) return null;
return typeof v === 'number' ? v : parseFloat(String(v));
});
const displayValue = $derived(() => {
const v = rawValue();
if (v === null || isNaN(v)) return '---';
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
});
const fillPercent = $derived(() => {
const v = rawValue();
if (v === null || isNaN(v)) return 0;
const frac = maxVal === minVal ? 0 : (v - minVal) / (maxVal - minVal);
return Math.max(0, Math.min(100, frac * 100));
});
</script>
<div
class="barv"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
>
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
{#if label}
<div class="bar-label">{label}</div>
{/if}
<div class="bar-value">
<span class="value">{displayValue()}</span>
{#if unit}<span class="unit">{unit}</span>{/if}
</div>
<div class="bar-track">
<div class="bar-fill" style="height: {fillPercent()}%;"></div>
</div>
</div>
<style>
.barv {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 3px;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
padding: 6px 8px;
font-family: system-ui, sans-serif;
overflow: hidden;
}
.quality-dot {
position: absolute;
top: 5px;
right: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
}
.bar-label {
font-size: 0.75rem;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: center;
}
.bar-value {
display: flex;
gap: 3px;
align-items: baseline;
}
.value {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #e2e8f0;
}
.unit {
font-size: 0.7rem;
color: #64748b;
}
.bar-track {
width: 24px;
flex: 1;
background: #2d3748;
border-radius: 3px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.bar-fill {
width: 100%;
background: #4a9eff;
border-radius: 3px;
transition: height 0.15s ease;
}
</style>
-66
View File
@@ -1,66 +0,0 @@
<script lang="ts">
import { wsClient } from '../ws';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const label = $derived(widget.options['label'] ?? 'Button');
const valueOpt = $derived(widget.options['value'] ?? '1');
const confirm = $derived(widget.options['confirm'] === 'true');
function handleClick() {
if (!sigRef) return;
// Parse as number if possible, otherwise send as string
const parsed = parseFloat(valueOpt);
const val = isNaN(parsed) ? valueOpt : parsed;
const doWrite = () => wsClient.write(sigRef, val);
if (confirm) {
if (window.confirm(`Send ${label}?`)) doWrite();
} else {
doWrite();
}
}
</script>
<div
class="button-widget"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
>
<button class="btn" onclick={handleClick}>{label}</button>
</div>
<style>
.button-widget {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 4px;
}
.btn {
width: 100%;
height: 100%;
background: #1e3a5f;
color: #e2e8f0;
border: 1px solid #2d5a9e;
border-radius: 6px;
font-size: 0.9rem;
font-family: system-ui, sans-serif;
cursor: pointer;
transition: background 0.15s;
}
.btn:hover {
background: #2563eb;
border-color: #3b82f6;
}
.btn:active {
background: #1d4ed8;
transform: scale(0.97);
}
</style>
-187
View File
@@ -1,187 +0,0 @@
<script lang="ts">
import { getSignalStore, getMetaStore } from '../stores';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
let signalValue = $derived(valueStore ? $valueStore : null);
let signalMeta = $derived(metaStore ? $metaStore : null);
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
const minVal = $derived(parseFloat(widget.options['min'] ?? String(signalMeta?.displayLow ?? 0)));
const maxVal = $derived(parseFloat(widget.options['max'] ?? String(signalMeta?.displayHigh ?? 100)));
const thresholdLow = $derived(widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null);
const thresholdHigh = $derived(widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null);
const quality = $derived(signalValue?.quality ?? 'unknown');
const qualityColor = $derived(() => {
switch (quality) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
});
const rawValue = $derived(() => {
const v = signalValue?.value;
if (v === null || v === undefined) return null;
return typeof v === 'number' ? v : parseFloat(String(v));
});
const displayValue = $derived(() => {
const v = rawValue();
if (v === null || isNaN(v)) return '---';
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
});
// Arc: -135deg to +135deg (270deg total)
const START_DEG = -135;
const END_DEG = 135;
const TOTAL_DEG = 270;
const cx = 50;
const cy = 54;
const r = 38;
function degToRad(deg: number) {
return (deg * Math.PI) / 180;
}
function polarToXY(deg: number, radius: number) {
const rad = degToRad(deg);
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
}
function arcPath(startDeg: number, endDeg: number, radius: number) {
const s = polarToXY(startDeg, radius);
const e = polarToXY(endDeg, radius);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
}
function valueToDeg(v: number) {
const clamped = Math.max(minVal, Math.min(maxVal, v));
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
return START_DEG + frac * TOTAL_DEG;
}
const fillColor = $derived(() => {
const v = rawValue();
if (v === null) return '#4a9eff';
if (thresholdHigh !== null && v >= thresholdHigh) return '#ef4444';
if (thresholdLow !== null && v <= thresholdLow) return '#f59e0b';
return '#4a9eff';
});
const needleDeg = $derived(() => {
const v = rawValue();
if (v === null) return START_DEG;
return valueToDeg(v);
});
const fillArcPath = $derived(() => {
const nd = needleDeg();
if (nd <= START_DEG) return '';
return arcPath(START_DEG, nd, r);
});
const bgArcPath = $derived(() => arcPath(START_DEG, END_DEG, r));
const needlePos = $derived(() => polarToXY(needleDeg(), r - 4));
</script>
<div
class="gauge"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
>
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
<svg viewBox="0 0 100 80" class="gauge-svg">
<!-- Background arc -->
<path d={bgArcPath()} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
<!-- Filled arc -->
{#if fillArcPath()}
<path d={fillArcPath()} fill="none" stroke={fillColor()} stroke-width="6" stroke-linecap="round" />
{/if}
<!-- Needle dot -->
<circle cx={needlePos().x} cy={needlePos().y} r="3" fill={fillColor()} />
<!-- Center value -->
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
{#if unit}
<text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>
{/if}
<!-- Min/Max labels -->
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
</svg>
{#if label}
<div class="gauge-label">{label}</div>
{/if}
</div>
<style>
.gauge {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
overflow: hidden;
font-family: system-ui, sans-serif;
padding: 4px;
}
.quality-dot {
position: absolute;
top: 5px;
right: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
}
.gauge-svg {
width: 100%;
flex: 1;
overflow: visible;
}
.gauge-value {
fill: #e2e8f0;
font-size: 14px;
font-family: ui-monospace, monospace;
}
.gauge-unit {
fill: #64748b;
font-size: 6px;
font-family: system-ui, sans-serif;
}
.gauge-range {
fill: #475569;
font-size: 6px;
font-family: system-ui, sans-serif;
}
.gauge-label {
font-size: 0.75rem;
color: #94a3b8;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
padding: 0 4px;
box-sizing: border-box;
}
</style>
-99
View File
@@ -1,99 +0,0 @@
<script lang="ts">
import { getSignalStore } from '../stores';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
let signalValue = $derived(valueStore ? $valueStore : null);
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
const condition = $derived(widget.options['condition'] ?? 'value > 0');
const colorTrue = $derived(widget.options['colorTrue'] ?? '#22c55e');
const colorFalse = $derived(widget.options['colorFalse'] ?? '#ef4444');
const quality = $derived(signalValue?.quality ?? 'unknown');
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
function evaluateCondition(expr: string, v: any): boolean {
// Safety check: reject dangerous patterns
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) {
return false;
}
try {
// eslint-disable-next-line no-new-func
return new Function('value', 'return ' + expr)(v);
} catch {
return false;
}
}
const ledOn = $derived(() => {
const v = signalValue?.value;
if (v === null || v === undefined) return false;
return evaluateCondition(condition, v);
});
const ledColor = $derived(() => ledOn() ? colorTrue : colorFalse);
</script>
<div
class="led-widget"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
>
<div
class="led-circle"
class:blink={isUncertain}
style="background: {ledColor()}; box-shadow: 0 0 8px {ledColor()}88;"
></div>
{#if label}
<div class="led-label">{label}</div>
{/if}
</div>
<style>
.led-widget {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
padding: 4px;
font-family: system-ui, sans-serif;
overflow: hidden;
}
.led-circle {
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.2s;
}
.led-circle.blink {
animation: blink-slow 1.8s ease-in-out infinite;
}
@keyframes blink-slow {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.led-label {
font-size: 0.75rem;
color: #94a3b8;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
</style>
-108
View File
@@ -1,108 +0,0 @@
<script lang="ts">
import { getSignalStore } from '../stores';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
let signalValue = $derived(valueStore ? $valueStore : null);
const bits = $derived(parseInt(widget.options['bits'] ?? '8', 10));
const labelsRaw = $derived(widget.options['labels'] ?? '');
const colorsTrueRaw = $derived(widget.options['colorsTrue'] ?? '');
const colorsFalseRaw = $derived(widget.options['colorsFalse'] ?? '');
const labelArr = $derived(labelsRaw ? labelsRaw.split(',') : []);
const colorsTrueArr = $derived(colorsTrueRaw ? colorsTrueRaw.split(',') : []);
const colorsFalseArr = $derived(colorsFalseRaw ? colorsFalseRaw.split(',') : []);
const quality = $derived(signalValue?.quality ?? 'unknown');
const isUncertain = $derived(quality === 'uncertain' || quality === 'unknown');
const intValue = $derived(() => {
const v = signalValue?.value;
if (v === null || v === undefined) return 0;
return typeof v === 'number' ? Math.floor(v) : parseInt(String(v), 10);
});
function getBit(val: number, bit: number): boolean {
return (val & (1 << bit)) !== 0;
}
const bitStates = $derived(() => {
const val = intValue();
return Array.from({ length: bits }, (_, i) => getBit(val, i));
});
</script>
<div
class="multiled-widget"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
>
{#each bitStates() as on, i}
{@const trueColor = colorsTrueArr[i] ?? '#22c55e'}
{@const falseColor = colorsFalseArr[i] ?? '#ef4444'}
{@const color = on ? trueColor : falseColor}
{@const bitLabel = labelArr[i] ?? String(i)}
<div class="bit-item">
<div
class="led-circle"
class:blink={isUncertain}
style="background: {color}; box-shadow: 0 0 6px {color}88;"
></div>
<div class="bit-label">{bitLabel}</div>
</div>
{/each}
</div>
<style>
.multiled-widget {
position: absolute;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-start;
gap: 4px;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
padding: 6px;
font-family: system-ui, sans-serif;
overflow: hidden;
}
.bit-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 20px;
}
.led-circle {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.led-circle.blink {
animation: blink-slow 1.8s ease-in-out infinite;
}
@keyframes blink-slow {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.bit-label {
font-size: 0.6rem;
color: #64748b;
text-align: center;
white-space: nowrap;
}
</style>
-378
View File
@@ -1,378 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSignalStore } from '../stores';
import type { Widget, SignalRef } from '../types';
let { widget }: { widget: Widget } = $props();
const plotType = $derived(widget.options['plotType'] ?? 'timeseries');
const timeWindow = $derived(parseFloat(widget.options['timeWindow'] ?? '60'));
const legendPos = $derived(widget.options['legend'] ?? 'bottom');
const yMinOpt = $derived(widget.options['yMin'] ?? 'auto');
const yMaxOpt = $derived(widget.options['yMax'] ?? 'auto');
let containerEl: HTMLDivElement;
let chartEl: HTMLDivElement;
// ── Shared per-signal data buffers ────────────────────────────────────────────
interface SeriesBuffer {
timestamps: number[]; // Unix seconds
values: number[];
}
// Filled on mount — up to 10 signals
let buffers: SeriesBuffer[] = [];
let unsubs: (() => void)[] = [];
// ── uPlot (timeseries) ────────────────────────────────────────────────────────
let uplotInstance: any = null;
let uplotImported = false;
// ── ECharts (other types) ─────────────────────────────────────────────────────
let echartsInstance: any = null;
let echartsImported = false;
// Histogram per-series: accumulate raw values, bucket on render
let histValues: number[][] = [];
const RING_MAX = 4000;
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
buf.timestamps.push(ts);
buf.values.push(val);
if (buf.timestamps.length > RING_MAX) {
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
buf.values.splice(0, buf.values.length - RING_MAX);
}
}
function windowedSlice(buf: SeriesBuffer, windowSec: number): { ts: number[]; vals: number[] } {
const now = Date.now() / 1000;
const cutoff = now - windowSec;
const start = buf.timestamps.findIndex((t) => t >= cutoff);
if (start === -1) return { ts: [], vals: [] };
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
}
// ── uPlot rendering ───────────────────────────────────────────────────────────
async function initUplot() {
if (uplotImported) return;
uplotImported = true;
// @ts-ignore
const uPlot = (await import('uplot')).default;
const signals = widget.signals;
const seriesConf = [
{},
...signals.map((s, i) => ({
label: s.name,
stroke: s.color ?? defaultColors[i % defaultColors.length],
width: 1.5,
})),
];
const legendShow = legendPos !== 'none';
uplotInstance = new uPlot(
{
width: chartEl.clientWidth || widget.w - 2,
height: chartEl.clientHeight || widget.h - 40,
series: seriesConf,
legend: { show: legendShow },
axes: [
{ space: 40 },
{
min: yMinOpt === 'auto' ? undefined : parseFloat(yMinOpt),
max: yMaxOpt === 'auto' ? undefined : parseFloat(yMaxOpt),
},
],
scales: {
x: { time: true },
y: {
range: yMinOpt === 'auto' && yMaxOpt === 'auto'
? undefined
: [
yMinOpt === 'auto' ? null : parseFloat(yMinOpt),
yMaxOpt === 'auto' ? null : parseFloat(yMaxOpt),
],
},
},
},
buildUplotData(),
chartEl,
);
}
function buildUplotData(): any[] {
if (buffers.length === 0) return [[]];
// Use longest timestamps set (first signal)
const tw = timeWindow;
const allTs = new Set<number>();
buffers.forEach((b) => {
const { ts } = windowedSlice(b, tw);
ts.forEach((t) => allTs.add(t));
});
const sortedTs = Array.from(allTs).sort((a, b) => a - b);
if (sortedTs.length === 0) return [[]];
const series: any[] = [sortedTs];
buffers.forEach((b) => {
const tsMap = new Map<number, number>();
const { ts, vals } = windowedSlice(b, tw);
ts.forEach((t, i) => tsMap.set(t, vals[i]));
series.push(sortedTs.map((t) => tsMap.get(t) ?? null));
});
return series;
}
function updateUplot() {
if (!uplotInstance) return;
uplotInstance.setData(buildUplotData());
}
// ── ECharts rendering ─────────────────────────────────────────────────────────
async function initEcharts() {
if (echartsImported) return;
echartsImported = true;
// @ts-ignore
const echarts = await import('echarts');
echartsInstance = echarts.init(chartEl, 'dark');
updateEcharts(echarts);
}
function buildEchartsOption(echarts?: any): any {
const signals = widget.signals;
const legendShow = legendPos !== 'none';
switch (plotType) {
case 'fft': {
return {
backgroundColor: 'transparent',
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
xAxis: { type: 'category', name: 'Freq Index', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#475569' } }, axisLabel: { color: '#64748b' } },
series: buffers.map((b, i) => ({
name: signals[i]?.name ?? `S${i}`,
type: 'line',
data: b.values.length ? b.values[b.values.length - 1] : [],
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
showSymbol: false,
})),
};
}
case 'histogram': {
const buckets = buildHistogram();
return {
backgroundColor: 'transparent',
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
xAxis: { type: 'category', data: buckets.labels, axisLabel: { color: '#64748b' } },
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
series: buckets.series.map((d, i) => ({
name: signals[i]?.name ?? `S${i}`,
type: 'bar',
data: d,
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
})),
};
}
case 'bar': {
return {
backgroundColor: 'transparent',
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
xAxis: { type: 'category', data: signals.map((s) => s.name), axisLabel: { color: '#64748b' } },
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
series: [{
type: 'bar',
data: buffers.map((b, i) => ({
value: b.values.length ? b.values[b.values.length - 1] : 0,
itemStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
})),
}],
};
}
case 'logic': {
return {
backgroundColor: 'transparent',
legend: legendShow ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } } : undefined,
xAxis: { type: 'value', axisLabel: { color: '#64748b' }, min: 'dataMin', max: 'dataMax' },
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLabel: { color: '#64748b' } },
series: buffers.map((b, i) => {
const { ts, vals } = windowedSlice(b, timeWindow);
return {
name: signals[i]?.name ?? `S${i}`,
type: 'line',
step: 'end',
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
lineStyle: { color: signals[i]?.color ?? defaultColors[i % defaultColors.length] },
showSymbol: false,
};
}),
};
}
case 'waterfall': {
// Display last N rows as a heatmap-style waterfall
const ROWS = 32;
const history = buffers[0];
const rows: number[][] = [];
const step = Math.max(1, Math.floor(history.values.length / ROWS));
for (let i = 0; i < ROWS; i++) {
const idx = Math.min(history.values.length - 1 - (ROWS - 1 - i) * step, history.values.length - 1);
if (idx < 0) {
rows.push([]);
} else {
const v = history.values[idx];
rows.push(Array.isArray(v) ? v : [v]);
}
}
const flatData: [number, number, number][] = [];
rows.forEach((row, ri) => row.forEach((val, ci) => flatData.push([ci, ri, val])));
return {
backgroundColor: 'transparent',
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
xAxis: { type: 'value', axisLabel: { color: '#64748b' } },
yAxis: { type: 'value', axisLabel: { color: '#64748b' } },
series: [{ type: 'heatmap', data: flatData }],
};
}
default:
return {};
}
}
function buildHistogram(): { labels: string[]; series: number[][] } {
const BUCKETS = 20;
const result: number[][] = buffers.map(() => new Array(BUCKETS).fill(0));
let globalMin = Infinity, globalMax = -Infinity;
histValues.forEach((vals) => {
vals.forEach((v) => {
if (v < globalMin) globalMin = v;
if (v > globalMax) globalMax = v;
});
});
if (!isFinite(globalMin) || !isFinite(globalMax) || globalMin === globalMax) {
return { labels: [], series: result };
}
const range = globalMax - globalMin;
const bucketSize = range / BUCKETS;
histValues.forEach((vals, si) => {
vals.forEach((v) => {
const idx = Math.min(BUCKETS - 1, Math.floor((v - globalMin) / bucketSize));
result[si][idx]++;
});
});
const labels = Array.from({ length: BUCKETS }, (_, i) =>
(globalMin + i * bucketSize).toPrecision(3),
);
return { labels, series: result };
}
function updateEcharts(echarts?: any) {
if (!echartsInstance) return;
echartsInstance.setOption(buildEchartsOption(echarts), { notMerge: true });
}
// ── Mount & cleanup ───────────────────────────────────────────────────────────
const defaultColors = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
onMount(async () => {
const signals = widget.signals;
buffers = signals.map(() => ({ timestamps: [], values: [] }));
histValues = signals.map(() => []);
signals.forEach((sig: SignalRef & { color?: string }, i: number) => {
const store = getSignalStore(sig);
const unsub = store.subscribe((sv) => {
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (!isNaN(v)) {
pushSample(buffers[i], ts, v);
if (plotType === 'histogram') {
histValues[i].push(v);
if (histValues[i].length > RING_MAX) histValues[i].splice(0, histValues[i].length - RING_MAX);
}
}
// Trigger render
if (plotType === 'timeseries') {
updateUplot();
} else {
updateEcharts();
}
});
unsubs.push(unsub);
});
if (plotType === 'timeseries') {
await initUplot();
} else {
await initEcharts();
}
// Handle resize
const ro = new ResizeObserver(() => {
if (uplotInstance) {
uplotInstance.setSize({ width: chartEl.clientWidth, height: chartEl.clientHeight });
}
if (echartsInstance) {
echartsInstance.resize();
}
});
ro.observe(containerEl);
unsubs.push(() => ro.disconnect());
});
onDestroy(() => {
unsubs.forEach((u) => u());
if (uplotInstance) uplotInstance.destroy();
if (echartsInstance) echartsInstance.dispose();
});
</script>
<svelte:head>
{#if plotType === 'timeseries'}
<link rel="stylesheet" href="/node_modules/uplot/dist/uPlot.min.css" />
{/if}
</svelte:head>
<div
class="plot-widget"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
bind:this={containerEl}
>
<div class="chart-area" bind:this={chartEl}></div>
</div>
<style>
.plot-widget {
position: absolute;
display: flex;
flex-direction: column;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
overflow: hidden;
font-family: system-ui, sans-serif;
}
.chart-area {
flex: 1;
width: 100%;
overflow: hidden;
min-height: 0;
}
/* Ensure uPlot fills the container */
.chart-area :global(.uplot) {
width: 100% !important;
height: 100% !important;
}
</style>
-170
View File
@@ -1,170 +0,0 @@
<script lang="ts">
import { getSignalStore, getMetaStore } from '../stores';
import { wsClient } from '../ws';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
let signalValue = $derived(valueStore ? $valueStore : null);
let signalMeta = $derived(metaStore ? $metaStore : null);
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
const unit = $derived(widget.options['unit'] ?? signalMeta?.unit ?? '');
const confirm = $derived(widget.options['confirm'] === 'true');
const isNumeric = $derived(signalMeta ? signalMeta.type !== 'TypeString' : true);
const quality = $derived(signalValue?.quality ?? 'unknown');
const qualityColor = $derived(() => {
switch (quality) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
});
const displayValue = $derived(() => {
const v = signalValue?.value;
if (v === null || v === undefined) return '---';
if (typeof v === 'number') {
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
}
return String(v);
});
let inputValue = $state('');
function handleSet() {
if (!sigRef) return;
const raw = inputValue.trim();
if (!raw) return;
const doWrite = () => {
const val = isNumeric ? parseFloat(raw) : raw;
wsClient.write(sigRef, val);
inputValue = '';
};
if (confirm) {
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
} else {
doWrite();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleSet();
}
</script>
<div
class="setvalue"
style="left: {widget.x}px; top: {widget.y}px; width: {widget.w}px; height: {widget.h}px;"
>
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
<span class="sv-label">{label}:</span>
{#if isNumeric}
<input
class="sv-input"
type="number"
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder="value"
/>
{:else}
<input
class="sv-input"
type="text"
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder="value"
/>
{/if}
<span class="sv-current">{displayValue()}</span>
{#if unit}<span class="sv-unit">{unit}</span>{/if}
<button class="sv-btn" onclick={handleSet}>Set</button>
</div>
<style>
.setvalue {
position: absolute;
display: flex;
align-items: center;
gap: 5px;
padding: 0 8px;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
overflow: hidden;
font-family: system-ui, sans-serif;
white-space: nowrap;
}
.quality-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sv-label {
color: #94a3b8;
font-size: 0.8rem;
flex-shrink: 0;
}
.sv-input {
width: 80px;
background: #0f1117;
border: 1px solid #3d4f6e;
border-radius: 4px;
color: #e2e8f0;
font-size: 0.85rem;
padding: 2px 5px;
flex-shrink: 0;
font-family: ui-monospace, monospace;
}
.sv-input:focus {
outline: none;
border-color: #4a9eff;
}
.sv-current {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #e2e8f0;
flex-shrink: 0;
}
.sv-unit {
font-size: 0.7rem;
color: #64748b;
flex-shrink: 0;
}
.sv-btn {
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
padding: 3px 10px;
font-size: 0.8rem;
cursor: pointer;
flex-shrink: 0;
font-family: system-ui, sans-serif;
}
.sv-btn:hover {
background: #1d4ed8;
}
.sv-btn:active {
background: #1e40af;
}
</style>
-99
View File
@@ -1,99 +0,0 @@
<script lang="ts">
import { getSignalStore, getMetaStore } from '../stores';
import type { Widget } from '../types';
let { widget }: { widget: Widget } = $props();
const sigRef = $derived(widget.signals[0]);
const label = $derived(widget.options['label'] ?? sigRef?.name ?? '');
// Reactive stores — re-created when sigRef changes
const valueStore = $derived(sigRef ? getSignalStore(sigRef) : null);
const metaStore = $derived(sigRef ? getMetaStore(sigRef) : null);
let signalValue = $derived(valueStore ? $valueStore : null);
let signalMeta = $derived(metaStore ? $metaStore : null);
const displayValue = $derived(() => {
if (!signalValue || signalValue.value === null || signalValue.value === undefined) {
return '---';
}
const v = signalValue.value;
if (typeof v === 'number') {
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
}
return String(v);
});
const unit = $derived(signalMeta?.unit ?? '');
const quality = $derived(signalValue?.quality ?? 'unknown');
const qualityColor = $derived(() => {
switch (quality) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
});
</script>
<div
class="textview"
style="
left: {widget.x}px;
top: {widget.y}px;
width: {widget.w}px;
height: {widget.h}px;
"
>
<span class="quality-dot" style="background: {qualityColor()};" title="Quality: {quality}"></span>
<span class="label">{label}:</span>
<span class="value">{displayValue()}</span>
{#if unit}
<span class="unit">{unit}</span>
{/if}
</div>
<style>
.textview {
position: absolute;
display: flex;
align-items: center;
gap: 0.4em;
padding: 0 0.6em;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 4px;
overflow: hidden;
box-sizing: border-box;
white-space: nowrap;
}
.quality-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.label {
color: #94a3b8;
font-size: 0.8rem;
flex-shrink: 0;
}
.value {
font-family: ui-monospace, Consolas, monospace;
font-size: 0.9rem;
color: #e2e8f0;
min-width: 4ch;
}
.unit {
color: #64748b;
font-size: 0.75rem;
flex-shrink: 0;
}
</style>
+1 -1
View File
@@ -1,4 +1,4 @@
import { readable, writable, type Readable } from 'svelte/store'; import { readable, writable, type Readable } from './store';
import type { SignalRef, SignalValue, SignalMeta } from './types'; import type { SignalRef, SignalValue, SignalMeta } from './types';
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
+39 -1
View File
@@ -1,5 +1,40 @@
import type { Interface, Widget, SignalRef } from './types'; import type { Interface, Widget, SignalRef } from './types';
/** Escape a string for use as an XML attribute value. */
function xmlEsc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/** Serialize an Interface object to an XML string. */
export function serializeInterface(iface: Interface): string {
const lines: string[] = [];
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
lines.push(
`<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` +
`version="${iface.version}" width="${iface.w}" height="${iface.h}">`
);
for (const w of iface.widgets) {
lines.push(
` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` +
`x="${w.x}" y="${w.y}" w="${w.w}" h="${w.h}">`
);
for (const sig of w.signals) {
const colorAttr = sig.color ? ` color="${xmlEsc(sig.color)}"` : '';
lines.push(` <signal ds="${xmlEsc(sig.ds)}" name="${xmlEsc(sig.name)}"${colorAttr}/>`);
}
for (const [key, value] of Object.entries(w.options)) {
lines.push(` <option key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
}
lines.push(` </widget>`);
}
lines.push(`</interface>`);
return lines.join('\n');
}
/** /**
* Parse an interface XML string into an Interface object. * Parse an interface XML string into an Interface object.
* *
@@ -26,8 +61,11 @@ export function parseInterface(xml: string): Interface {
throw new Error(`Expected root element <interface>, got <${root.tagName}>`); throw new Error(`Expected root element <interface>, got <${root.tagName}>`);
} }
const ifaceId = root.getAttribute('id') ?? '';
const name = root.getAttribute('name') ?? 'Untitled'; const name = root.getAttribute('name') ?? 'Untitled';
const version = parseInt(root.getAttribute('version') ?? '1', 10); const version = parseInt(root.getAttribute('version') ?? '1', 10);
const w = parseInt(root.getAttribute('width') ?? '1200', 10);
const h = parseInt(root.getAttribute('height') ?? '800', 10);
const widgets: Widget[] = []; const widgets: Widget[] = [];
@@ -62,5 +100,5 @@ export function parseInterface(xml: string): Interface {
widgets.push({ id, type, x, y, w, h, signals, options }); widgets.push({ id, type, x, y, w, h, signals, options });
} }
return { name, version, widgets }; return { id: ifaceId, name, version, w, h, widgets };
} }
-9
View File
@@ -1,9 +0,0 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
+5
View File
@@ -0,0 +1,5 @@
import { h, render, Fragment } from 'preact';
import App from './App';
import './styles.css';
render(<App />, document.getElementById('app')!);
+97
View File
@@ -0,0 +1,97 @@
// Minimal type declarations for vendored Preact — no npm required.
// Global JSX namespace — required for "jsx": "react" + jsxFactory: "h"
declare namespace JSX {
type Element = import('preact').VNode;
interface ElementClass { render(): import('preact').ComponentChild; }
interface ElementAttributesProperty { props: {}; }
interface ElementChildrenAttribute { children: {}; }
type IntrinsicElements = {
[K in keyof HTMLElementTagNameMap]: any;
} & {
[K in keyof SVGElementTagNameMap]: any;
} & { [key: string]: any };
}
declare module 'preact' {
export type Key = string | number;
export type Ref<T> = { current: T | null };
export type VNode<P = {}> = { type: any; props: P; key: Key | null };
export type ComponentType<P = {}> = (props: P) => VNode | null;
export type ComponentChild = VNode | string | number | boolean | null | undefined;
export type ComponentChildren = ComponentChild | ComponentChild[];
export interface Attributes {
key?: Key;
ref?: Ref<any>;
children?: ComponentChildren;
}
export function h(type: any, props: any, ...children: any[]): VNode;
export const Fragment: unique symbol;
export function render(vnode: ComponentChild, parent: Element | ShadowRoot): void;
export function cloneElement(vnode: VNode, props?: any, ...children: any[]): VNode;
namespace JSX {
type Element = VNode;
interface ElementClass { render(): ComponentChild; }
interface ElementAttributesProperty { props: {}; }
interface ElementChildrenAttribute { children: {}; }
// Allow any HTML/SVG attributes on intrinsic elements
type IntrinsicElements = {
[K in keyof HTMLElementTagNameMap]: any;
} & {
[K in keyof SVGElementTagNameMap]: any;
} & { [key: string]: any };
}
}
declare module 'preact/hooks' {
import type { Ref, VNode } from 'preact';
export type StateUpdater<S> = S | ((prevState: S) => S);
export function useState<S>(initialState: S | (() => S)): [S, (update: StateUpdater<S>) => void];
export function useReducer<S, A>(reducer: (state: S, action: A) => S, initialState: S): [S, (action: A) => void];
export function useEffect(effect: () => void | (() => void), inputs?: readonly any[]): void;
export function useLayoutEffect(effect: () => void | (() => void), inputs?: readonly any[]): void;
export function useRef<T>(initialValue: T): { current: T };
export function useRef<T>(initialValue: T | null): Ref<T>;
export function useMemo<T>(factory: () => T, inputs: readonly any[]): T;
export function useCallback<T extends (...args: any[]) => any>(callback: T, inputs: readonly any[]): T;
export function useContext<T>(context: import('preact').Context<T>): T;
}
declare module 'uplot' {
namespace uPlot {
type AlignedData = (number | null | undefined)[][];
interface Series { label?: string; stroke?: string; width?: number; [k: string]: any; }
interface Scale { min?: number; max?: number; time?: boolean; [k: string]: any; }
interface Axis { stroke?: string; grid?: any; ticks?: any; [k: string]: any; }
interface Options {
width: number;
height: number;
series: Series[];
legend?: { show?: boolean };
axes?: Axis[];
scales?: { [key: string]: Scale };
[k: string]: any;
}
}
class uPlot {
constructor(opts: uPlot.Options, data: uPlot.AlignedData, target: HTMLElement);
setData(data: uPlot.AlignedData): void;
setSize(size: { width: number; height: number }): void;
destroy(): void;
}
export default uPlot;
}
declare module 'echarts' {
interface EChartsType {
setOption(option: any, opts?: any): void;
resize(): void;
dispose(): void;
}
export function init(el: HTMLElement, theme?: string | null): EChartsType;
export type { EChartsType };
}
+1312
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"strict": true,
"noImplicitAny": false,
"noUnusedLocals": true,
"noUnusedParameters": false,
"skipLibCheck": true,
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.tsx", "./preact.d.ts"]
}
+69
View File
@@ -0,0 +1,69 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function BarH({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsubV = getSignalStore(sigRef).subscribe(setSv);
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const quality = sv.quality;
const rawV = sv.value;
const rawValue: number | null = rawV === null || rawV === undefined ? null
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
function displayValue(): string {
if (rawValue === null || isNaN(rawValue)) return '---';
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
}
function fillPercent(): number {
if (rawValue === null || isNaN(rawValue)) return 0;
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
return Math.max(0, Math.min(100, frac * 100));
}
return (
<div
class="barh"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
{label && <div class="bar-label">{label}</div>}
<div class="bar-track">
<div class="bar-fill" style={`width:${fillPercent()}%;`} />
</div>
<div class="bar-value">
<span class="value">{displayValue()}</span>
{unit && <span class="unit">{unit}</span>}
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function BarV({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsubV = getSignalStore(sigRef).subscribe(setSv);
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const quality = sv.quality;
const rawV = sv.value;
const rawValue: number | null = rawV === null || rawV === undefined ? null
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
function displayValue(): string {
if (rawValue === null || isNaN(rawValue)) return '---';
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
}
function fillPercent(): number {
if (rawValue === null || isNaN(rawValue)) return 0;
const frac = maxVal === minVal ? 0 : (rawValue - minVal) / (maxVal - minVal);
return Math.max(0, Math.min(100, frac * 100));
}
return (
<div
class="barv"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
{label && <div class="bar-label">{label}</div>}
<div class="bar-value">
<span class="value">{displayValue()}</span>
{unit && <span class="unit">{unit}</span>}
</div>
<div class="bar-track">
<div class="bar-fill" style={`height:${fillPercent()}%;`} />
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { h } from 'preact';
import { wsClient } from '../lib/ws';
import type { Widget } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function Button({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const label = widget.options['label'] ?? 'Button';
const valueOpt = widget.options['value'] ?? '1';
const confirm = widget.options['confirm'] === 'true';
function handleClick() {
if (!sigRef) return;
const parsed = parseFloat(valueOpt);
const val = isNaN(parsed) ? valueOpt : parsed;
const doWrite = () => wsClient.write(sigRef, val);
if (confirm) {
if (window.confirm(`Send ${label}?`)) doWrite();
} else {
doWrite();
}
}
return (
<div
class="button-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<button class="btn" onClick={handleClick}>{label}</button>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
// Arc: -135deg to +135deg (270deg total)
const START_DEG = -135;
const END_DEG = 135;
const TOTAL_DEG = 270;
const cx = 50, cy = 54, r = 38;
function degToRad(deg: number) { return (deg * Math.PI) / 180; }
function polarToXY(deg: number, radius: number) {
const rad = degToRad(deg);
return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) };
}
function arcPath(startDeg: number, endDeg: number, radius: number): string {
const s = polarToXY(startDeg, radius);
const e = polarToXY(endDeg, radius);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return `M ${s.x} ${s.y} A ${radius} ${radius} 0 ${largeArc} 1 ${e.x} ${e.y}`;
}
function valueToDeg(v: number, minVal: number, maxVal: number): number {
const clamped = Math.max(minVal, Math.min(maxVal, v));
const frac = maxVal === minVal ? 0 : (clamped - minVal) / (maxVal - minVal);
return START_DEG + frac * TOTAL_DEG;
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function Gauge({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsubV = getSignalStore(sigRef).subscribe(setSv);
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const minVal = parseFloat(widget.options['min'] ?? String(meta?.displayLow ?? 0));
const maxVal = parseFloat(widget.options['max'] ?? String(meta?.displayHigh ?? 100));
const thresholdLow = widget.options['thresholdLow'] ? parseFloat(widget.options['thresholdLow']) : null;
const thresholdHigh = widget.options['thresholdHigh'] ? parseFloat(widget.options['thresholdHigh']) : null;
const quality = sv.quality;
const rawV = sv.value;
const rawValue: number | null = rawV === null || rawV === undefined ? null
: typeof rawV === 'number' ? rawV : parseFloat(String(rawV));
function displayValue(): string {
if (rawValue === null || isNaN(rawValue)) return '---';
return Number.isFinite(rawValue) ? rawValue.toPrecision(4).replace(/\.?0+$/, '') : String(rawValue);
}
function fillColor(): string {
if (rawValue === null) return '#4a9eff';
if (thresholdHigh !== null && rawValue >= thresholdHigh) return '#ef4444';
if (thresholdLow !== null && rawValue <= thresholdLow) return '#f59e0b';
return '#4a9eff';
}
const needleDeg = rawValue === null ? START_DEG : valueToDeg(rawValue, minVal, maxVal);
const bgPath = arcPath(START_DEG, END_DEG, r);
const fillPath = needleDeg > START_DEG ? arcPath(START_DEG, needleDeg, r) : '';
const needlePos = polarToXY(needleDeg, r - 4);
const fc = fillColor();
return (
<div
class="gauge"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
<svg viewBox="0 0 100 80" class="gauge-svg">
<path d={bgPath} fill="none" stroke="#2d3748" stroke-width="6" stroke-linecap="round" />
{fillPath && (
<path d={fillPath} fill="none" stroke={fc} stroke-width="6" stroke-linecap="round" />
)}
<circle cx={needlePos.x} cy={needlePos.y} r="3" fill={fc} />
<text x={cx} y={cy + 10} text-anchor="middle" class="gauge-value">{displayValue()}</text>
{unit && <text x={cx} y={cy + 20} text-anchor="middle" class="gauge-unit">{unit}</text>}
<text x="12" y="76" text-anchor="middle" class="gauge-range">{minVal}</text>
<text x="88" y="76" text-anchor="middle" class="gauge-range">{maxVal}</text>
</svg>
{label && <div class="gauge-label">{label}</div>}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { h } from 'preact';
import type { Widget } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function ImageWidget({ widget, onContextMenu }: Props) {
const url = widget.options['url'] ?? '';
const fit = widget.options['fit'] ?? 'contain'; // contain | cover | fill
const alt = widget.options['alt'] ?? '';
const border = widget.options['border'] !== 'false';
return (
<div
class="image-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;${border ? '' : 'border:none;'}`}
onContextMenu={onContextMenu}
>
{url
? <img src={url} alt={alt} style={`object-fit:${fit};width:100%;height:100%;display:block;`} />
: <span class="image-placeholder">No URL</span>
}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore } from '../lib/stores';
import type { Widget, SignalValue } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function evaluateCondition(expr: string, v: any): boolean {
if (/backtick|`|import|fetch|eval|Function|window|document/.test(expr)) return false;
try {
// eslint-disable-next-line no-new-func
return new Function('value', 'return ' + expr)(v);
} catch {
return false;
}
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function Led({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
useEffect(() => {
if (!sigRef) return;
const unsub = getSignalStore(sigRef).subscribe(setSv);
return unsub;
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const condition = widget.options['condition'] ?? 'value > 0';
const colorTrue = widget.options['colorTrue'] ?? '#22c55e';
const colorFalse = widget.options['colorFalse'] ?? '#ef4444';
const quality = sv.quality;
const isUncertain = quality === 'uncertain' || quality === 'unknown';
const ledOn = sv.value !== null && sv.value !== undefined ? evaluateCondition(condition, sv.value) : false;
const ledColor = ledOn ? colorTrue : colorFalse;
return (
<div
class="led-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<div
class={`led-circle${isUncertain ? ' blink' : ''}`}
style={`background:${ledColor};box-shadow:0 0 8px ${ledColor}88;`}
/>
{label && <div class="led-label">{label}</div>}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { h } from 'preact';
import type { Widget } from '../lib/types';
interface Props {
widget: Widget;
onContextMenu?: (e: MouseEvent) => void;
onNavigate?: (interfaceId: string) => void;
}
export default function LinkWidget({ widget, onContextMenu, onNavigate }: Props) {
const target = widget.options['target'] ?? '';
const label = widget.options['label'] ?? target ?? 'Open';
const icon = widget.options['icon'] ?? '↗';
function handleClick(e: MouseEvent) {
e.preventDefault();
if (target && onNavigate) onNavigate(target);
}
return (
<div
class="link-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<button class="link-btn" onClick={handleClick} title={`Navigate to: ${target}`}>
<span class="link-icon">{icon}</span>
<span class="link-label">{label}</span>
</button>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore } from '../lib/stores';
import type { Widget, SignalValue } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function getBit(val: number, bit: number): boolean {
return (val & (1 << bit)) !== 0;
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function MultiLed({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
useEffect(() => {
if (!sigRef) return;
const unsub = getSignalStore(sigRef).subscribe(setSv);
return unsub;
}, [sigRef?.ds, sigRef?.name]);
const bits = parseInt(widget.options['bits'] ?? '8', 10);
const labelsRaw = widget.options['labels'] ?? '';
const colorsTrueRaw = widget.options['colorsTrue'] ?? '';
const colorsFalseRaw = widget.options['colorsFalse'] ?? '';
const labelArr = labelsRaw ? labelsRaw.split(',') : [];
const colorsTrueArr = colorsTrueRaw ? colorsTrueRaw.split(',') : [];
const colorsFalseArr = colorsFalseRaw ? colorsFalseRaw.split(',') : [];
const quality = sv.quality;
const isUncertain = quality === 'uncertain' || quality === 'unknown';
const rawV = sv.value;
const intValue = rawV === null || rawV === undefined ? 0
: typeof rawV === 'number' ? Math.floor(rawV) : parseInt(String(rawV), 10);
const bitStates = Array.from({ length: bits }, (_, i) => getBit(intValue, i));
return (
<div
class="multiled-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{bitStates.map((on, i) => {
const trueColor = colorsTrueArr[i] ?? '#22c55e';
const falseColor = colorsFalseArr[i] ?? '#ef4444';
const color = on ? trueColor : falseColor;
const bitLabel = labelArr[i] ?? String(i);
return (
<div key={i} class="bit-item">
<div
class={`led-circle${isUncertain ? ' blink' : ''}`}
style={`background:${color};box-shadow:0 0 6px ${color}88;`}
/>
<div class="bit-label">{bitLabel}</div>
</div>
);
})}
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import uPlot from 'uplot';
import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores';
import type { Widget, SignalRef } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
interface SeriesBuffer {
timestamps: number[];
values: number[];
}
const RING_MAX = 4000;
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
const ECHARTS_DARK = {
backgroundColor: '#1a1f2e',
textStyle: { color: '#94a3b8' },
axisLineStyle: { color: '#475569' },
axisLabelStyle: { color: '#64748b' },
splitLineStyle: { color: '#2d3748' },
};
function pushSample(buf: SeriesBuffer, ts: number, val: number) {
buf.timestamps.push(ts);
buf.values.push(val);
if (buf.timestamps.length > RING_MAX) {
buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
buf.values.splice(0, buf.values.length - RING_MAX);
}
}
function windowedSlice(buf: SeriesBuffer, windowSec: number) {
const cutoff = Date.now() / 1000 - windowSec;
const start = buf.timestamps.findIndex(t => t >= cutoff);
if (start === -1) return { ts: [] as number[], vals: [] as number[] };
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
}
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
const BUCKETS = 20;
let lo = Infinity, hi = -Infinity;
for (const vals of allVals) for (const v of vals) { if (v < lo) lo = v; if (v > hi) hi = v; }
if (!isFinite(lo) || lo === hi) return { labels: [], counts: allVals.map(() => []) };
const size = (hi - lo) / BUCKETS;
const counts = allVals.map(vals => {
const bins = new Array<number>(BUCKETS).fill(0);
for (const v of vals) bins[Math.min(BUCKETS - 1, Math.floor((v - lo) / size))]++;
return bins;
});
const labels = Array.from({ length: BUCKETS }, (_, i) => (lo + i * size).toPrecision(3));
return { labels, counts };
}
export default function PlotWidget({ widget, onContextMenu }: Props) {
const outerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!chartRef.current) return;
const signals = widget.signals;
const plotType = widget.options['plotType'] ?? 'timeseries';
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
const yMin = widget.options['yMin'];
const yMax = widget.options['yMax'];
const legendPos = widget.options['legend'] ?? 'bottom';
const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
const histValues: number[][] = signals.map(() => []);
const unsubs: (() => void)[] = [];
// ── uPlot (timeseries) ──────────────────────────────────────────────────
let uplot: uPlot | null = null;
function uplotData(): uPlot.AlignedData {
if (signals.length === 0) return [[]];
const allTs = new Set<number>();
buffers.forEach(b => windowedSlice(b, timeWindow).ts.forEach(t => allTs.add(t)));
const tsSorted = Array.from(allTs).sort((a, b) => a - b);
if (tsSorted.length === 0) {
// Return proper empty structure: [timestamps, ...series]
return [[], ...signals.map(() => [])] as uPlot.AlignedData;
}
const tsMap = buffers.map(b => {
const m = new Map<number, number>();
const { ts, vals } = windowedSlice(b, timeWindow);
ts.forEach((t, i) => m.set(t, vals[i]));
return m;
});
return [
tsSorted,
...tsMap.map(m => tsSorted.map(t => m.get(t) ?? null)),
] as uPlot.AlignedData;
}
// ── ECharts ────────────────────────────────────────────────────────────
let echart: echarts.EChartsType | null = null;
function echartsOption(): any {
const axisLine = { lineStyle: { color: '#475569' } };
const axisLabel = { color: '#64748b' };
const splitLine = { lineStyle: { color: '#2d3748' } };
const legendOpt = showLegend
? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } }
: undefined;
switch (plotType) {
case 'histogram': {
const { labels, counts } = buildHistogram(histValues);
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
series: counts.map((d, i) => ({
name: signals[i]?.name ?? `S${i}`,
type: 'bar',
data: d,
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
})),
};
}
case 'bar': {
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
series: [{
type: 'bar',
data: buffers.map((b, i) => ({
value: b.values.length ? b.values[b.values.length - 1] : 0,
itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
})),
}],
};
}
case 'fft': {
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel },
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
series: buffers.map((b, i) => ({
name: signals[i]?.name ?? `S${i}`,
type: 'line',
data: b.values.length ? b.values[b.values.length - 1] : [],
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
})),
};
}
case 'logic': {
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel },
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine },
series: buffers.map((b, i) => {
const { ts, vals } = windowedSlice(b, timeWindow);
return {
name: signals[i]?.name ?? `S${i}`,
type: 'line', step: 'end',
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
};
}),
};
}
case 'waterfall': {
const ROWS = 32;
const b = buffers[0];
const step = Math.max(1, Math.floor(b.values.length / ROWS));
const flatData: [number, number, number][] = [];
for (let ri = 0; ri < ROWS; ri++) {
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step;
if (idx < 0) continue;
const row = b.values[idx];
const arr = Array.isArray(row) ? row : [row];
arr.forEach((val, ci) => flatData.push([ci, ri, val]));
}
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
xAxis: { type: 'value', axisLabel },
yAxis: { type: 'value', axisLabel },
series: [{ type: 'heatmap', data: flatData }],
};
}
default:
return { backgroundColor: ECHARTS_DARK.backgroundColor };
}
}
// ── Init chart ─────────────────────────────────────────────────────────
const el = chartRef.current;
const w = el.clientWidth || widget.w;
const h = el.clientHeight || widget.h;
if (plotType === 'timeseries') {
const seriesConf: uPlot.Series[] = [
{},
...signals.map((s, i) => ({
label: s.name,
stroke: s.color ?? COLORS[i % COLORS.length],
width: 1.5,
})),
];
const scaleY: uPlot.Scale = {};
if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);
uplot = new uPlot(
{
width: w,
height: h,
series: seriesConf,
legend: { show: showLegend },
axes: [
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
{ stroke: '#64748b', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
],
scales: { x: { time: true }, y: scaleY },
},
uplotData(),
el,
);
} else {
echart = echarts.init(el);
echart.setOption(echartsOption());
}
// ── Subscribe to signals ───────────────────────────────────────────────
signals.forEach((sig: SignalRef & { color?: string }, i) => {
const unsub = getSignalStore(sig).subscribe(sv => {
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return;
pushSample(buffers[i], ts, v);
if (plotType === 'histogram') histValues[i].push(v);
if (uplot) uplot.setData(uplotData());
else if (echart) echart.setOption(echartsOption(), { notMerge: true });
});
unsubs.push(unsub);
});
// ── Resize ─────────────────────────────────────────────────────────────
const ro = new ResizeObserver(() => {
if (!chartRef.current) return;
const nw = chartRef.current.clientWidth;
const nh = chartRef.current.clientHeight;
if (nw > 0 && nh > 0) {
if (uplot) uplot.setSize({ width: nw, height: nh });
if (echart) echart.resize();
}
});
if (outerRef.current) ro.observe(outerRef.current);
return () => {
ro.disconnect();
unsubs.forEach(u => u());
if (uplot) uplot.destroy();
if (echart) echart.dispose();
};
}, [widget.id]);
return (
<div
class="plot-widget"
ref={outerRef}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function SetValue({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
const [inputValue, setInputValue] = useState('');
useEffect(() => {
if (!sigRef) return;
const unsubV = getSignalStore(sigRef).subscribe(setSv);
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const confirm = widget.options['confirm'] === 'true';
const isNumeric = meta ? meta.type !== 'TypeString' : true;
const quality = sv.quality;
function displayValue(): string {
const v = sv.value;
if (v === null || v === undefined) return '---';
if (typeof v === 'number') {
return Number.isFinite(v) ? v.toPrecision(4).replace(/\.?0+$/, '') : String(v);
}
return String(v);
}
function handleSet() {
if (!sigRef) return;
const raw = inputValue.trim();
if (!raw) return;
const doWrite = () => {
const val = isNumeric ? parseFloat(raw) : raw;
wsClient.write(sigRef, val);
setInputValue('');
};
if (confirm) {
if (window.confirm(`Set ${label} to ${raw}?`)) doWrite();
} else {
doWrite();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleSet();
}
return (
<div
class="setvalue"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
<span class="sv-label">{label}:</span>
<input
class="sv-input"
type={isNumeric ? 'number' : 'text'}
value={inputValue}
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
onKeyDown={handleKeydown}
placeholder="value"
/>
<span class="sv-current">{displayValue()}</span>
{unit && <span class="sv-unit">{unit}</span>}
<button class="sv-btn" onClick={handleSet}>Set</button>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { h } from 'preact';
import type { Widget } from '../lib/types';
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function TextLabel({ widget, onContextMenu }: Props) {
const text = widget.options['text'] ?? '';
const fontSize = widget.options['fontSize'] ?? '1rem';
const color = widget.options['color'] ?? '#e2e8f0';
const align = widget.options['align'] ?? 'left';
return (
<div
class="textlabel"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;font-size:${fontSize};color:${color};text-align:${align};`}
onContextMenu={onContextMenu}
>
{text}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function TextView({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsubV = getSignalStore(sigRef).subscribe(setSv);
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] ?? sigRef?.name ?? '';
const unit = widget.options['unit'] ?? meta?.unit ?? '';
const quality = sv.quality;
function displayValue(): string {
if (!sv || sv.value === null || sv.value === undefined) return '---';
const v = sv.value;
if (typeof v === 'number') {
return Number.isFinite(v) ? v.toPrecision(6).replace(/\.?0+$/, '') : String(v);
}
return String(v);
}
return (
<div
class="textview"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
<span class="label">{label}:</span>
<span class="value">{displayValue()}</span>
{unit && <span class="unit">{unit}</span>}
</div>
);
}
-2
View File
@@ -1,2 +0,0 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}
-20
View File
@@ -1,20 +0,0 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"module": "esnext",
"types": ["svelte", "vite/client"],
"noEmit": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}
+2 -5
View File
@@ -1,7 +1,4 @@
{ {
"files": [], "extends": "./src/tsconfig.json",
"references": [ "include": ["src/**/*.ts", "src/**/*.tsx", "src/preact.d.ts"]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+45
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
import type { Ref, Context, ComponentChild } from './preact';
export type StateUpdater<S> = S | ((prevState: S) => S);
export function useState<S>(initialState: S | (() => S)): [S, (update: StateUpdater<S>) => void];
export function useReducer<S, A>(reducer: (state: S, action: A) => S, initialState: S): [S, (action: A) => void];
export function useEffect(effect: () => void | (() => void), inputs?: readonly unknown[]): void;
export function useLayoutEffect(effect: () => void | (() => void), inputs?: readonly unknown[]): void;
export function useRef<T>(initialValue: T): { current: T };
export function useRef<T>(initialValue: T | null): Ref<T>;
export function useRef<T = undefined>(): Ref<T | undefined>;
export function useMemo<T>(factory: () => T, inputs: readonly unknown[]): T;
export function useCallback<T extends (...args: any[]) => any>(callback: T, inputs: readonly unknown[]): T;
export function useContext<T>(context: Context<T>): T;
+2
View File
@@ -0,0 +1,2 @@
import{options as n}from"preact";var t,r,u,i,o=0,f=[],c=n,e=c.__b,a=c.__r,v=c.diffed,l=c.__c,m=c.unmount,s=c.__;function d(n,t){c.__h&&c.__h(r,n,o||t),o=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function h(n){return o=1,p(D,n)}function p(n,u,i){var o=d(t++,2);if(o.t=n,!o.__c&&(o.__=[i?i(u):D(void 0,u),function(n){var t=o.__N?o.__N[0]:o.__[0],r=o.t(t,n);t!==r&&(o.__N=[r,o.__[1]],o.__c.setState({}))}],o.__c=r,!r.u)){var f=function(n,t,r){if(!o.__c.__H)return!0;var u=o.__c.__H.__.filter(function(n){return!!n.__c});if(u.every(function(n){return!n.__N}))return!c||c.call(this,n,t,r);var i=!1;return u.forEach(function(n){if(n.__N){var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=!0)}}),!(!i&&o.__c.props===n)&&(!c||c.call(this,n,t,r))};r.u=!0;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,r){if(this.__e){var u=c;c=void 0,f(n,t,r),c=u}e&&e.call(this,n,t,r)},r.shouldComponentUpdate=f}return o.__N||o.__}function y(n,u){var i=d(t++,3);!c.__s&&C(i.__H,u)&&(i.__=n,i.i=u,r.__H.__h.push(i))}function _(n,u){var i=d(t++,4);!c.__s&&C(i.__H,u)&&(i.__=n,i.i=u,r.__h.push(i))}function A(n){return o=5,T(function(){return{current:n}},[])}function F(n,t,r){o=6,_(function(){return"function"==typeof n?(n(t()),function(){return n(null)}):n?(n.current=t(),function(){return n.current=null}):void 0},null==r?r:r.concat(n))}function T(n,r){var u=d(t++,7);return C(u.__H,r)&&(u.__=n(),u.__H=r,u.__h=n),u.__}function q(n,t){return o=8,T(function(){return n},t)}function x(n){var u=r.context[n.__c],i=d(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function P(n,t){c.useDebugValue&&c.useDebugValue(t?t(n):n)}function b(n){var u=d(t++,10),i=h();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function g(){var n=d(t++,11);if(!n.__){for(var u=r.__v;null!==u&&!u.__m&&null!==u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function j(){for(var n;n=f.shift();)if(n.__P&&n.__H)try{n.__H.__h.forEach(z),n.__H.__h.forEach(B),n.__H.__h=[]}catch(t){n.__H.__h=[],c.__e(t,n.__v)}}c.__b=function(n){r=null,e&&e(n)},c.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),s&&s(n,t)},c.__r=function(n){a&&a(n),t=0;var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.forEach(function(n){n.__N&&(n.__=n.__N),n.i=n.__N=void 0})):(i.__h.forEach(z),i.__h.forEach(B),i.__h=[],t=0)),u=r},c.diffed=function(n){v&&v(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==f.push(t)&&i===c.requestAnimationFrame||((i=c.requestAnimationFrame)||w)(j)),t.__H.__.forEach(function(n){n.i&&(n.__H=n.i),n.i=void 0})),u=r=null},c.__c=function(n,t){t.some(function(n){try{n.__h.forEach(z),n.__h=n.__h.filter(function(n){return!n.__||B(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],c.__e(r,n.__v)}}),l&&l(n,t)},c.unmount=function(n){m&&m(n);var t,r=n.__c;r&&r.__H&&(r.__H.__.forEach(function(n){try{z(n)}catch(n){t=n}}),r.__H=void 0,t&&c.__e(t,r.__v))};var k="function"==typeof requestAnimationFrame;function w(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,100);k&&(t=requestAnimationFrame(r))}function z(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function B(n){var t=r;n.__c=n.__(),r=t}function C(n,t){return!n||n.length!==t.length||t.some(function(t,r){return t!==n[r]})}function D(n,t){return"function"==typeof t?t(n):t}export{q as useCallback,x as useContext,P as useDebugValue,y as useEffect,b as useErrorBoundary,g as useId,F as useImperativeHandle,_ as useLayoutEffect,T as useMemo,p as useReducer,A as useRef,h as useState};
//# sourceMappingURL=hooks.module.js.map
+22
View File
@@ -0,0 +1,22 @@
export type Key = string | number;
export interface Ref<T> { current: T | null; }
export type ComponentChild = VNode | string | number | bigint | boolean | null | undefined;
export type ComponentChildren = ComponentChild | ComponentChild[];
export interface Attributes { key?: Key; ref?: Ref<any>; children?: ComponentChildren; }
export type VNode<P = {}> = { type: any; props: P & { children?: ComponentChildren }; key: Key | null; };
export type ComponentType<P = {}> = (props: P & Attributes) => VNode | null;
export type Context<T> = { Provider: ComponentType<{ value: T; children?: ComponentChildren }>; Consumer: ComponentType<{ children: (value: T) => ComponentChild }> };
export function h(type: any, props: any, ...children: any[]): VNode;
export const Fragment: unique symbol;
export function render(vnode: ComponentChild, parent: Element | ShadowRoot): void;
export function cloneElement(vnode: VNode, props?: any, ...children: any[]): VNode;
export function createContext<T>(defaultValue: T): Context<T>;
export namespace JSX {
type Element = VNode;
interface ElementClass { render(): ComponentChild; }
interface ElementAttributesProperty { props: {}; }
interface ElementChildrenAttribute { children: {}; }
type IntrinsicElements = { [K in keyof HTMLElementTagNameMap]: any } & { [K in keyof SVGElementTagNameMap]: any } & { [key: string]: any };
}
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
+6140
View File
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/healthz': 'http://localhost:8080',
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
})
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface id="demo" name="Demo Panel" version="1" width="1200" height="800">
<!-- Text view widgets -->
<widget type="textview" id="tv1" x="20" y="20" w="200" h="60">
<signal ds="stub" name="sine_1hz"/>
<option key="label" value="Sine 1 Hz"/>
</widget>
<widget type="textview" id="tv2" x="240" y="20" w="200" h="60">
<signal ds="stub" name="ramp_10s"/>
<option key="label" value="Ramp 10s"/>
</widget>
<!-- Gauge widgets -->
<widget type="gauge" id="g1" x="20" y="100" w="160" h="160">
<signal ds="stub" name="sine_1hz"/>
<option key="label" value="Sine 1Hz"/>
<option key="min" value="-1"/>
<option key="max" value="1"/>
</widget>
<widget type="gauge" id="g2" x="200" y="100" w="160" h="160">
<signal ds="stub" name="ramp_10s"/>
<option key="label" value="Ramp"/>
<option key="min" value="0"/>
<option key="max" value="100"/>
</widget>
<!-- Bar widgets -->
<widget type="barh" id="bh1" x="20" y="280" w="200" h="40">
<signal ds="stub" name="ramp_10s"/>
<option key="label" value="Ramp H"/>
<option key="min" value="0"/>
<option key="max" value="100"/>
</widget>
<widget type="barv" id="bv1" x="240" y="280" w="40" h="160">
<signal ds="stub" name="sine_1hz"/>
<option key="label" value="Sine V"/>
<option key="min" value="-1"/>
<option key="max" value="1"/>
</widget>
<!-- LED -->
<widget type="led" id="led1" x="300" y="280" w="60" h="60">
<signal ds="stub" name="toggle_1hz"/>
<option key="label" value="Toggle"/>
<option key="condition" value="value > 0"/>
</widget>
<!-- Set value -->
<widget type="setvalue" id="sv1" x="20" y="460" w="200" h="60">
<signal ds="stub" name="setpoint"/>
<option key="label" value="Setpoint"/>
</widget>
<!-- Button -->
<widget type="button" id="btn1" x="240" y="460" w="120" h="60">
<signal ds="stub" name="setpoint"/>
<option key="label" value="Reset SP"/>
<option key="value" value="25"/>
</widget>
<!-- Plot timeseries -->
<widget type="plot" id="plot1" x="380" y="20" w="400" h="200">
<signal ds="stub" name="sine_1hz"/>
<signal ds="stub" name="sine_01hz"/>
<option key="plotType" value="timeseries"/>
<option key="timeWindow" value="30"/>
<option key="label" value="Sine signals"/>
</widget>
<!-- Plot histogram -->
<widget type="plot" id="plot2" x="380" y="240" w="400" h="200">
<signal ds="stub" name="noise"/>
<option key="plotType" value="histogram"/>
<option key="label" value="Noise histogram"/>
</widget>
<!-- Text label -->
<widget type="textlabel" id="lbl1" x="800" y="20" w="200" h="40">
<option key="text" value="System Status"/>
</widget>
<!-- Multi LED -->
<widget type="multiled" id="ml1" x="800" y="80" w="200" h="120">
<signal ds="stub" name="toggle_1hz"/>
<option key="label" value="Bits"/>
<option key="bits" value="4"/>
</widget>
</interface>