Compare commits

..

54 Commits

Author SHA1 Message Date
Martino Ferrari 336095c052 plan updated 2026-06-26 14:08:28 +02:00
Martino Ferrari c53a49e540 controllogic: fix data race in setPath nested-array assignment
setPath mutated nested sub-arrays in place; since graph-local slices (and
their nested sub-slices) may be read concurrently by other flow goroutines,
an action.array.set with a multi-level path raced readers of the same local.
The single-threaded TS source mutates in place safely, but the Go port runs
flows on concurrent goroutines, so setPath now copies on descent (every level
returns a freshly allocated slice). Adds a -race regression test that
concurrently drives nested set + inner-element reads, plus a nested-set
correctness test asserting the prior stored value is not mutated.

Found by whole-branch review (Opus); missed by the per-task reviews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 20:11:14 +02:00
Martino Ferrari b6bc9dc2f2 docs: control-logic array+scalar locals (Value model, statevars, array nodes) 2026-06-24 20:02:55 +02:00
Martino Ferrari 3133e50e09 controllogic: drop sign alias shim + add array node tests (set/remove/pop) 2026-06-24 20:02:52 +02:00
Martino Ferrari 550ec06bc8 ControlLogicEditor: local-var declarations + array action nodes 2026-06-24 19:56:28 +02:00
Martino Ferrari 9c1f2685a7 controllogic: debug value is Value (array-capable); lua get narrows to scalar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 19:50:47 +02:00
Martino Ferrari 088063d9cd controllogic: add action.array.* nodes (push/set/remove/pop/clear) 2026-06-24 19:45:40 +02:00
Martino Ferrari a6fa4e7c7c controllogic: locals as Value, init from declarations, value-aware write
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 19:40:29 +02:00
Martino Ferrari 519c1f2df4 controllogic: make expr evaluator value-polymorphic (arrays, indexing, array funcs)
Rewrites internal/controllogic/expr.go to evaluate to Value (scalar float64
or []Value array): adds array literals [a,b], postfix indexing arr[i], and
array functions (len, sum, mean, push, pop, slice, concat, sort, …). Resolver
type changes from func(...) float64 to func(...) Value; EvalValue added;
EvalExpr/EvalBool retain scalar float64/bool returns. Three temporary shims
added in engine.go, lua.go, and expr_test.go pending Tasks 4 and 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:53:40 +02:00
Martino Ferrari 3ddffc14d7 controllogic: add Graph.StateVars declarations (persisted via store JSON)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:46:01 +02:00
Martino Ferrari e76865d132 controllogic: add Value union + sizing helpers (port of arraypolicy.ts) 2026-06-24 14:42:40 +02:00
Martino Ferrari 5012511306 docs: plan for control-logic array+scalar local variables (Phase 2)
Implementation plan to port the panel-logic declared-local-variable feature
(scalar + array, sizing policies) to the server-side control-logic engine and
its editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 14:40:35 +02:00
Martino Ferrari 44c8f98e01 Remove legacy array logic nodes; migrate + dropdown-ify
Delete the action.accumulate / action.clear node kinds (superseded by the
typed action.array.* nodes). Old panels are migrated transparently on load
(xml.ts migrateLegacyArrayNode): accumulate→array.push, clear→array.clear,
and legacy single-`array` export folded into the JSON `columns` form.

Export CSV column pickers now use array-local dropdowns instead of free-text
+ datalist, matching the action.array.* node inspectors. Removes the now-dead
flow-array-names datalist and legacy single-`array` fallbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 14:21:11 +02:00
Martino Ferrari 05b06d64a4 feat(editor): suggest declared array locals in array-name autocomplete
The flow-array-names datalist (used by the export-CSV node columns and the
legacy accumulate/clear nodes) was sourced only from array names already
referenced by other nodes, so a freshly declared array local would not appear
as an autocomplete suggestion until it was typed somewhere. Seed the datalist
with declared array statevars first, then node-referenced names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 14:08:11 +02:00
Martino Ferrari cb4e81beb2 fix(editor): array local-variable support in signal-tree pane
The signal-tree pane (edit mode) has its own inline "add local variable"
form, separate from the Logic-tab LocalVars form that gained array support
in the array-locals feature. This second entry point was missed, so the
type dropdown there only offered number/bool/string. Mirror the LogicEditor
form: add the array type option plus elem/sizing/capacity selects and a
JSON-validated initial value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:59:18 +02:00
Martino Ferrari 9d9e538a90 fix(logic): subscribe signals referenced by array action node params
subscribeRefs() walked node kinds to build the signal subscription set but
omitted the new action.array.push/set/remove nodes. Signal or local-var
references in their expr/index params were never subscribed, so the live
cache held no value and they resolved to NaN at run time (action.accumulate
was handled, its modern equivalent action.array.push was not). Add the
missing cases, including the comma-split index list for array.set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:50:26 +02:00
Martino Ferrari 9d48292976 docs(logic): document panel-logic array locals
Document array-valued local variables (statevar type=array with
elem/sizing/capacity), the value-polymorphic expression engine and array
functions, the array.* mutation nodes, accumulate/export unification, and
index-aligned CSV export in TECHNICAL_SPEC §3.8. Note in TODO that panel-logic
(Phase 1) array support is complete while the server-side control-logic port
(Phase 2) remains pending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:31:15 +02:00
Martino Ferrari 91661485ae feat(widgets): array source modes for plot, table, multi-LED
- PlotWidget: 1-D array values (local vars + EPICS waveforms) are
  routed through the waveform rendering path regardless of configured
  plotType; nested 2-D arrays are skipped gracefully.  effectivePlotType
  switches to 'waveform' whenever any waveforms[] slot is populated.
- TableWidget: new sourceMode:'array' option renders one <tr> per array
  element (index in 'name' col, formatted value in 'value' col).  For
  2-D (elem:'array') rows, colIndices option maps column positions to
  inner-array positions.  Scalar/multi-signal mode unchanged.
- MultiLed: new sourceMode:'array' option (or auto-detect when value is
  already an array) renders one LED per element with live-length tracking;
  elem truthiness controls lit state; per-element label/color overrides
  supported; meta.elem==='bool' awareness noted in code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:22:20 +02:00
Martino Ferrari 603574f86f feat(logic): array local declaration form + array nodes + array debug badges
- LocalVars: add array type option with elem/sizing/capacity sub-fields
  and JSON literal initial value with inline parse validation
- Palette + KIND_LABEL: register action.array.push/set/remove/pop/clear
- Inspector: array-node cases with array-local select, index and expr fields
- nodeLabel: compact summaries for all five array node kinds
- flowDebug: export fmtBadge() for compact array display [a,b,c,…](n=N);
  formatBadge now delegates to fmtBadge for numbers and arrays
- EditMode: wrap both initLocalState calls with ensureArrayDecls so
  array locals referenced only by logic nodes are auto-declared in preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:18:00 +02:00
Martino Ferrari b82e8852b3 feat(logic): round-trip array statevar attributes in panel XML
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:12:05 +02:00
Martino Ferrari 8f50bc2498 feat(logic): array action nodes + accumulate/export unification + migration
Add five action.array.push|set|remove|pop|clear node kinds; replace the
legacy {t,v} in-memory arrays store with array locals as single source of
truth; alias action.accumulate→push and action.clear→array.clear for
backward compat; rewrite action.export as index-aligned CSV with custom
header labels; add ensureArrayDecls() auto-declare migration wired via
Canvas.tsx so undeclared arrays referenced by logic nodes are initialised.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:07:24 +02:00
Martino Ferrari 062bb44dba feat(logic): array local init + sizing enforcement in localstate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:01:32 +02:00
Martino Ferrari f776de378f fix(logic): single-eval array index + typed reduce accumulators
- Fix redundant double-evaluation of n.a in the 'index' case by extracting
  to a local const before use in both idx() call and array indexing.
- Add explicit <number> type argument to reduce() calls in sum and mean
  functions to fix TypeScript TS2345 accumulator type error: the inferred
  ArrVal union type was not compatible with numeric addition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:57:45 +02:00
Martino Ferrari 5578bceea2 feat(logic): array-aware expression engine (literals, indexing, array funcs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:54:43 +02:00
Martino Ferrari 2f40a9d1a0 feat(logic): add array sizing-policy helpers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:51:28 +02:00
Martino Ferrari 3b8c6540e1 feat(logic): add array fields to StateVar type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:50:06 +02:00
Martino Ferrari 774e28453b Add Phase 1 (panel-logic TS) implementation plan for local arrays
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:40:36 +02:00
Martino Ferrari e4e67ee0c2 Add design spec for local array values in flow engines
Approved design for first-class array-valued local variables across the
panel-logic (TS) and control-logic (Go) engines: array StateVar declarations
(dynamic/capped/fixed sizing), an array-aware expression language shared by
both engines, sizing-policy-aware mutation nodes (unifying the existing
accumulate/export/clear arrays), persistence, and widget binding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:32:11 +02:00
Martino Ferrari cf9da3df0a Implemented new datasources (modbus,scpi) 2026-06-24 06:12:52 +02:00
Martino Ferrari 999a1510d4 Ignore build/test output and workspace runtime state
Add .gitignore rules for SQLite WAL/SHM sidecars, coverage output, and
the dev workspace/data storage dir (keeping the demo.xml/epics_test.xml
fixtures), completing the runtime-artifact cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 01:48:07 +02:00
Martino Ferrari 6f7c90cc98 Stop tracking dev workspace runtime artifacts
Untrack generated runtime state under workspace/data (SQLite audit db +
WAL/SHM, config/control-logic/synthetic versions, trash, acl) plus dev
logs and iocsh history; these are regenerated on each run. Curated
fixtures (demo.xml, epics_test.xml, the EPICS test IOC db, run.sh,
st.cmd) stay tracked. Extend .gitignore so they don't return.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 01:47:43 +02:00
Martino Ferrari c0f7e662be Testing 2026-06-24 01:39:15 +02:00
Martino Ferrari 11120bedca Added ldap and pam authentication 2026-06-23 17:59:40 +02:00
Martino Ferrari ac24011487 Implemented admin pane + user permission 2026-06-22 17:49:14 +02:00
Martino Ferrari 73fcbe7b28 Improved plot view 2026-06-22 00:30:15 +02:00
Martino Ferrari 113e5a0fe8 Implemented confi snapshots 2026-06-21 23:50:03 +02:00
Martino Ferrari 04d31a15c4 Done config 2026-06-21 17:40:04 +02:00
Martino Ferrari b0ac044035 Working on better ux and comnfig editro 2026-06-21 12:50:04 +02:00
Martino Ferrari 3fc7c1b546 initial config manager 2026-06-20 17:40:03 +02:00
Martino Ferrari 206d5b541d Expand TODO widgets item with concrete HMI widget examples
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-20 17:10:52 +02:00
Martino Ferrari f7f297c3df Add synthetic array (waveform) DSP support + UX improvements
Adds full array/waveform support through the synthetic DSP engine: a
dsp.Sample value model (scalar or []float64), array ops (index, slice,
sum, mean, min, max, length, fft) with an in-tree radix-2 FFT, and static
type propagation (OpOutputType) that the editor mirrors to colour wires by
data type and flag invalid wirings. Stateful filters and lua stay
scalar-only. Adds a waveform plot mode (x-vs-index trace).

Also: errored-node hover reasons, S/N add-signal/add-node HUD shortcuts in
the synthetic editor, and view-mode widgets that blend with the canvas
background (chrome kept in edit mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-20 17:06:55 +02:00
Martino Ferrari 446de7f1ee Improving all side of app 2026-06-20 14:28:28 +02:00
Martino Ferrari 901b87d407 Implementing more advanced feature: audit and more 2026-06-19 14:17:46 +02:00
Martino Ferrari 8f6dbcba49 Working on audit 2026-06-19 09:44:57 +02:00
Martino Ferrari 914108e575 Plot fix 2026-06-19 07:57:42 +02:00
Martino Ferrari ba836f00e6 Add widget-control logic action and multi-column CSV / multi-field dialogs
Extend the panel logic editor with two capabilities:

- action.widget: drive a panel widget by id from a flow — enable/disable,
  show/hide, and (for plot widgets) clear, pause, or resume the live plot.
  Wired via a per-widget command store consumed by a Canvas WidgetView wrapper
  (disable overlay / hide) and PlotWidget (pause/clear), reset on panel unmount.
- action.export now emits multiple named arrays as CSV columns with per-column
  labels and an alignment mode (common/any/interpolate); dialogs support asking
  for and displaying multiple values per dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:51:18 +02:00
Martino Ferrari afefba3184 Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers,
Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and
user-interaction dialog nodes, and a synthetic node-graph editor.

Add an optional logic-editor allowlist (server.logic_editors) gating who
may add/edit panel logic and control logic, surfaced via /api/v1/me and
enforced in the API; hide logic affordances in the UI accordingly.

Update README, example config, and functional/technical specs to cover
all current features (plot panels, panel/control logic, local variables,
access control) and refresh the in-app manual and contextual help.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:27:35 +02:00
Martino Ferrari aba394b84d Major changes: logic add to panel, local variables, panel histor, users management... 2026-06-18 17:37:04 +02:00
Martino Ferrari 71430bc3b0 Wworking on improving the tool 2026-05-21 07:41:56 +02:00
Martino Ferrari 6ff8fb5c25 Improved perfs 2026-05-12 10:16:48 +02:00
Martino Ferrari 912ecdd9ed Improved UI 2026-05-06 15:55:45 +02:00
Martino Ferrari 0a5a85e4c4 working epics ioc and tested 2026-05-04 21:13:36 +02:00
Martino Ferrari 90669c5fd6 Initial working fully go release 2026-04-30 23:01:01 +02:00
Martino Ferrari 6e51ffc5e1 Initial go port of epics 2026-04-28 00:09:22 +02:00
245 changed files with 55566 additions and 1575 deletions
+20
View File
@@ -5,3 +5,23 @@ interfaces/
synthetic.json
uopi.toml
*.local.toml
resources
.claude
.github
.goreleaser.yaml
.iocsh_history
go.work.sum
*.log
# Test output
coverage.out
*.coverprofile
# SQLite runtime databases (sidecar WAL/SHM)
*.db-shm
*.db-wal
# uopi dev runtime storage — ignore generated state, keep curated fixtures
workspace/data/*
!workspace/data/demo.xml
!workspace/data/epics_test.xml
+44 -105
View File
@@ -1,4 +1,4 @@
.PHONY: all frontend backend backend-epics backend-debug release release-epics test bench race lint clean run
.PHONY: all frontend backend backend-debug backend-pam release catools test cover bench race lint fmt clean run
# --------------------------------------------------------------------------- #
# Sources — adding any file here triggers a frontend or backend rebuild #
@@ -8,7 +8,9 @@ FRONTEND_SRCS := $(shell find web/src -name '*.tsx' -o -name '*.ts' -o -name '*.
$(wildcard web/vendor/*.mjs web/vendor/*.js web/vendor/*.css) \
tools/buildfrontend/main.go
GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum
GO_SRCS := $(shell find cmd internal -name '*.go') \
$(shell find pkg/ca -name '*.go') \
web/embed.go go.mod go.sum go.work
# --------------------------------------------------------------------------- #
# Outputs #
@@ -16,16 +18,17 @@ GO_SRCS := $(shell find cmd internal -name '*.go') web/embed.go go.mod go.sum
FRONTEND_OUT := web/dist/main.js
BINARY := dist/uopi
CATOOLS := dist/catools
# --------------------------------------------------------------------------- #
# Top-level targets #
# --------------------------------------------------------------------------- #
all: $(BINARY)
all: $(BINARY) $(CATOOLS)
frontend: $(FRONTEND_OUT)
backend: $(BINARY)
backend: $(BINARY) $(CATOOLS)
# --------------------------------------------------------------------------- #
# Build rules #
@@ -36,127 +39,48 @@ $(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)
# Compile the self-contained binary (embeds web/dist at build time).
# Pure-Go CA client (pkg/ca) is always used; CGO_ENABLED=0 produces a fully
# static binary with no external library dependencies.
$(BINARY): $(GO_SRCS) $(FRONTEND_OUT)
@mkdir -p dist
go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
# Build with EPICS Channel Access support.
#
# Auto-detection: if EPICS_BASE is set in the environment, CGO flags are
# derived automatically. Override any variable on the command line:
#
# make backend-epics EPICS_BASE=/path/to/epics/base EPICS_ARCH=linux-x86_64
#
# If EPICS_BASE is not set, set it to the directory that contains
# include/cadef.h and lib/<arch>/libca.so.
EPICS_BASE ?= $(EPICS_BASE)
# Detect host arch from EPICS startup script if not provided.
EPICS_ARCH ?= $(shell \
if [ -x "$(EPICS_BASE)/startup/EpicsHostArch" ]; then \
"$(EPICS_BASE)/startup/EpicsHostArch"; \
elif [ -x "$(EPICS_BASE)/lib" ]; then \
ls "$(EPICS_BASE)/lib" | grep linux | head -1; \
else \
echo "linux-x86_64"; \
fi)
EPICS_CGO_CFLAGS ?= -I$(EPICS_BASE)/include -I$(EPICS_BASE)/include/os/Linux -I$(EPICS_BASE)/include/compiler/gcc
EPICS_CGO_LDFLAGS ?= -L$(EPICS_BASE)/lib/$(EPICS_ARCH) -lca -lCom -Wl,-rpath,$(EPICS_BASE)/lib/$(EPICS_ARCH)
backend-epics: $(FRONTEND_OUT)
@if [ -z "$(EPICS_BASE)" ]; then \
echo "ERROR: EPICS_BASE is not set."; \
echo " export EPICS_BASE=/path/to/epics/base"; \
echo " then re-run: make backend-epics"; \
exit 1; \
fi
@echo "Building with EPICS support"
@echo " EPICS_BASE = $(EPICS_BASE)"
@echo " EPICS_ARCH = $(EPICS_ARCH)"
# CA diagnostic tools (caget/caput/cainfo/camonitor equivalents)
$(CATOOLS): $(GO_SRCS)
@mkdir -p dist
CGO_ENABLED=1 \
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
CGO_LDFLAGS="$(EPICS_CGO_LDFLAGS)" \
go build -tags epics -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
@echo "Built: $(BINARY) (EPICS CA enabled)"
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
# Unstripped binary for debugging
# PAM-enabled binary: adds built-in HTTP Basic authentication validated against
# the host PAM stack (server.basic_auth). Requires cgo + libpam (-dev headers),
# so the resulting binary is NOT fully static — use only when basic_auth is needed.
backend-pam: $(FRONTEND_OUT)
@mkdir -p dist
CGO_ENABLED=1 go build -tags pam -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
backend-debug: $(FRONTEND_OUT)
@mkdir -p dist
go build -o $(BINARY) ./cmd/uopi
CGO_ENABLED=0 go build -o $(BINARY) ./cmd/uopi
CGO_ENABLED=0 go build -o $(CATOOLS) ./cmd/catools
# --------------------------------------------------------------------------- #
# Portable release targets #
# Release #
# --------------------------------------------------------------------------- #
#
# Two flavours depending on whether EPICS is needed:
#
# make release — pure-Go, CGO_ENABLED=0, 100 % static binary.
# No EPICS; works anywhere without extra libs.
#
# make release-epics — CGO build with EPICS CA.
# Tries to link libca/libCom statically (if .a
# archives are present in $(EPICS_LIBDIR)).
# Falls back to bundled shared libs + $ORIGIN
# rpath when only .so files are available.
# The output directory (dist/release/) contains
# everything needed to run on the target machine.
#
# Cross-compilation (no-EPICS only): override RELEASE_OS / RELEASE_ARCH.
# make release RELEASE_OS=linux RELEASE_ARCH=arm64
# Fully static, pure-Go CA client, no external library dependencies.
# Cross-compile: make release RELEASE_OS=linux RELEASE_ARCH=arm64
RELEASE_OS ?= linux
RELEASE_ARCH ?= amd64
RELEASE_DIR := dist/release
EPICS_LIBDIR := $(EPICS_BASE)/lib/$(EPICS_ARCH)
# Fully static, no CGO — the most portable build possible.
release: $(FRONTEND_OUT)
@mkdir -p $(RELEASE_DIR)
CGO_ENABLED=0 GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) \
go build -ldflags="-s -w" -o $(RELEASE_DIR)/uopi ./cmd/uopi
@echo "Built: $(RELEASE_DIR)/uopi (fully static, no EPICS)"
# EPICS release — maximum static linking.
# Strategy A (preferred): if libca.a + libCom.a exist, embed them directly
# → single self-contained binary, no extra files needed.
# Strategy B (fallback): copy the shared libs next to the binary and set an
# $ORIGIN rpath so the OS finds them regardless of installation prefix.
release-epics: $(FRONTEND_OUT)
@if [ -z "$(EPICS_BASE)" ]; then \
echo "ERROR: EPICS_BASE is not set."; \
echo " export EPICS_BASE=/path/to/epics/base"; \
exit 1; \
fi
@mkdir -p $(RELEASE_DIR)
@echo "Building portable EPICS release"
@echo " EPICS_BASE = $(EPICS_BASE)"
@echo " EPICS_ARCH = $(EPICS_ARCH)"
@if [ -f "$(EPICS_LIBDIR)/libca.a" ] && [ -f "$(EPICS_LIBDIR)/libCom.a" ]; then \
echo " Strategy A: static .a archives found — embedding libca + libCom"; \
CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
CGO_LDFLAGS="-Wl,-Bstatic -L$(EPICS_LIBDIR) -lca -lCom -Wl,-Bdynamic -lm -lpthread -lrt -ldl" \
go build -tags epics \
-ldflags="-s -w -linkmode external" \
-o $(RELEASE_DIR)/uopi ./cmd/uopi; \
echo "Built: $(RELEASE_DIR)/uopi (EPICS statically embedded)"; \
else \
echo " Strategy B: no .a archives — bundling shared libs with \$$ORIGIN rpath"; \
CGO_ENABLED=1 GOOS=linux GOARCH=$(RELEASE_ARCH) \
CGO_CFLAGS="$(EPICS_CGO_CFLAGS)" \
CGO_LDFLAGS="-L$(EPICS_LIBDIR) -lca -lCom -Wl,-rpath,\$$ORIGIN" \
go build -tags epics \
-ldflags="-s -w" \
-o $(RELEASE_DIR)/uopi ./cmd/uopi; \
cp $(EPICS_LIBDIR)/libca.so* $(RELEASE_DIR)/; \
cp $(EPICS_LIBDIR)/libCom.so* $(RELEASE_DIR)/; \
echo "Built: $(RELEASE_DIR)/ (deploy the whole directory together)"; \
ls $(RELEASE_DIR)/; \
fi
@echo "Built: $(RELEASE_DIR)/uopi (static, pure-Go CA)"
# --------------------------------------------------------------------------- #
# Dev / CI #
@@ -164,10 +88,19 @@ release-epics: $(FRONTEND_OUT)
test:
go test ./...
cd pkg/ca && go test ./...
cd pkg/pva && go test ./...
# Run tests with the race detector enabled.
race:
go test -race ./...
cd pkg/ca && go test -race ./...
cd pkg/pva && go test -race ./...
# Run the main module's tests with coverage and print the total.
cover:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1
# Run all benchmarks and print memory allocations.
bench:
@@ -176,8 +109,14 @@ bench:
lint:
go vet ./...
# Rewrite all Go sources in canonical gofmt form (matches the CI gofmt gate).
fmt:
gofmt -w $(shell git ls-files '*.go')
run: $(BINARY)
$(BINARY)
catools: $(CATOOLS)
clean:
rm -rf dist/ web/dist/
+41 -4
View File
@@ -10,12 +10,19 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
| Feature | Details |
|---------|---------|
| **Live data** | EPICS Channel Access (CA) and user-defined synthetic signals |
| **Live data** | EPICS Channel Access (CA) / PVAccess and user-defined synthetic signals |
| **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML |
| **Widget library** | Text view, gauge, LED, bar, set-point control, button, plots |
| **Widget library** | Text view/label, gauge, LED, multi-LED, bar, set-point control, button, image, link, plots |
| **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser |
| **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport |
| **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode |
| **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks |
| **Configuration manager** | Versioned configuration **sets** (typed signal schemas, grouped) and **instances** (values) with validation, array/CSV editing, apply-to-signals, and JSON import/export |
| **Version history & diff** | Git-style history for panels, synthetic signals, control logic and config sets/instances: view, fork, promote any revision, with unified / side-by-side diff |
| **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic |
| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI |
| **Synthetic signals** | Compose, filter, and transform signals with a DSP pipeline (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula) |
| **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes |
| **Access control** | Identity via trusted proxy header; per-user global level (write/read-only/no-access); per-panel ownership + sharing; nested folders; optional logic-editor allowlist |
| **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal |
| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy |
| **Observability** | Prometheus-format metrics at `/metrics` |
@@ -64,7 +71,20 @@ Create `uopi.toml` (all fields are optional — shown values are defaults):
```toml
[server]
listen = ":8080"
storage_dir = "./data" # where interface XML files and synthetic.json are stored
storage_dir = "./interfaces" # where interface XML, ACL and logic data are stored
# Identity & access control (all optional)
trusted_user_header = "" # HTTP header carrying the proxy-authenticated user
default_user = "" # identity used when the header is absent (LAN/dev)
# logic_editors = [] # restrict who may edit panel/control logic (users or groups)
# [[server.blacklist]] # downgrade a user: level = "readonly" or "noaccess"
# user = "guest"
# level = "readonly"
# [[groups]] # named user sets, referenced by panel sharing
# name = "operators"
# members = ["alice", "bob"]
[datasource.epics]
enabled = false # requires build with -tags epics
@@ -118,7 +138,24 @@ The REST API is available under `/api/v1`. Key endpoints:
| `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces |
| `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface |
| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface |
| `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders |
| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules |
| `GET/POST` | `/api/v1/folders` | List or create panel folders |
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs |
| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph |
| `GET/POST` | `/api/v1/config/sets` | List or create configuration sets (schemas) |
| `GET/PUT/DELETE` | `/api/v1/config/sets/{id}` | Read, update, or delete a config set |
| `GET/POST` | `/api/v1/config/instances` | List or create configuration instances (values) |
| `GET/PUT/DELETE` | `/api/v1/config/instances/{id}` | Read, update, or delete a config instance |
| `POST` | `/api/v1/config/instances/{id}/apply` | Write an instance's values to their target signals |
| `GET` | `/api/v1/config/{sets\|instances}/diff` | Structural diff between two revisions |
| `GET` | `/api/v1/{interfaces\|synthetic\|controllogic\|config/sets\|config/instances}/{id}/versions` | List revisions; `…/{version}` fetches one |
| `POST` | `…/{id}/versions/{v}/promote` | Promote a revision to current |
| `POST` | `…/{id}/versions/{v}/fork` | Fork a revision into a new document |
| `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission |
| `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) |
| `GET` | `/metrics` | Prometheus-format server metrics |
| `GET` | `/healthz` | Health check |
+142
View File
@@ -0,0 +1,142 @@
# TODO
- **BUG FIX**:
- [x] connecting from firefox always get default user — native SPNEGO/Kerberos auth (`[server.kerberos]`, `internal/server/kerberos.go`): uopi challenges with `401 Negotiate` and resolves the user from the validated ticket instead of relying on a proxy header Firefox never triggers; on non-Kerberos hosts (SSSD/LDAP) use standalone built-in HTTP Basic auth — either validated via PAM (`[server.basic_auth]`, `internal/pamauth`, build with `make backend-pam`) or, keeping the static binary, pure-Go LDAP search-then-bind (`[server.ldap]`, `internal/ldapauth`) — sharing `internal/server/basicauth.go` (which also challenges the page load so the browser shows its login dialog) with optional built-in TLS (`[server.tls]`)
- [x] dpi scaling seems not to work on firefox — root font-size now folds in `window.devicePixelRatio` (`applyZoom` = 16×zoom×dpr) with `watchDpr()` re-applying on monitor change
- [ ] **MAJOR** Implement configuration manager:
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
- with configuration manager user should be able to:
- Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- Create, delete (keep server cache), edit (git style versioning) and compare configuration instances: meaning set actual value to a configuration set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- [x] user can apply and save configuration instances from manager
- [x] **instance snapshot**: create a new instance from the current live value of all of a set's target signals (optional label, else auto name) — exposed in the config editor (⎙ Snapshot), control loop (`action.config.snapshot`) and logic editor (`action.config.snapshot`)
- [x] **live diff**: compare a stored instance against current signal values ("Diff vs current" button → `/config/instances/{id}/livediff`)
- [x] git-style versioning for config sets and instances: fork any revision, click to view, promote to current, with unified / side-by-side diff (shared `VersionTree`)
- [x] in logic editor and control loop add nodes to read/write/create/apply config instances
- [x] **apply** and **read** config-instance nodes in both control logic (server Go) and panel logic (client TS)
- [x] **create / write** nodes (mutate versioned instances from automation) in both engines
- [x] **Config Selector** panel widget: operator picks an instance of a chosen set (optionally a subset) from a combo, writing its id to a panel-local string variable; apply/read/write panel-logic nodes can read their instance dynamically from that variable ("From variable" source)
- [x] support for all supported types (number, bools, enums, arrays, string etc)
- [x] ux elements for config set
- [x] the configuration editor should automatically get type and info from signal when possible (type is read-only, auto-derived from the bound signal)
- [x] a slick tree drag and drop editor to order elements and organize in group and sub-groups
- [x] possibility to customise unit, max, min etc
- [x] export / import a config set as JSON
- [x] full-screen config window
- [x] ux elements for config instances:
- automatically use the correct setting widget depending on the type (e.g. combo for enum, nubmer input for number):
- for arrays: import csv option, display points as mini plot (+ open dialog plot with more info), manual enter points via table like interface
- automatically fill with default or existing values (when forking an existing instance)
- explicity show errors/missing info and give additional info on hover
- [x] add advanced validation / transformation framework to configurations using custom CUE rules:
- [x] backend runs validation / transformation rules (real CUE via `cuelang.org/go`; `internal/confmgr/cue.go`). Rules are a third versioned `Kind` bound to a set; evaluated on instance create/update — violations block the save, concrete derivations transform & persist the values
- [x] user can create/edit/delete/compare rules from webui (Rules tab in ConfigManager; git-style versioning + side-by-side/unified source diff)
- [x] integrate syntax highlight (hand-rolled `CueEditor.tsx`, mirroring `LuaEditor`)
- [x] integrate autocomplete for signals names and cue grammars (param keys + target signals + CUE keywords/types; Ctrl+Space)
- [ ] ~~integrate cue lsp~~ — descoped: a separate language-server process does not fit the no-npm / single portable-binary architecture
- [x] when validation fails the error is propagated to the user (live `/config/rules/check` panel while editing; save returns the structured violation)
- [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`)
- [ ] Improve UX:
- Config editor:
- [x] Instance should be filtered by config set (combo box) (`ConfigManager.tsx` InstancesManager: `setFilter` combo in the instance list head, shown when >1 set; empty-set hint)
- [x] Validation rules should also be filtered by config set (combo box) (`ConfigManager.tsx` RulesManager: `setFilter` combo mirroring instances, shown when >1 set; empty-set hint; `Meta.setId` surfaced via `List`)
- [x] Validation rules can be enabled / disabled — disabled rules are skipped when an instance is created/updated/applied (`Enabled *bool`, nil=enabled for legacy; `IsEnabled()`; `rulesForSet` skips disabled; UI checkbox + list `off` badge)
- [x] Rule editor preview button — runs the working (unsaved) source against a live signal snapshot of the set without persisting it, showing the input snapshot JSON and processed output JSON (`POST /config/rules/preview``previewConfigRule`; `RulePreviewView`)
- [ ] Synthetic editor:
- [x] color code the node link by type
- [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple)
- [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either)
- [x] can not connect input / output that are not compatible (definite scalar↔array mismatch blocked; unknown/any always allowed)
- [x] hover on a block in error should show the reason
- [x] add proper array functionality
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
- [ ] Panels:
- [x] in view mode the widgets should have no border/bg but blend with background
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
- [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog)
- [x] table widget (`web/src/widgets/TableWidget.tsx`; multi-signal value table, one row per bound signal, configurable columns name/value/unit/status/time, optional header + title, per-signal value format + row-label overrides; blends in view mode)
- [ ] other industrial hmi widgets
- [x] widget panel exposes an editable Widget ID field (default = generated uuid) for easier logic interaction (`PropertiesPane.tsx` `IdInput`; `EditMode.renameWidgetId` propagates the rename to `action.widget` logic refs + plot-layout leaves; rejects empty/duplicate ids)
- [x] add container widgets: labelled/title pane, tab panes, collapsable panes (`web/src/widgets/Container.tsx`; decorative grouping frame rendered behind widgets, accent/bg options; `pane` variant = title + view-mode collapse; `tabs` variant = tab bar where each contained widget is assigned a tab via its "Tab" field and only the active tab shows; geometric membership via `web/src/lib/containers.ts`)
- [x] moving container widget should move the widgets on it — starting a move (drag or arrow nudge) on a container snapshots the widgets geometrically inside it (centre-inside, transitive through nested containers) and moves them by the same delta; membership is captured at move start so widgets never re-parent mid-move (`withContainedWidgets` in `web/src/lib/containers.ts`, used by `EditCanvas` drag-start + `EditMode` arrow nudge)
- [x] plot pane:
- [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`)
- [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types)
- [x] plot x-axis synchronised (shared uPlot cursor crosshair across all linked timeseries plots + zoom/pan range sync in historical mode; per-plot 🔗 link/unlink toolbar toggle; `web/src/lib/plotSync.ts`)
- [x] cursors and measuraments (📏 measure mode: click cursor A then B → on-canvas A/B lines + readout panel with Δt/rate and per-signal value@A→value@B and Δ)
- [x] pause/resume (local toolbar pause, OR'd with panel-logic pause; buffers persist across plot-type/window changes)
- [x] save screenshot (PNG export — uPlot canvas / ECharts getDataURL)
- [x] clean ui:
- [x] create small statusbar where connection widget and other status related info will be placed (bottom `.statusbar`: connection chip + current panel name + history indicator)
- [x] group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar (⋯ Tools ▾ dropdown)
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
- [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `<li>`, action buttons `stopPropagation` (`InterfaceList.tsx`)
- [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer)
- [x] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config). Uniform scope model: each item carries owner + scope ∈ {private,group,global} + scope-groups; empty/unknown scope = global (legacy-safe). Shared Go helper `access.CanSee` (`internal/access/scope.go`) filters list endpoints by the caller; shared frontend `web/src/lib/scope.tsx` provides `bucketOf`/`filterByScope`, the segmented `[Mine | Group ▾ | Global]` `ScopeFilter`, and the `ScopePicker` create/save visibility editor. Visibility is a selector filter, not a hard security boundary (owner always sees own items)
- [x] panel tree by user / group / global — scope bucket derived server-side from the panel ACL (`panelScope` in `internal/api/api.go`: public→global, group grant→group, else→private; unmanaged→global), surfaced on `InterfaceListItem.scope`/`groups`; `InterfaceList.tsx` filters panels by bucket and hides folders with no in-scope descendants; visibility is edited via the existing Share dialog
- [x] signal tree by user / group / global — synthetic `SignalDef` gained a `group` visibility mode + `Groups[]`; `synVisible` (`api.go`) routes it through `access.CanSee`; `SyntheticGraphEditor.tsx` wizard offers panel/user/group/global with group checkboxes
- [x] config tree by user / group / global — `ConfigSet`/`ConfigInstance` carry owner+scope+groups (`internal/confmgr`), list endpoints filter via `filterConfigMetas`/`CanSee`, owner preserved across updates; `ConfigManager.tsx` adds `ScopeFilter` + `ScopePicker` to both Sets and Instances managers
- [x] control sequence by user / group / global — control-logic `Graph` gained owner+scope+scopeGroups (`internal/controllogic/model.go`; named `scopeGroups` to avoid the existing cosmetic `Groups []NodeGroup`); `listControlLogic` filters via `CanSee`, create/update stamp/preserve owner; `ControlLogicEditor.tsx` adds `ScopeFilter` + `ScopePicker` (`filterByScope(..., g => g.scopeGroups ?? [])`)
- [ ] Node editors:
- [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval)
- [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors)
- [x] node counter should be better padded (`.flow-group-count` gained vertical padding so the "N nodes" line isn't cramped against the collapsed-box header)
- [x] In all editors: undo / redo + copy / paste with shortcuts (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo-redo; Ctrl+C / Ctrl+V copy-paste of the selection, multi-select aware, internal wires preserved, ids remapped, pasted +offset; ref-based 50-deep history + clipboard scoped per editor; toolbar ↩/↪ buttons). Panel `LogicEditor` already had this; added to `ControlLogicEditor` (undo/redo + copy/paste) and `SyntheticGraphEditor` (copy/paste — it already had undo/redo). Synthetic paste never copies the permanent output node
- [x] In all editors: implement zoom in / out, home zoom and fit zoom to help user navigate complex graph (shared `web/src/lib/flowZoom.ts` `useFlowZoom` hook; CSS `transform: scale()` on a `.flow-canvas-zoom` layer inside the scaled `.flow-canvas-inner`; toolbar row /%//⤢ in all three editors; `toCanvas` divides pointer offsets by zoom)
- [x] fix: the fit-zoom (⤢) button was clipped outside the fixed-width palette — toolbar buttons now shrink to fit (`min-width:0`, zero side padding in `.flow-palette-toolbar .toolbar-btn`)
- [x] fix: zoom value (e.g. 100%) is overflowing, reduce padding and increase the zoom value widget width (`flow-zoom-pct` class on the zoom-value button → `flex:1.9` so it gets ~2× the width of the single-glyph icon buttons; toolbar font dropped to 0.75rem, gap to 0.25rem, `tabular-nums` to stop width jitter; all three editors)
- [x] Live / debug mode in all three node editors — evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green ◉ toggle in each `.flow-palette-toolbar`. Per-editor backends:
- [ ] Syntetic signal editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx`
- [x] Logic editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched
- [x] add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
- [x] array functions should work with new local array
- NOTE: **Phase 1 (panel logic, client-side TS) is DONE** — array statevars (dynamic/capped/fixed), value-polymorphic expression engine with indexing + array functions, `action.array.*` mutation nodes (legacy accumulate/export/clear unified + auto-migrated), panel-XML round-trip, editor declaration form, and widget array modes (plot/table/multi-LED). **Phase 2 (server-side `internal/controllogic` Go port, see below) has now landed**, so this feature is complete across both engines.
- [x] Control loop:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server pushes per-node events over the WS (`debugSubscribe`/`debugNode`) via a new `DebugObserver` on the engine (lock-free `atomic.Value`, gated by a per-graph watch set) + `internal/server/debughub.go` (drop-on-full fan-out). Two modes share one message shape: **Live** observes the running enabled graph; **Simulate** dry-runs the unsaved edits in a throwaway sandbox (`StartSimulate`, side effects suppressed). Live/Sim sub-toggle in the editor
- [x] add full support to server side array values — DONE (Phase 2): `Value = float64 | []Value` model (`internal/controllogic/value.go`), `Graph.StateVars` (number/bool/array with dynamic/capped/fixed sizing) persisted in store JSON, value-polymorphic `expr.go` (array literals, negative-wrap indexing, full array-function table), and `action.array.push|set|remove|pop|clear` nodes. Lua block stays scalar-only (array local → NaN); CSV export remains panel-logic-only
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
- [x] possibility to fork any version
- [x] click to view the version
- [x] possibility to view graphical diff between versions (side by side or unified diff)
- [x] simple slick versioning pane:
- [x] vertical tree like
- [x] each version represented by a circle
- [x] active (the one currently view/edited) version has circle bigger then rest
- [x] selected (the one that will be executed/showed by user) version has circle full, not active only border
- [x] unsaved / new version appear with connection line dashed
- [x] Implement admin pane: create / manage groups, set users permits, manage auditors etc
- [x] user manager
- [x] group manager
- [x] server statistics: load, conenctions, observed signals, average latency, other statistics
- [ ] Implement new datasources:
- [ ] Finalize alarm service
- [ ] modbus tcp
- [ ] scpi tcp / VXI-11 protocol
- [ ] udp? other?
- [ ] **MAJOR** Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
- [ ] clients should be distributed to balance load\
- same user should be (if possible) connect to only one service to simplify synch issue
- [ ] control sequences should be executed in only in one server instance but with backups ones to take over if the active service stop or die
- [ ] sync config between service
- [ ] manage conflict
- [ ] avoid incorrect
- [ ] ensure that the system can survive with up to only one instance alive
- [ ] advance admin panel:
- [ ] should have info about service topology and statistics of each server
- [ ] admin should deploy new instances via ssh to target machines directly from the admin panel
- [ ] for phisical connect datasource (e.g. modbus) pin the service to a specific machine or deploy specific datasource only service to a machine: user can setup backup instances (e.g. machine in the same sub-network that can be switched on in case primary fail)
- [ ] QA and CI
- [ ] coverage of service 90+%
- [ ] coverage of client 80+%
- [ ] add integrated tests with interface simulations
- [ ] update doc and keep user manual up to date
- [ ] add code example for lua scripts
- [ ] add tutorial
+327
View File
@@ -0,0 +1,327 @@
// Command catools provides CA (Channel Access) command-line utilities using
// the pure-Go goca library.
//
// Usage:
//
// catools get <pv> [pv...] Get current value(s)
// catools put <pv> <value> Write a value
// catools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
// catools info <pv> [pv...] Print metadata (type, units, limits)
//
// Environment:
//
// EPICS_CA_ADDR_LIST — space-separated CA server addresses
// EPICS_CA_AUTO_ADDR_LIST — set to "NO" to disable broadcast search
// UOPI_CA_DEBUG — set to "1" for CA protocol debug logging
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
ca "github.com/uopi/goca"
"github.com/uopi/goca/proto"
)
func usage() {
fmt.Fprintln(os.Stderr, `catools — EPICS Channel Access command-line tool
Usage:
catools get <pv> [pv...] Get current value(s)
catools put <pv> <value> Write a value
catools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
catools info <pv> [pv...] Print metadata (type, units, limits)
Environment:
EPICS_CA_ADDR_LIST Space-separated CA server addresses
EPICS_CA_AUTO_ADDR_LIST Set to NO to disable broadcast search
UOPI_CA_DEBUG Set to 1 for CA protocol debug logging`)
os.Exit(1)
}
func main() {
if len(os.Args) < 2 {
usage()
}
cmd := os.Args[1]
args := os.Args[2:]
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg := ca.ConfigFromEnv()
cli, err := ca.NewClient(ctx, cfg)
if err != nil {
fatalf("connect: %v", err)
}
switch cmd {
case "get":
if len(args) == 0 {
fatalf("get: no PV names specified")
}
runGet(ctx, cli, args)
case "put":
if len(args) < 2 {
fatalf("put: usage: catools put <pv> <value>")
}
runPut(ctx, cli, args[0], args[1])
case "info":
if len(args) == 0 {
fatalf("info: no PV names specified")
}
runInfo(ctx, cli, args)
case "monitor":
if len(args) == 0 {
fatalf("monitor: no PV names specified")
}
runMonitor(ctx, cli, args)
default:
fatalf("unknown command %q — use get, put, monitor, or info", cmd)
}
}
// -------------------------------------------------------------------------- //
// get //
// -------------------------------------------------------------------------- //
func runGet(ctx context.Context, cli *ca.Client, pvs []string) {
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
for _, pv := range pvs {
tv, err := cli.Get(tctx, pv)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: error: %v\n", pv, err)
continue
}
fmt.Printf("%-30s %s\n", pv, formatValue(tv))
}
}
// -------------------------------------------------------------------------- //
// put //
// -------------------------------------------------------------------------- //
func runPut(ctx context.Context, cli *ca.Client, pv, valueStr string) {
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Get metadata to determine the right type.
ci, err := cli.GetCtrl(tctx, pv)
if err != nil {
fatalf("%s: get metadata: %v", pv, err)
}
var value any
switch ci.DBFType {
case proto.DBFString:
value = valueStr
case proto.DBFEnum:
if n, err := strconv.ParseInt(valueStr, 10, 32); err == nil {
value = int32(n)
} else if ci.Enum != nil {
for i, s := range ci.Enum.Strings {
if strings.EqualFold(s, valueStr) {
value = int32(i)
break
}
}
}
if value == nil {
fatalf("%s: unknown enum value %q", pv, valueStr)
}
default:
if n, err := strconv.ParseFloat(valueStr, 64); err == nil {
value = n
} else if n, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
value = int64(n)
} else {
value = valueStr
}
}
if err := cli.Put(tctx, pv, value); err != nil {
fatalf("%s: put: %v", pv, err)
}
tv, err := cli.Get(tctx, pv)
if err != nil {
fmt.Printf("%-30s %s (write sent, readback failed: %v)\n", pv, valueStr, err)
return
}
fmt.Printf("%-30s %s\n", pv, formatValue(tv))
}
// -------------------------------------------------------------------------- //
// info //
// -------------------------------------------------------------------------- //
func runInfo(ctx context.Context, cli *ca.Client, pvs []string) {
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
for _, pv := range pvs {
ci, err := cli.GetCtrl(tctx, pv)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: error: %v\n", pv, err)
continue
}
printInfo(pv, ci)
fmt.Println()
}
}
func printInfo(pv string, ci ca.CtrlInfo) {
fmt.Printf("Name : %s\n", pv)
fmt.Printf("DBF type : %s (%d)\n", dbfTypeName(ci.DBFType), ci.DBFType)
fmt.Printf("Count : %d\n", ci.Count)
writable := "No"
if ci.Access&proto.AccessWrite != 0 {
writable = "Yes"
}
fmt.Printf("Writable : %s\n", writable)
switch {
case ci.Double != nil:
d := ci.Double
fmt.Printf("Units : %s\n", d.Units)
fmt.Printf("Disp range : [%g, %g]\n", d.LowerDispLimit, d.UpperDispLimit)
fmt.Printf("Ctrl range : [%g, %g]\n", d.LowerCtrlLimit, d.UpperCtrlLimit)
fmt.Printf("Alarm LOLO : %g HIHI: %g\n", d.LowerAlarmLimit, d.UpperAlarmLimit)
fmt.Printf("Warn LOW : %g HIGH: %g\n", d.LowerWarnLimit, d.UpperWarnLimit)
case ci.Long != nil:
l := ci.Long
fmt.Printf("Units : %s\n", l.Units)
fmt.Printf("Disp range : [%d, %d]\n", l.LowerDispLimit, l.UpperDispLimit)
fmt.Printf("Ctrl range : [%d, %d]\n", l.LowerCtrlLimit, l.UpperCtrlLimit)
fmt.Printf("Alarm LOLO : %d HIHI: %d\n", l.LowerAlarmLimit, l.UpperAlarmLimit)
fmt.Printf("Warn LOW : %d HIGH: %d\n", l.LowerWarnLimit, l.UpperWarnLimit)
case ci.Enum != nil:
fmt.Printf("Enum states: %d\n", len(ci.Enum.Strings))
for i, s := range ci.Enum.Strings {
fmt.Printf(" [%d] %s\n", i, s)
}
case ci.Str != nil:
fmt.Printf("Type : string\n")
}
}
// -------------------------------------------------------------------------- //
// monitor //
// -------------------------------------------------------------------------- //
func runMonitor(ctx context.Context, cli *ca.Client, pvs []string) {
type entry struct {
pv string
ch chan proto.TimeValue
can context.CancelFunc
}
entries := make([]*entry, 0, len(pvs))
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
for _, pv := range pvs {
ch := make(chan proto.TimeValue, 32)
cancelFn, err := cli.Subscribe(tctx, pv, ch)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: subscribe error: %v\n", pv, err)
continue
}
entries = append(entries, &entry{pv: pv, ch: ch, can: cancelFn})
fmt.Printf("Monitoring %s — press Ctrl-C to stop\n", pv)
}
if len(entries) == 0 {
return
}
for _, e := range entries {
go func(e *entry) {
for {
select {
case tv, ok := <-e.ch:
if !ok {
return
}
ts := tv.Timestamp.Local().Format("15:04:05.000")
fmt.Printf("%s %-30s %s\n", ts, e.pv, formatValue(tv))
case <-ctx.Done():
return
}
}
}(e)
}
<-ctx.Done()
for _, e := range entries {
e.can()
}
}
// -------------------------------------------------------------------------- //
// Formatting helpers //
// -------------------------------------------------------------------------- //
func formatValue(tv proto.TimeValue) string {
var val string
switch {
case tv.Str != "":
val = tv.Str
case len(tv.Doubles) > 1:
parts := make([]string, len(tv.Doubles))
for i, v := range tv.Doubles {
parts[i] = strconv.FormatFloat(v, 'g', -1, 64)
}
val = "[" + strings.Join(parts, " ") + "]"
default:
val = strconv.FormatFloat(tv.Double, 'g', -1, 64)
}
severity := ""
switch tv.Severity {
case proto.SeverityMinor:
severity = " MINOR"
case proto.SeverityMajor:
severity = " MAJOR"
case proto.SeverityInvalid:
severity = " INVALID"
}
ts := tv.Timestamp.Local().Format("2006-01-02 15:04:05.000")
return fmt.Sprintf("%s%s [%s]", val, severity, ts)
}
func dbfTypeName(t int) string {
switch t {
case proto.DBFString:
return "DBF_STRING"
case proto.DBFShort:
return "DBF_SHORT"
case proto.DBFFloat:
return "DBF_FLOAT"
case proto.DBFEnum:
return "DBF_ENUM"
case proto.DBFChar:
return "DBF_CHAR"
case proto.DBFLong:
return "DBF_LONG"
case proto.DBFDouble:
return "DBF_DOUBLE"
default:
return "DBF_UNKNOWN"
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "catools: "+format+"\n", args...)
os.Exit(1)
}
+253
View File
@@ -0,0 +1,253 @@
// Command pvtools provides PV Access command-line utilities using
// the pure-Go gopva library.
//
// Usage:
//
// pvtools get <pv> [pv...] Get current value(s) via PVA
// pvtools put <pv> <value> Write a value via PVA
// pvtools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
// pvtools info <pv> [pv...] Print PVA structure info
//
// Environment:
//
// EPICS_PVA_ADDR_LIST — space-separated PVA server addresses
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
pva "github.com/uopi/gopva"
"github.com/uopi/gopva/pvdata"
)
func usage() {
fmt.Fprintln(os.Stderr, `pvtools — EPICS PV Access command-line tool
Usage:
pvtools get <pv> [pv...] Get current value(s)
pvtools put <pv> <value> Write a value
pvtools monitor <pv> [pv...] Stream live updates (Ctrl-C to stop)
pvtools info <pv> [pv...] Print structure info
Environment:
EPICS_PVA_ADDR_LIST Space-separated PVA server addresses`)
os.Exit(1)
}
func main() {
if len(os.Args) < 2 {
usage()
}
cmd := os.Args[1]
args := os.Args[2:]
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
switch cmd {
case "get":
if len(args) == 0 {
fatalf("get: no PV names specified")
}
runGet(ctx, args)
case "put":
if len(args) < 2 {
fatalf("put: usage: pvtools put <pv> <value>")
}
fatalf("put: PVA write not yet implemented")
case "monitor":
if len(args) == 0 {
fatalf("monitor: no PV names specified")
}
runMonitor(ctx, args)
case "info":
if len(args) == 0 {
fatalf("info: no PV names specified")
}
runInfo(ctx, args)
default:
fatalf("unknown command %q — use get, put, monitor, or info", cmd)
}
}
// -------------------------------------------------------------------------- //
// get //
// -------------------------------------------------------------------------- //
func runGet(ctx context.Context, pvs []string) {
cl, err := pva.NewClient()
if err != nil {
fatalf("connect: %v", err)
}
defer cl.Close()
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
for _, name := range pvs {
sv, err := cl.Get(tctx, name)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: error: %v\n", name, err)
continue
}
fmt.Printf("%-30s %s\n", name, formatValue(sv))
}
}
// -------------------------------------------------------------------------- //
// monitor //
// -------------------------------------------------------------------------- //
func runMonitor(ctx context.Context, pvs []string) {
cl, err := pva.NewClient()
if err != nil {
fatalf("connect: %v", err)
}
defer cl.Close()
for _, name := range pvs {
ch := cl.Monitor(ctx, name)
go func(pvName string, ch <-chan pva.MonitorEvent) {
for evt := range ch {
if evt.Err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", pvName, evt.Err)
return
}
ts := time.Now().Local().Format("15:04:05.000")
fmt.Printf("%s %-30s %s\n", ts, pvName, formatValue(evt.Value))
}
}(name, ch)
fmt.Printf("Monitoring %s (PVA) — press Ctrl-C to stop\n", name)
}
<-ctx.Done()
}
// -------------------------------------------------------------------------- //
// info //
// -------------------------------------------------------------------------- //
func runInfo(ctx context.Context, pvs []string) {
cl, err := pva.NewClient()
if err != nil {
fatalf("connect: %v", err)
}
defer cl.Close()
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
for _, name := range pvs {
sv, err := cl.Get(tctx, name)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: error: %v\n", name, err)
continue
}
printInfo(name, sv)
fmt.Println()
}
}
func printInfo(name string, sv pvdata.StructValue) {
fmt.Printf("Name : %s\n", name)
fmt.Printf("Fields : %d\n", len(sv.Fields))
printStruct(sv, " ")
}
func printStruct(sv pvdata.StructValue, indent string) {
for _, f := range sv.Fields {
switch v := f.Value.(type) {
case pvdata.StructValue:
fmt.Printf("%s%s (struct):\n", indent, f.Name)
printStruct(v, indent+" ")
default:
fmt.Printf("%s%s = %v\n", indent, f.Name, v)
}
}
}
// -------------------------------------------------------------------------- //
// Formatting helpers //
// -------------------------------------------------------------------------- //
func formatValue(sv pvdata.StructValue) string {
if len(sv.Fields) == 0 {
return "<empty>"
}
val := fieldByName(sv, "value")
alarm := ""
if a := structByName(sv, "alarm"); a != nil {
if sev := fieldByName(*a, "severity"); sev != nil {
if s, ok := sev.(int32); ok && s != 0 {
alarm = fmt.Sprintf(" (alarm sev=%d)", s)
}
}
}
ts := ""
if t := structByName(sv, "timeStamp"); t != nil {
sec := int64(0)
nsec := int32(0)
if v := fieldByName(*t, "secondsPastEpoch"); v != nil {
sec, _ = v.(int64)
}
if v := fieldByName(*t, "nanoseconds"); v != nil {
nsec, _ = v.(int32)
}
if sec != 0 {
const epicsEpoch = 631152000
ut := time.Unix(sec+epicsEpoch, int64(nsec)).Local()
ts = " [" + ut.Format("2006-01-02 15:04:05.000") + "]"
}
}
if val == nil {
val = sv.Fields[0].Value
}
return fmt.Sprintf("%v%s%s", formatScalar(val), alarm, ts)
}
func formatScalar(v any) string {
switch x := v.(type) {
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case float32:
return strconv.FormatFloat(float64(x), 'g', -1, 32)
case string:
return x
case []string:
return strings.Join(x, " ")
default:
return fmt.Sprintf("%v", v)
}
}
func fieldByName(sv pvdata.StructValue, name string) interface{} {
for _, f := range sv.Fields {
if f.Name == name {
return f.Value
}
}
return nil
}
func structByName(sv pvdata.StructValue, name string) *pvdata.StructValue {
for _, f := range sv.Fields {
if f.Name == name {
if s, ok := f.Value.(pvdata.StructValue); ok {
return &s
}
}
}
return nil
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "pvtools: "+format+"\n", args...)
os.Exit(1)
}
+251 -5
View File
@@ -9,13 +9,27 @@ import (
"log/slog"
"os"
"os/signal"
"os/user"
"path/filepath"
"syscall"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/modbus"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/scpi"
"github.com/uopi/uopi/internal/datasource/servervar"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/ldapauth"
"github.com/uopi/uopi/internal/pamauth"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/server"
"github.com/uopi/uopi/internal/storage"
"github.com/uopi/uopi/web"
@@ -37,6 +51,16 @@ func main() {
os.Exit(1)
}
// In an unproxied/local deployment (no trusted_user_header) without an explicit
// default_user, attribute actions to the OS user running the process so the UI
// and audit log show a real identity instead of anonymous.
if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
if u, err := user.Current(); err == nil && u.Username != "" {
cfg.Server.DefaultUser = u.Username
log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
}
}
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
os.Exit(1)
@@ -48,6 +72,18 @@ func main() {
os.Exit(1)
}
aclStore, err := panelacl.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open panel ACL store", "err", err)
os.Exit(1)
}
cfgStore, err := confmgr.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open configuration store", "err", err)
os.Exit(1)
}
webFS, err := fs.Sub(web.FS, "dist")
if err != nil {
log.Error("failed to sub web dist", "err", err)
@@ -57,7 +93,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
brk := broker.New(ctx, log)
brk := broker.New(ctx, log, broker.WithMaxUpdateRate(cfg.Server.MaxUpdateRateHz))
// Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
// Enabled by default; disable via [datasource.stub] enabled = false in config.
@@ -83,10 +119,18 @@ func main() {
}
}
// EPICS Channel Access data source (requires -tags epics build).
// epics.Available() returns false when built without the tag.
// EPICS Channel Access data source.
// The pure-Go implementation is used by default (no CGo required).
// Build with -tags epics to use the CGo-based libca implementation instead.
if cfg.Datasource.EPICS.Enabled && epics.Available() {
ds := epics.New(cfg.Datasource.EPICS.CAAddrList, cfg.Datasource.EPICS.ArchiveURL)
ds := epics.New(
cfg.Datasource.EPICS.CAAddrList,
cfg.Datasource.EPICS.ArchiveURL,
cfg.Datasource.EPICS.ChannelFinderURL,
cfg.Datasource.EPICS.AutoSyncFilter,
cfg.Datasource.EPICS.AutoSyncFromArchiver,
cfg.Datasource.EPICS.PVNames,
)
if err := ds.Connect(ctx); err != nil {
log.Error("epics connect", "err", err)
} else {
@@ -94,7 +138,209 @@ func main() {
}
}
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, log)
// PV Access data source.
if cfg.Datasource.PVA.Enabled {
pvaDS := pva.New(cfg.Datasource.PVA.AddrList)
if err := pvaDS.Connect(ctx); err != nil {
log.Error("pva connect", "err", err)
} else {
brk.Register(pvaDS)
}
}
// Modbus TCP data source: polls configured holding/input/coil/discrete
// registers on one or more devices. Disabled unless [datasource.modbus] is
// configured with devices.
if cfg.Datasource.Modbus.Enabled {
mbDS, err := modbus.New(cfg.Datasource.Modbus)
if err != nil {
log.Error("modbus init", "err", err)
} else if err := mbDS.Connect(ctx); err != nil {
log.Error("modbus connect", "err", err)
} else {
brk.Register(mbDS)
context.AfterFunc(ctx, mbDS.Close)
}
}
// SCPI data source: polls instrument channels over raw TCP sockets.
// Disabled unless [datasource.scpi] is configured with instruments.
if cfg.Datasource.SCPI.Enabled {
scpiDS, err := scpi.New(cfg.Datasource.SCPI)
if err != nil {
log.Error("scpi init", "err", err)
} else if err := scpiDS.Connect(ctx); err != nil {
log.Error("scpi connect", "err", err)
} else {
brk.Register(scpiDS)
context.AfterFunc(ctx, scpiDS.Close)
}
}
// Server variables: a small persistent key/value source ("srv") that the
// control-logic engine writes (e.g. sequence state) and panels can read.
srvVars, err := servervar.New(cfg.Server.StorageDir)
if err != nil {
log.Error("server variables init", "err", err)
os.Exit(1)
}
brk.Register(srvVars)
// Build the role-based access policy from config: each [[groups]] entry lists
// members by role (viewer/operator/logiceditor/auditor/admin) and an optional
// parent for nesting. Every user is an implicit viewer of the built-in public
// group; a config with no roles at all is fully open (everyone admin).
specs := make([]access.GroupSpec, 0, len(cfg.Groups))
for _, g := range cfg.Groups {
members := make(map[string]access.Role)
assign := func(users []string, role access.Role) {
for _, u := range users {
members[u] = role
}
}
assign(g.Viewers, access.RoleViewer)
assign(g.Operators, access.RoleOperator)
assign(g.LogicEditors, access.RoleLogic)
assign(g.Auditors, access.RoleAuditor)
assign(g.Admins, access.RoleAdmin)
specs = append(specs, access.GroupSpec{Name: g.Name, Parent: g.Parent, Members: members})
}
policy := access.New(cfg.Server.DefaultUser, specs)
// Once enabled, runtime admin-pane changes persist to {storage_dir}/access.json,
// which (when present on a later startup) supersedes the TOML access config.
if err := policy.EnablePersistence(cfg.Server.StorageDir); err != nil {
log.Error("failed to load access store", "err", err)
os.Exit(1)
}
// Audit log: when enabled, every system-affecting action (user/automated
// signal writes, interface and control-logic mutations) is recorded to SQLite.
// When disabled a no-op recorder is used so call sites need no guards.
recorder := audit.Nop()
if cfg.Audit.Enabled {
dbPath := cfg.Audit.DBPath
if dbPath == "" {
dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db")
}
r, err := audit.NewSQLite(dbPath, log)
if err != nil {
log.Error("failed to open audit log", "path", dbPath, "err", err)
os.Exit(1)
}
defer r.Close()
recorder = r
log.Info("audit log enabled", "path", dbPath)
}
// Server-side control logic: flow graphs that run continuously under the root
// context, independent of any panel. The store is loaded from disk and the
// engine starts all enabled graphs.
ctrlStore, err := controllogic.NewStore(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open control logic store", "err", err)
os.Exit(1)
}
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, cfgStore, recorder, log)
// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
// connected clients (filtered by user/group) and route input responses back
// to server variables. Installed before Reload so running graphs can emit.
dialogs := server.NewDialogHub(brk, policy, recorder, log)
ctrlEngine.SetNotifier(dialogs)
// Debug hub: control-logic editors observe live graphs or dry-run unsaved
// edits; the engine pushes per-node execution events through it.
debugHub := server.NewDebugHub(ctrlEngine, log)
ctrlEngine.SetDebugObserver(debugHub)
ctrlEngine.Reload()
// Native SPNEGO/Kerberos authentication (optional): load the service keytab so
// the server can validate browser Negotiate tickets and resolve users directly.
var krbKeytab *keytab.Keytab
if cfg.Server.Kerberos.Enabled {
if cfg.Server.Kerberos.Keytab == "" {
log.Error("kerberos enabled but no keytab configured (server.kerberos.keytab)")
os.Exit(1)
}
kt, err := keytab.Load(cfg.Server.Kerberos.Keytab)
if err != nil {
log.Error("failed to load kerberos keytab", "path", cfg.Server.Kerberos.Keytab, "err", err)
os.Exit(1)
}
krbKeytab = kt
log.Info("native SPNEGO/Kerberos authentication enabled",
"keytab", cfg.Server.Kerberos.Keytab, "service_principal", cfg.Server.Kerberos.ServicePrincipal)
}
// Built-in HTTP Basic authentication (optional): challenge the browser with a
// login dialog and validate credentials against either the host PAM stack
// (basic_auth) or an LDAP directory (ldap), so users log in with their normal
// accounts. The three built-in auth methods (kerberos, basic_auth, ldap) are
// mutually exclusive.
var basicAuthFn func(user, pass string) error
var basicAuthRealm string
enabledAuth := 0
for _, on := range []bool{cfg.Server.Kerberos.Enabled, cfg.Server.BasicAuth.Enabled, cfg.Server.LDAP.Enabled} {
if on {
enabledAuth++
}
}
if enabledAuth > 1 {
log.Error("server.kerberos, server.basic_auth and server.ldap are mutually exclusive; enable only one")
os.Exit(1)
}
if cfg.Server.BasicAuth.Enabled {
if !pamauth.Available {
log.Error("basic_auth enabled but this binary lacks PAM support; rebuild with: make backend-pam (or use server.ldap, which needs no cgo)")
os.Exit(1)
}
service := cfg.Server.BasicAuth.PAMService
if service == "" {
service = "uopi"
}
basicAuthFn = func(user, pass string) error {
return pamauth.Authenticate(service, user, pass)
}
basicAuthRealm = "uopi"
log.Info("built-in HTTP Basic authentication enabled (PAM)", "pam_service", service)
}
if cfg.Server.LDAP.Enabled {
ldapAuth, err := ldapauth.New(ldapauth.Config{
URIs: cfg.Server.LDAP.URIs,
SearchBase: cfg.Server.LDAP.SearchBase,
UserAttr: cfg.Server.LDAP.UserAttr,
UserObjectClass: cfg.Server.LDAP.UserObjectClass,
BindDN: cfg.Server.LDAP.BindDN,
BindPassword: cfg.Server.LDAP.BindPassword,
StartTLS: cfg.Server.LDAP.StartTLS,
CACertFile: cfg.Server.LDAP.CACert,
InsecureSkipVerify: cfg.Server.LDAP.InsecureSkipVerify,
})
if err != nil {
log.Error("ldap auth misconfigured", "err", err)
os.Exit(1)
}
basicAuthFn = ldapAuth.Authenticate
basicAuthRealm = "uopi"
log.Info("built-in HTTP Basic authentication enabled (LDAP)", "uri", cfg.Server.LDAP.URIs, "search_base", cfg.Server.LDAP.SearchBase)
}
if basicAuthFn != nil && !cfg.Server.TLS.Enabled {
log.Warn("HTTP Basic authentication enabled without TLS; credentials are sent in clear text — enable server.tls unless on a fully isolated network")
}
// Built-in TLS (optional): terminate HTTPS directly, recommended whenever
// Basic auth is enabled.
var tlsCert, tlsKey string
if cfg.Server.TLS.Enabled {
if cfg.Server.TLS.Cert == "" || cfg.Server.TLS.Key == "" {
log.Error("server.tls enabled but cert/key not configured (server.tls.cert, server.tls.key)")
os.Exit(1)
}
tlsCert, tlsKey = cfg.Server.TLS.Cert, cfg.Server.TLS.Key
log.Info("built-in TLS enabled", "cert", tlsCert)
}
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.Server.TLS.RedirectFrom, cfg.UI.DefaultZoom, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+310 -66
View File
@@ -8,13 +8,23 @@ uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling
## 2. Users and Roles
| Role | Description |
|------|-------------|
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts |
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
| Administrator | Manages server configuration, data sources, and saved interfaces |
User identity is established from a header set by a trusted authenticating reverse
proxy (`server.trusted_user_header`), with a configurable `default_user` fallback for
unproxied/LAN deployments. There is no login page inside the application.
In the initial version all users share the same access level. Role-based access control is deferred to a future release.
**Global access levels.** Every user is trusted with full **write** access by default. A
configuration blacklist can downgrade specific users to **read-only** (view only, no
writes) or **no access** (denied). Named **groups** are defined in configuration and
referenced by per-panel sharing.
**Per-panel access.** Panels are owned by their creator and private by default. Owners
share a panel with specific users/groups (read or write) or make it public; the owner's
global level always caps the per-panel permission. Panels are organised into nested
folders whose permissions inherit down the chain. See §9.
**Logic-edit restriction.** Adding/editing panel logic and server-side control logic can
optionally be limited to an allowlist of users/groups (`server.logic_editors`); when
unset, any writer may edit logic.
---
@@ -24,58 +34,107 @@ In the initial version all users share the same access level. Role-based access
The default mode when opening the application.
**Interface list pane (left, collapsible)**
- Displays all interfaces saved on the server, grouped into a tree by folder.
**Interface list pane (left, collapsible, resizable)**
- Displays all interfaces saved on the server.
- Right-click on an interface: options to open in Edit mode or clone it.
- "New interface" button opens Edit mode with a blank canvas.
- "Import" option loads an interface from a local XML file.
- The pane width can be adjusted by dragging the resize handle on its right edge.
**HMI canvas (center)**
- Renders the selected interface as a live, interactive panel.
- Widgets display real-time data; controls (set-value, buttons) are active.
- Panel logic (if any) runs while the panel is open (see §6).
- No drag, resize, or layout operations are possible in this mode.
- Right-clicking any widget opens a context menu:
- **Signal info** — floating window showing DS name, type, unit, range, current value and timestamp.
- **Signal info** — shows DS name, type, unit, range, current value and timestamp.
- **Copy signal name** — copies the signal identifier to the clipboard.
- **Export data to CSV** — downloads buffered/historical data for the signal(s) used by the widget.
- **Export data to CSV** — downloads buffered data for the signal(s) used by the widget.
> Dedicated multi-signal plotting is provided by **plot panels** — a special interface
> kind whose plots fill the viewport (see §3.3) — rather than a separate live "Plot tab".
**Top toolbar**
- Show/hide interface list pane.
- Time selector: navigate to a past timestamp (enabled only when the server has archive access for all signals on the canvas).
- "Live" button: return to real-time data after historical navigation.
- **⏱ History** button: toggle historical time navigation bar.
- **⚙ Control logic** button: open the server-side control-logic editor (see §7).
Shown only to users permitted to edit logic.
- Zoom control (A / % / A+): adjust the UI scale (see §3.4).
- Edit button: switch to Edit mode for the current interface.
- Connection status indicator and signed-in user chip.
**Historical time navigation bar** (shown when History is active)
- Date/time range pickers (start and end).
- **Load** button fetches archive data and replaces live data in all widgets.
- **Live** button discards archive data and resumes real-time streaming.
- Status shown on plot widgets: "Loading history…", "No archive data for this range", "Archive unavailable".
### 3.2 Edit Mode
Activated via the "New interface" button or by right-clicking an existing interface.
Activated via the "New interface" button or by clicking Edit in the toolbar.
**Signal tree pane (left, resizable and collapsible)**
- Shows all signals known to each connected data source.
- Sources are shown as top-level nodes; signals are nested within.
- User can add custom entries:
- For EPICS: manually enter a PV name.
- For Synthetic: define a new synthetic signal (see §5.2).
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
- For Synthetic: define a new synthetic signal via the Synthetic Wizard (see §5.2).
- Filter/search box to narrow the list.
- Synthetic signals show an edit (✎) button to reopen the wizard.
**Widget canvas (center)**
- Free-form canvas where widgets can be placed at arbitrary pixel positions.
- Background grid with optional snap-to-grid.
**Center area — Layout / Logic tabs**
- **Layout**: free-form canvas where widgets can be placed at arbitrary pixel positions,
over a background grid with snap-to-grid. (For plot panels this is replaced by the
split-layout editor; see §3.3.)
- **Logic**: a node-graph flow editor for panel behaviour (see §6). Hidden when the user
is not permitted to edit logic.
**Properties pane (right, collapsible)**
**Local variables**
- A panel may declare panel-scoped variables (data source `local`) with initial values.
- They are written by set-value widgets, buttons and logic actions, and referenced
anywhere a signal is. Added from the signal tree's *Local* group / the Logic palette.
**Properties pane (right, resizable and collapsible)**
- Appears when one or more widgets are selected.
- Displays and edits all options for the selected widget (see §4).
- Width is adjustable by dragging the resize handle on its left edge.
**Top toolbar**
- Show/hide signal pane.
- Show/hide properties pane.
- Show/hide signal pane / properties pane.
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
- Import / Export interface to/from local XML file.
- Save interface to server.
- Load interface from server.
- Export interface to local XML file.
- Import interface from local XML file.
- "Add text" tool — inserts a static text label.
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
- "Add link" tool — inserts a button that opens another interface.
- Close (return to View mode).
- Zoom control (A / % / A+).
### 3.3 Plot Panels (Split Layout)
A **plot panel** is a special interface kind dedicated to charts. Rather than free-form
widget boxes, plots **fill the viewport** and the user divides the space between them with
a recursive split layout (tmux/IDE-style tiling). Created via **+ Plot** in the interface
list, opening with a single full-viewport empty plot.
**Editing (Edit mode):**
- Hover a pane and use its split buttons (**⬌** vertical / **⬍** horizontal) to divide it;
a new empty plot fills the freed half. Nesting is unlimited.
- Drag the divider between two panes to resize them.
- Click a pane to select it and configure its plot in the Properties pane (plot sub-type,
Y range, time window, legend, per-signal colour). Drag a signal onto a pane to add it.
- A pane's **✕** removes it; the layout collapses onto its sibling.
**View mode:** the saved split layout fills the screen with live, streaming plots; the
per-widget right-click menu (signal info / copy / CSV) and historical time navigation
apply as for any time-series plot.
Per-plot configuration reuses the standard Plot widget (§4.4), so all plot sub-types and
options are available within a pane.
### 3.4 UI Zoom
The toolbar in both modes contains a zoom control (**A** / **nn%** / **A+**) that adjusts the base font size of the entire UI:
- 11 zoom steps from 50% to 250% (50, 60, 75, 85, 100, 115, 130, 150, 175, 200, 250%).
- The selected zoom level is persisted in `localStorage` and restored on the next page load.
- At 100%, the base font size adapts to viewport height via `clamp(13px, 1.5vh, 18px)`, making the UI naturally usable on 4K displays without any manual adjustment.
---
@@ -83,7 +142,7 @@ Activated via the "New interface" button or by right-clicking an existing interf
### 4.1 Creating Widgets
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type (iconised). The user selects one and the widget is placed at the drop location with default size.
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type. The user selects one and the widget is placed at the drop location with default size.
### 4.2 Selecting Widgets
@@ -104,8 +163,7 @@ When multiple widgets are selected:
- An align/distribute toolbar appears above the canvas with:
- Align left / center horizontal / right.
- Align top / center vertical / bottom.
- Distribute evenly — by center spacing (horizontal/vertical).
- Distribute evenly — by gap size (horizontal/vertical).
- Distribute evenly (horizontal/vertical).
### 4.4 Widget Catalogue
@@ -115,7 +173,7 @@ When multiple widgets are selected:
| Gauge | numeric scalar | Circular or arc gauge with configurable range |
| Vertical bar | numeric scalar | Vertical level indicator |
| Horizontal bar | numeric scalar | Horizontal level indicator |
| Set value | numeric or string, writable | Shows `name: [input field] current_value unit` + Set button |
| Set value | numeric, string, or enum; writable | Input field or enum dropdown + Set button |
| LED | boolean / numeric | Coloured indicator with configurable condition and label |
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
| Button | writable | Sends a fixed value or command on click |
@@ -134,73 +192,258 @@ When multiple widgets are selected:
| Histogram | numeric scalar(s) |
| Bar chart | numeric scalar(s) |
| Logic analyser | boolean / integer (bitset) |
| Waveform | 1-D numeric array (latest sample, x-vs-index) |
### 4.5 Widget Properties (Properties Pane)
Common to all:
- Label text, font size, text colour.
- Position (X, Y) and size (W, H) — editable numerically.
- Data source and signal name (read-only after creation; reassignable via drag).
- Data source and signal name.
Per type:
- **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label.
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states.
- **Plot**: plot sub-type selector, Y-axis range (auto or manual), time window duration, legend position, colour per signal, line style.
- **Set value**: input type (numeric / string / enum), confirmation prompt toggle.
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states, label.
- **Plot**: plot sub-type selector, Y-axis range (auto or manual min/max), time window duration, legend position (top / bottom / none), value format string.
- **Set value**: no special options — enum mode is detected automatically from signal metadata.
- **Link**: target interface name.
### 4.6 Set-value Widget — Enum Mode
When the signal's metadata reports enum strings (e.g. EPICS mbbi/mbbo records):
- The input field is replaced by a `<select>` dropdown showing all enum options.
- The current live value is shown as the pre-selected option (display only).
- The user selects a value from the dropdown, then clicks **Set** to write it.
- The Set button is always required; changing the dropdown does not write immediately.
---
## 5. Data Sources
### 5.1 EPICS
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA).
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range (DRVL/DRVH, LOPR/HOPR), alarm limits, enum strings (for mbbi/mbbo records), read/write mode, acquisition mode.
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable.
- Attempts to enumerate available PV names from the IOC (e.g. via PV lists or Channel Finder). Unknown PVs can still be manually added by the user.
- When an EPICS Archive Appliance or Channel Archiver is configured, the server can satisfy historical data requests.
- Connects to an EPICS environment via Channel Access (CA).
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range, alarm limits, enum strings (for mbbi/mbbo records), read/write mode.
- Uses `ca_add_event` monitors — no polling.
- When an EPICS Archive Appliance is configured, the server can satisfy historical data requests from the toolbar time navigator.
### 5.2 Synthetic
### 5.2 Synthetic Signals
A signal defined by composing one or more input signals (from any data source) through a chain of processing functions.
A signal defined by composing one or more input signals through a chain of processing nodes.
**Built-in processing functions (non-exhaustive):**
- Arithmetic: gain, offset, add, subtract, multiply, divide.
- Signal processing: moving average (N samples or time window), RMS, bandpass filter (IIR/FIR), lowpass / highpass filter, derivative, integral.
- FFT, inverse FFT.
- Peak detection, threshold crossing.
- Custom formula: inline expression (`a * sin(b) + c`).
- Lua script block: arbitrary Lua code with access to input values and state.
**Two authoring surfaces:**
- **Wizard** — for the common single-input case: name the signal, pick an input and a
processing node, set parameters, Create.
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
It supports undo/redo (Ctrl+Z / Ctrl+Shift+Z) and copy/paste (Ctrl+C / Ctrl+V) of the
selection; paste remaps ids and preserves internal wires (the permanent output node is
never duplicated).
**User workflow:**
1. Click "New synthetic signal" in the signal tree.
2. Name the signal and choose input signals.
3. Build a processing pipeline by chaining function blocks (UI similar to a node graph or an ordered list).
4. Optionally write a Lua snippet for custom logic.
5. The synthetic signal appears in the tree and can be used like any other signal.
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
that created it), *user*, or *global* (shared with everyone).
The dialogs are resizable and default to a width that accommodates the Lua editor.
**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the editor.
**Built-in node types:**
| Node | Parameters | Description |
|------|------------|-------------|
| Source | DS, signal name | Input from any data source |
| Gain | factor | Multiply by constant |
| Offset | value | Add constant |
| Moving average | window (samples) | Rolling mean |
| Low-pass filter | frequency (Hz), order (18) | IIR low-pass; correct for non-uniform sample rates |
| Formula | expression | Inline math (variables: `a`, `b`, …) |
| Lua script | script | Arbitrary Lua 5.1 with persistent state table |
**Lua editor:** the Lua node parameter uses a code editor with syntax highlighting (keywords, strings, comments, numbers rendered in distinct colours). The editor is a full-height multi-line input that grows with the dialog.
---
## 6. Interface Persistence
## 6. Panel Logic
The **Logic** tab in the panel editor is a node-graph (Node-RED-style) flow editor that
gives a panel interactive behaviour. The flow runs **client-side** while the panel is open
in View mode; it is saved as part of the interface XML. The tab and any logic editing are
gated by the logic-edit restriction (§2).
**Authoring:** drag blocks from the palette (or click to add), connect output ports to
input ports, and edit the selected node in the inspector. Expression fields reference a
signal as `{ds:name}` and a panel-local variable by its bare name, and support arithmetic,
comparisons, boolean logic and common math functions. The editor has its own undo/redo and
copy/paste.
**Node categories:**
| Category | Nodes |
|----------|-------|
| Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle |
| Logic | AND gate, If (then/else), Loop (count or while) |
| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays; Apply config / Read config / Write config / Create config / Snapshot config (see §11) |
| Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) |
**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds
since the firing trigger last fired).
---
## 7. Control Logic
**Control logic** is server-side automation: flow graphs that run continuously on the
server, independent of any connected client (unlike panel logic, which only runs while a
panel is open). Opened with the **⚙ Control logic** button in the View-mode toolbar and
managed through the REST API.
- Triggers include *cron* schedules and signal *alarm*/threshold conditions.
- A **Lua** block provides custom logic; results are written back to signals.
- **Apply / Read / Write / Create config** action nodes drive the configuration manager
(§11): *Apply config* writes every value of a chosen instance to its bound signals
(audited); *Read config* reads one parameter's value into a target signal or variable;
*Write config* stores a value into a parameter (creating a new instance revision); *Create
config* makes a new instance for a set, optionally seeded from another instance. Both
mutating nodes are audited.
- Each graph can be enabled/disabled independently; saving reloads the engine live.
- The editor has its own undo/redo and copy/paste (Ctrl+Z / Ctrl+Shift+Z, Ctrl+C / Ctrl+V;
also ↩/↪ toolbar buttons). Copy/paste is multi-select aware and preserves wires internal
to the selection.
- Editing is gated by the logic-edit restriction (§2).
---
## 8. Interface Persistence
- Interfaces are saved to the server in XML format and are available to all connected clients.
- Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON).
- Saved versions are retained; a panel's version history can be listed, tagged, viewed, promoted, forked and diffed — the same git-style versioning shared by all versioned documents (§12).
- Export/Import allows local file exchange of XML files.
- The XML schema records: widget type, position, size, signal bindings, and all property values.
- The XML schema records: interface kind (panel/plot) and split layout, widget type,
position, size, signal bindings, all property values, local variables, and panel logic.
---
## 7. Historical Data Navigation
## 9. Access Control, Sharing & Folders
When the server has archive access for all signals on the current canvas:
- The top toolbar shows a date/time picker.
- Selecting a past time replays data from the archive into all widgets.
- The "Live" button resumes real-time streaming.
- Widgets that support time-axis (plots) show the historical range; point-value widgets show the value at the selected time.
**Identity** is resolved per request from the trusted proxy header, falling back to
`default_user`. The `/api/v1/me` endpoint reports the caller's identity, global level,
group memberships and whether they may edit logic; the UI hides affordances accordingly.
**Global levels** (§2): write (default), read-only, or no-access via the config blacklist.
**Per-panel sharing:** the **Share** dialog on a panel grants read or write to specific
users or groups, or marks the panel public. New panels are private to their owner. The
owner's global level caps any per-panel grant.
**Folders:** panels are organised into nested folders; permissions inherit down the folder
chain. Panels can be dragged to reorder or move between folders.
**Logic-editor allowlist:** when `server.logic_editors` is set, only the listed users/
groups may add or change panel logic and control logic; everyone else keeps full access to
the rest of the application.
---
## 8. Non-functional Requirements
## 10. Historical Data Navigation
When the server has archive access configured:
- The **⏱ History** button in the View mode toolbar reveals a time range bar.
- Users set a start and end date/time and click **Load**.
- Plot widgets fetch and display the archived data for the selected range.
- Point-value widgets (text view, gauge, bar, LED) show the value at the end of the selected range.
- The **Live** button resumes real-time streaming.
- Status overlays on plot widgets indicate loading, empty, or error states.
---
## 11. Configuration Manager
The **Configuration manager** (opened from the View-mode toolbar) manages three kinds of
versioned documents, on **Sets**, **Instances** and **Rules** tabs, in a full-screen modal.
**Configuration sets** are schemas: an ordered list of typed parameters, each binding a
target signal. Parameters can be organised into **groups** and **subgroups** via a
drag-and-drop tree.
- A parameter's **type** (number, integer, boolean, string, enum, array/waveform) and its
unit/range/enum values are auto-derived from the bound signal's metadata when available;
the type is read-only.
- Each parameter may declare a **default**, a **mandatory** flag, and **min/max** /
**enum** constraints.
- Sets can be **exported / imported** as JSON.
**Configuration instances** assign concrete values to a chosen set's parameters.
- The editor renders the right control per type (number input, enum/boolean combo, and a
waveform editor with live sparkline, table editing, CSV import and a pop-out plot for
arrays); empty fields fall back to the parameter default.
- Validation mirrors the backend (required-but-unset, out-of-range, wrong type, bad enum)
and is surfaced inline with hover detail.
- **Apply** writes every value to its target signal and reports a per-parameter
applied / failed / skipped result.
- **Snapshot** (⎙ in the Instances tab) captures the *current live value* of every target
signal of a chosen set into a new instance — an optional label, otherwise an auto name. This
records "what the hardware holds right now" as a reusable configuration.
- **Diff vs current** compares a stored instance (resolved with defaults) against the current
live signal values, so an operator can see how the saved configuration differs from what the
system currently has.
The Sets and Instances tabs support the shared version history (§12): view, fork and promote
revisions, plus a **structural diff** between any two revisions (per-parameter added / removed
/ changed / unchanged).
**Validation / transformation rules** (Rules tab) attach **CUE** logic to a set. Each rule is
CUE source describing constraints and/or derivations over the parameter values: a field like
`voltage: >=0 & <=24` validates a value, while a concrete derivation like
`power: voltage * current` computes one. When an instance of the bound set is saved, every
rule runs — a failed constraint **blocks the save** and surfaces the violation, and derived
values are written into the stored instance. The rule editor is a CUE-aware code editor with
syntax highlighting and autocomplete (parameter keys, target signal names, and CUE
keywords/types via Ctrl+Space); a live panel re-evaluates the rule against the set's default
values on every keystroke, showing compile errors, violations, or the derived values. Rules
are versioned like the other documents, with a side-by-side / unified source diff, and every
create / edit / delete is audited. (A full CUE language server is intentionally out of scope:
it does not fit the no-dependency, single portable-binary design.)
**Automation:** both the panel-logic (§6) and control-logic (§7) editors expose **Apply
config**, **Read config**, **Write config**, **Create config** and **Snapshot config** action
nodes. *Apply config* applies a chosen instance exactly like the Apply button; *Read config*
reads a single numeric parameter into a target signal or variable; *Write config* stores a
value into a parameter, creating a new instance revision; *Create config* makes a new instance
for a set, optionally seeded from another instance; *Snapshot config* captures every target
signal's current live value of a set into a new instance (like the ⎙ Snapshot button). The
mutating nodes are audited.
**Config Selector widget:** a panel widget that lets an operator pick which configuration a
flow operates on at run time. It lists the instances of a chosen set — all of them or a
defined subset — in a combo and writes the selected instance id to a panel-local string
variable. The panel-logic Apply / Read / Write nodes can take their instance "From variable"
(reading that id) instead of a fixed instance, so the operator's choice drives the flow.
Control-logic nodes use fixed instances only (their variables are numeric).
---
## 12. Version History & Diff
All versioned documents — panels, synthetic signals, control-logic graphs, and
configuration sets/instances — share one git-style version model and a common history
pane:
- A **vertical version tree** lists every revision (one node each); the **current**
(executed) revision is filled, the **viewed** revision is enlarged, and unsaved edits show
a dashed connector.
- **View** loads any past revision read-only into the editor — viewing alone is not an edit
and does not mark the document dirty; saving from a viewed revision creates a new revision
on top (history is never destroyed).
- **Fork** copies a revision into a brand-new document (version reset to 1).
- **Promote** re-saves an older revision as a new current revision.
- **Diff** compares two revisions, defaulting to *current-vs-selected*, shown either
**unified** or **side-by-side**. Panels, synthetic and control-logic use a generic line
diff of the serialized document; configuration sets/instances use a richer per-parameter
structural diff.
---
## 13. Non-functional Requirements
| Requirement | Target |
|-------------|--------|
@@ -210,4 +453,5 @@ When the server has archive access for all signals on the current canvas:
| Concurrent clients | ≥ 20 simultaneous browser clients |
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
| Frontend responsiveness | 60 fps canvas rendering during live updates |
| Screen DPI | Frontend adapts to device pixel ratio |
| Screen DPI | UI scales with viewport height; manual zoom 50250% |
| Plot buffer | 200,000 samples per signal retained in-browser |
+86
View File
@@ -0,0 +1,86 @@
# Guide: Configuring uopi for ITER CS-Studio
This guide explains how to configure **uopi** to work within the **ITER CODAC (Control System Studio / Phoebus)** environment. Since uopi is a single portable binary, it can be deployed alongside or as a lightweight web-based alternative to standard CS-Studio screens.
---
## 1. Prerequisite Information
To connect uopi to the ITER control system, you need three pieces of information usually found in your standard CODAC environment or terminal:
| Info Needed | Purpose | Where to find it in ITER |
| :--- | :--- | :--- |
| **CA Addr List** | PV Discovery via UDP | Run `echo $EPICS_CA_ADDR_LIST` in a terminal. |
| **Archiver URL** | Historical Trends | Check your CS-Studio preferences under *Archive Appliance* (usually `http://arch-mgmt:17665/`). |
| **CFS URL** | Automatic PV Search | Check CS-Studio preferences under *Channel Finder* (usually `http://cf-service:8080/ChannelFinder`). |
---
## 2. Configuration (`uopi.toml`)
Create a file named `uopi.toml` in the directory where you will run uopi. Use the values found above:
```toml
[server]
listen = ":8080" # The port uopi will serve the web HMI on
storage_dir = "./interfaces" # Where your XML screens will be saved
[datasource.epics]
enabled = true
# Paste the output of 'echo $EPICS_CA_ADDR_LIST' here
ca_addr_list = "10.0.x.x 10.0.x.255"
# ITER Archive Appliance URL
archive_url = "http://arch-mgmt:17665/"
# ITER Channel Finder URL
channel_finder_url = "http://cf-service:8080/ChannelFinder"
# Automatic discovery on startup
auto_sync_from_archiver = true
auto_sync_filter = "tags=production"
```
---
## 3. Deployment in the ITER Environment
### A. Running via Terminal
If you are on a CODAC workstation or a server with network access to the Plant System:
1. Copy the `uopi` binary to the machine.
2. Run it pointing to your config:
```bash
./uopi -config uopi.toml
```
3. Open a browser and navigate to `http://<machine-ip>:8080`.
### B. Offline Usage (No Internet)
uopi is built with **zero external dependencies**. All scripts and the favicon are embedded. No `npm` or `curl` calls are made at runtime. It will function perfectly on isolated ITER networks.
---
## 4. Discovering ITER PVs
Once uopi is running:
1. **Automatic Discovery:** If `auto_sync_from_archiver` is set to `true`, the sidebar will be pre-populated with thousands of ITER PVs on boot.
2. **Smart Grouping:** Click the **"Group By"** dropdown in the sidebar. Select **"Area"** or **"System"**. uopi will use the metadata from the ITER Channel Finder to organize PVs into folders (e.g., `Vacuum`, `Cryogenics`, `PowerSupply`).
3. **Advanced Search:**
* Click the **🔍 (Advanced Search)** icon in the sidebar.
* Use the **Channel Finder** tab to find signals by tags (e.g., `interlock`) or properties (e.g., `iocName`).
* Use the **Archive Appliance** tab to perform a glob search (e.g., `*TEMP*`) directly against the ITER Archiver database.
---
## 5. Transitioning from CS-Studio (`.bob` / `.opi`)
uopi uses a custom XML format for its screens, but the layout logic is similar to Phoebus/CS-Studio.
* **Signals:** Just like in CS-Studio, you can drag PVs from the sidebar directly onto the canvas to create widgets.
* **Historical Data:** Any PV discovered via the Archiver tab or configured with the Archiver URL will automatically show historical data when added to a **Plot Widget**.
* **Synthetic Signals:** You can create complex ITER-specific calculations (e.g., `R = V/I`) by clicking the **Σ** icon and combining multiple EPICS PVs into a single virtual signal.
---
## 6. Troubleshooting
* **Timeout Error:** If `caget` times out in uopi, verify that `ca_addr_list` includes the correct broadcast address for your ITER subnet (e.g., ends in `.255`).
* **Metadata Missing:** If PVs show up but have no units/descriptions, ensure the `channel_finder_url` is reachable from the uopi binary.
+564 -104
View File
@@ -20,31 +20,31 @@
| Package | Purpose |
| ---------------------------- | ------------------------------------------------------ |
| `nhooyr.io/websocket` | WebSocket server (no CGo, more ergonomic than gorilla) |
| `go-epics/ca` or CGo wrapper | EPICS Channel Access |
| CGo wrapper to `libca` | EPICS Channel Access |
| `yuin/gopher-lua` | Lua 5.1 runtime for synthetic signals |
| `gonum.org/v1/gonum` | DSP and math functions (FFT, filters) |
| `BurntSushi/toml` | TOML config parsing |
| `encoding/xml` (stdlib) | Interface file serialisation |
| `net/http` (stdlib) | HTTP server and static file serving |
### 1.2 Frontend — Svelte + TypeScript
### 1.2 Frontend — Preact + TypeScript
**Rationale:**
- Svelte compiles to vanilla JS with no virtual DOM, giving the smallest bundle and lowest runtime overhead — essential for the 60 fps reactive feel required.
- Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival.
- Preact is a 3 kB React-compatible virtual DOM library — small bundle, fast diffing, no extra framework overhead.
- esbuild (invoked via its Go API) bundles the TypeScript/TSX source in milliseconds with no Node.js or npm dependency at build time.
- TypeScript catches signal subscription and widget property type errors at build time.
- All vendor JS/CSS is checked into `web/vendor/` so the repo builds without internet access.
**Key dependencies:**
**Key dependencies (vendored):**
| Package | Purpose |
| ----------------- | ------------------------------------------------------------------------------ |
| `svelte` + `vite` | Framework and build toolchain |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
| `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) |
| `svelte-konva` | Svelte bindings for Konva |
| Package | Purpose |
| ---------------- | --------------------------------------------------------------- |
| `preact` 10 | Virtual DOM UI framework |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser, waveform plots |
| `uplot.css` | uPlot default stylesheet |
**Intentionally excluded:** React, Vue, WebGPU, jQuery.
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
---
@@ -52,36 +52,49 @@
```
uopi/
├── cmd/uopi/ # main package — CLI flags, wiring
├── cmd/uopi/ # main package — CLI flags, wiring
├── internal/
│ ├── server/ # HTTP + WebSocket handlers
│ ├── broker/ # signal fan-out to clients
│ ├── server/ # HTTP + WebSocket handlers
│ ├── broker/ # signal fan-out to clients
│ ├── datasource/
│ │ ├── iface.go # DataSource interface
│ │ ├── epics/ # EPICS CA/PVA implementation
│ │ └── synthetic/ # synthetic signal engine
│ ├── lua/ # Lua sandbox helpers
│ ├── dsp/ # DSP functions (wraps gonum + custom)
── storage/ # interface XML read/write
│ └── api/ # REST handler functions
├── web/ # Svelte source
│ ├── src/
│ │ ├── iface.go # DataSource interface
│ │ ├── epics/ # EPICS CA implementation (CGo)
│ │ └── synthetic/ # synthetic signal engine + DSP bridge
│ ├── dsp/ # DSP node implementations (lowpass, MA, etc.)
│ ├── storage/ # interface XML read/write
── api/ # REST handler functions
├── web/
│ ├── embed.go # //go:embed dist — exports FS to Go
│ ├── src/ # TypeScript/TSX source (Preact)
│ │ ├── lib/
│ │ │ ├── ws.ts # WebSocket client + subscription manager
│ │ │ ├── stores.ts # Svelte stores for signal values
│ │ │ ├── widgets/ # one .svelte file per widget type
│ │ │ ── editor/ # edit-mode canvas, toolbar, properties pane
│ │ ├── routes/
│ │ │ ├── +page.svelte # view mode
│ │ │ └── edit/+page.svelte # edit mode
│ │ ── app.html
│ ├── package.json
└── vite.config.ts
├── docs/ # specs, work plan
│ │ │ ├── stores.ts # signal value + metadata stores
│ │ │ ├── types.ts # shared TypeScript interfaces
│ │ │ ── xml.ts # interface XML parse/serialize
│ │ │ └── format.ts # value formatting helpers
│ │ ├── widgets/ # one .tsx file per widget type
│ │ ├── App.tsx # top-level component, mode routing
│ │ ── ViewMode.tsx # view mode layout + tabs
│ ├── EditMode.tsx # edit mode layout + toolbar
│ ├── Canvas.tsx # live HMI canvas (view mode)
│ │ ├── EditCanvas.tsx # free-form widget editor canvas
│ │ ├── PlotPanel.tsx # live plot side-panel (Plot tab)
│ │ ├── InfoPanel.tsx # signal info side-panel
│ │ ├── ZoomControl.tsx # UI zoom A-/A+ control
│ │ ├── SyntheticWizard.tsx # new synthetic signal dialog
│ │ ├── SyntheticEditor.tsx # edit existing synthetic signal
│ │ ├── LuaEditor.tsx # Lua code editor with syntax highlight
│ │ └── styles.css # all component styles
│ ├── vendor/ # vendored JS/CSS (preact, uplot, echarts)
│ └── dist/ # built frontend — generated, not committed
├── tools/buildfrontend/ # esbuild Go API bundler (go generate)
├── docs/ # specs, work plan
├── CLAUDE.md
└── README.md
```
The embed package lives at `web/embed.go` (not in `cmd/`) because `//go:embed` paths cannot use `..`.
---
## 3. Backend Architecture
@@ -151,6 +164,22 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Request historical data
{ "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 }
// Start a control-logic live-debug session (one per client; replaces any prior).
// mode "live" — observe the running, enabled graph identified by graphId.
// mode "simulate" — dry-run the unsaved `graph` in a server sandbox (no real
// writes/config/dialogs); re-send on each edit to refresh it.
{ "type": "debugSubscribe", "mode": "live", "graphId": "g1" }
{ "type": "debugSubscribe", "mode": "simulate", "graph": { /* unsaved control-logic graph */ } }
// Stop the current debug session (tears down any simulate sandbox).
{ "type": "debugUnsubscribe" }
// Force a trigger node of the current debug session (live or simulate) to run
// now, as if it had fired. nodeId must be a trigger node of the watched graph;
// best-effort (dropped if the session is gone). Drives the editor's
// double-click-to-fire gesture on trigger nodes in debug mode.
{ "type": "fireTrigger", "nodeId": "t" }
```
**Server → Client messages:**
@@ -165,6 +194,11 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Historical data response
{ "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] }
// Control-logic node execution during a live-debug session. Emitted ~per node
// run for the watched graph (live) or sandbox (simulate); value is meaningful
// only when hasValue is true (e.g. an action.write's value, a flow.if branch 0/1).
{ "type": "debugNode", "graphId": "g1", "nodeId": "w", "value": 42, "hasValue": true, "ts": 1750000000000 }
// Error
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
```
@@ -173,22 +207,60 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
Base path: `/api/v1`
| Method | Path | Description |
| ------ | ------------------------- | -------------------------------------------- |
| GET | `/datasources` | List connected data sources and their status |
| GET | `/signals?ds=epics` | List signals for a data source |
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal |
| GET | `/interfaces` | List saved interfaces |
| POST | `/interfaces` | Create a new interface (body: XML) |
| GET | `/interfaces/:id` | Download interface XML |
| PUT | `/interfaces/:id` | Update interface XML |
| DELETE | `/interfaces/:id` | Delete interface |
| POST | `/interfaces/:id/clone` | Clone an interface |
| Method | Path | Description |
| --------------- | ------------------------------------- | ------------------------------------------------- |
| GET | `/me` | Caller identity, global level, groups, `canEditLogic` |
| GET | `/datasources` | List connected data sources and their status |
| GET | `/signals?ds=epics` | List signals for a data source |
| GET | `/signals/search?q=` | Search signals across all sources |
| GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder |
| GET | `/archiver/search?q=` | Search the EPICS archiver for PV names |
| GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) |
| POST | `/interfaces/reorder` | Reorder panels / move between folders |
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
| POST | `/interfaces/{id}/clone` | Clone an interface |
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
| GET, POST | `/folders` | List or create panel folders |
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
| GET | `/usergroups` | List configured users and groups (for sharing) |
| GET, PUT | `/groups` | Read or set group definitions |
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
| POST | `/synthetic/trace` | Stateless single-shot trace of an unsaved graph for the live-debug view — every node's value; stateful ops flagged `approx` |
| GET, POST | `/controllogic` | List or create server-side control-logic graphs |
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
| GET, POST | `/config/sets` | List or create configuration sets (schemas) |
| GET, PUT, DELETE| `/config/sets/{id}` | Read, update, or delete a config set |
| GET, POST | `/config/instances` | List or create configuration instances (values) |
| GET, PUT, DELETE| `/config/instances/{id}` | Read, update, or delete a config instance |
| POST | `/config/instances/{id}/apply` | Write an instance's values to their target signals |
| POST | `/config/instances/{id}/validate` | Run the set's CUE rules over the stored values; returns a structured `RuleResult` (no save) |
| GET | `/config/instances/{id}/livediff` | Diff the stored instance (resolved with defaults) against the current live signal values |
| POST | `/config/sets/{id}/snapshot` | Capture every target signal's current live value into a new instance (body: optional `{name}`) |
| GET | `/config/{sets\|instances}/diff` | Structural diff between two revisions (`a,av,b,bv`)|
| GET, POST | `/config/rules` | List or create CUE validation/transformation rules |
| GET, PUT, DELETE| `/config/rules/{id}` | Read, update, or delete a rule |
| POST | `/config/rules/check` | Compile an (unsaved) CUE source and evaluate it against sample values — powers the live editor |
**Versioning (shared).** Interfaces, synthetic signals, control-logic graphs and config
sets/instances all expose the same revision endpoints under their `{base}`:
| Method | Path | Description |
| ------ | ---- | ----------- |
| GET | `{base}/{id}/versions` | List revisions; `…/{version}` fetches one |
| POST | `{base}/{id}/versions/{v}/promote` | Promote a revision to current |
| POST | `{base}/{id}/versions/{v}/fork` | Fork a revision into a new document |
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a panel version |
Mutating requests are gated by the access middleware (§3.10): global level for writes,
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
endpoints and for any change to a panel's `<logic>` block. Config-set/instance
promote/fork are gated by the same write policy.
### 3.5 EPICS Data Source
- Uses CGo bindings to EPICS Base `libca` (Channel Access). PVAccess support via `p4p` C library or a pure-Go PVA client if available.
- Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it.
- Uses CGo bindings to EPICS Base `libca` (Channel Access).
- Channel connections are lazy: connected on first Subscribe, disconnected when the broker releases it.
- On connect, a `ca_get` retrieves full DBR_CTRL metadata (units, limits, enum strings).
- `ca_add_event` sets up the monitor. Update callbacks push into the broker's raw channel.
- Multiple PV subscriptions share one CA context per data source instance (thread-safe with `ca_attach_context`).
@@ -198,13 +270,51 @@ Base path: `/api/v1`
- Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes.
- Processing nodes are re-evaluated whenever any upstream signal emits a new value.
- Built-in node types implemented on top of `gonum/dsp` and custom code.
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table.
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
- Definitions are stored in a configurable JSON/TOML file alongside server configuration.
- The `dsp_bridge.go` file maps node type names to `dsp.Node` implementations.
**Built-in node types:**
| Node type | Parameters | In→Out | Description |
| ---------------- | --------------------------- | -------------- | ----------------------------------------------- |
| `source` | `ds`, `name` | — | Reads a signal from any data source |
| `gain` | `gain` | elementwise | Multiplies by a constant |
| `offset` | `offset` | elementwise | Adds a constant |
| `add`/`subtract` | — | elementwise | Sum of inputs / `a b` |
| `multiply`/`divide` | — | elementwise | Product of inputs / `a ÷ b` |
| `clamp` | `min`, `max` | elementwise | Constrains to a range |
| `threshold` | `threshold`, `high`, `low` | elementwise | Comparator output |
| `moving_average` | `window` (samples) | scalar-only | Rolling mean |
| `rms` | `window` (samples) | scalar-only | Rolling RMS |
| `derivative` | — | scalar-only | Time derivative (per-sample `dt`) |
| `integrate` | — | scalar-only | Trapezoidal integral |
| `lowpass` | `freq` (Hz), `order` (18) | scalar-only | Cascaded IIR Butterworth-style low-pass filter |
| `expr` | `expr`, `vars` | elementwise | Inline math expression (named inputs) |
| `lua` | `script`, `vars` | scalar-only | Arbitrary Lua 5.1 code with persistent state |
| `index` | `i` | array→scalar | Element `i` of a waveform (bounds-checked) |
| `slice` | `start`, `end` | array→array | Sub-range of a waveform (clamped) |
| `sum`/`mean` | — | array→scalar | Σ / average of a waveform |
| `min`/`max` | — | array→scalar | Reduction of a waveform |
| `length` | — | array→scalar | Element count of a waveform |
| `fft` | — | array→array | Magnitude spectrum (zero-padded to next pow-2) |
**Scalar vs waveform values:** A value flowing through the graph is a `dsp.Sample` — either a scalar `float64` or a `[]float64` waveform (the array-aware counterpart of EPICS `TypeFloat64Array`). *Elementwise* ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). *Reduction*/*producer* ops (`index`/`slice`/`sum`/…/`fft`) operate natively on waveforms. *Scalar-only* ops (stateful filters + `lua`) reject array inputs, since their per-evaluation state cannot be split across array lanes. `OpOutputType` (`internal/dsp/types.go`) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in `web/src/lib/synthTypes.ts` to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime `Sample` typing is authoritative.
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
**Lua node:** Receives `inputs` table (indexed by signal name) and a persistent `state` table across calls. The `os`, `io`, `package`, and `debug` libraries are disabled.
### 3.7 Interface Storage
Interfaces are stored as XML files in a configurable directory on the server.
Interfaces are stored as XML files in a configurable directory on the server. The
`<interface>` element carries an optional `kind` attribute (`panel` default, or `plot`);
plot panels add a nested `<layout>` split tree referencing plot widgets by id. Beyond
widgets, an interface may also contain panel-local variables and a `<logic>` flow graph
(`<node>` + `<param>` + `<wire>`), all round-tripping through the same XML.
Per-panel access rules and folder placement are kept in a sidecar `acl.json` in the same
directory (not in the interface XML), and prior versions of each panel are retained for the
version history endpoints.
```xml
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
@@ -215,6 +325,7 @@ Interfaces are stored as XML files in a configurable directory on the server.
<option key="yMin" value="auto"/>
<option key="yMax" value="auto"/>
<option key="timeWindow" value="60"/>
<option key="legend" value="bottom"/>
</widget>
<widget id="w2" type="led" x="50" y="50" w="80" h="80">
<signal ds="epics" name="EPICS:STATUS"/>
@@ -226,51 +337,331 @@ Interfaces are stored as XML files in a configurable directory on the server.
</interface>
```
### 3.8 Panel Logic Engine
Panel logic is a client-side flow engine (`web/src/lib/logic.ts`, singleton `logicEngine`).
The graph is built/edited in the panel editor's Logic tab (`LogicEditor.tsx`) and serialized
into the interface XML. In View mode, `Canvas` calls `logicEngine.load(iface.logic)` on
mount and `clear()` on unmount. The engine subscribes to every referenced signal into a live
cache, then on each trigger activation walks the wired graph (gates, if/loop, actions),
evaluating expression fields via a small safe recursive-descent evaluator (no `eval`).
Triggers include button/threshold/change/timer/loop and On-open/On-close lifecycle; actions
include signal writes, delay, log, array mutations (see below) and
user dialogs (info/error/set-point). The engine runs entirely in the browser — no backend
component.
**Array-valued local variables.** Panel-local variables (`<statevar>`) may be declared with
`type="array"`. An array statevar carries `elem` (`number`|`bool`|`array`, the element kind —
`array` gives a 2-D array), `sizing`, and `capacity`. The three sizing policies
(`web/src/lib/arraypolicy.ts`) are: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`,
oldest dropped past the cap), **capped** (length kept ≤ `capacity`, oldest dropped FIFO), and
**fixed** (exactly `capacity` elements — truncated or zero-padded). Sizing is enforced only at
store time (`writeLocalState``applySizing`); expressions themselves are pure and produce
unbounded values. The value model is the tagged union `ArrVal = number | ArrVal[]` with
booleans represented as `1`/`0` at the leaves.
The expression evaluator (`web/src/lib/expr.ts`) is value-polymorphic: `evalValue` returns an
`ArrVal`, while `evalExpr` returns a `number` (`NaN` if the result is an array). It supports
array literals `[a, b, c]`, indexing `a[i]` (negative indices count from the end), and a table
of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`, `add`,
`sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill` (plus
`min`/`max`, which accept either scalars or an array). These are pure — they return new values
and never mutate a stored variable.
Array mutation nodes write back to a declared array statevar: `action.array.push`, `…set`,
`…remove`, `…pop`, and `…clear`. The legacy `action.accumulate` and `action.clear` nodes are
retained as aliases over `action.array.push` / `action.array.clear`, and legacy graphs are
auto-migrated to declare their backing arrays on load (`ensureArrayDecls`). `action.export`
now produces **index-aligned** CSV: each configured array becomes a column (custom per-column
labels supported), rows aligned by element index. Array statevars and all array nodes
round-trip through the panel XML (`<statevar type="array" elem=… sizing=… capacity=…>`).
> **Note:** The above is Phase 1 (panel logic, client-side TypeScript). The equivalent array
> support in the server-side control-logic engine (`internal/controllogic`) is Phase 2 and has
> now landed — see the **Array-valued local variables** subsection of §3.9 below.
### 3.9 Control Logic Engine
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
the root context, independent of any client. The engine subscribes to the broker, fires on
cron schedules and signal alarm/threshold conditions, executes a Lua block for custom logic,
and writes results back to signals. Graphs are persisted by a store and managed via
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
can be individually enabled/disabled.
**Array-valued local variables (Phase 2).** Engine locals now carry a `Value` — the tagged
union `Value = float64 | []Value` (`internal/controllogic/value.go`), the Go port of the
panel-logic `ArrVal` model, with booleans represented as `1`/`0` at the leaves. A graph may
declare `statevars`: each has a `Name`, a `Type` (`number` | `bool` | `array`), an `Initial`
expression, and — for arrays — a `Sizing` policy and `Capacity`. The three sizing policies
mirror the frontend: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`, oldest dropped
FIFO past the cap), **capped** (length kept ≤ `Capacity`, oldest dropped FIFO), and **fixed**
(exactly `Capacity` elements — truncated or zero-padded). Sizing is enforced on write. State
vars are persisted in the graph's store JSON and seeded into locals at compile time.
The expression evaluator (`internal/controllogic/expr.go`) is value-polymorphic: it supports
array literals `[a, b, c]`, indexing `arr[i]` (negative indices wrap from the end), and a
table of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`,
`add`, `sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill`
(plus `min`/`max`, which accept either scalars or an array). These are pure — they produce new
values and never mutate a stored local. Five action nodes write back to a declared array
statevar: `action.array.push`, `…set`, `…remove`, `…pop`, and `…clear`. The embedded Lua block
remains **scalar-only**: reading an array local from Lua yields `NaN`. CSV export
(`action.export`) is panel-logic-only and is **not** available in control logic.
The engine also holds a `*confmgr.Store` (injected via `NewEngine`) for five config action
nodes: `action.config.apply` resolves an instance + its set and runs `confmgr.Apply` with a
broker-backed, audited write closure; `action.config.read` resolves a single parameter value
(`ConfigInstance.Resolve`), coerces it to float64 and writes it to the node's target;
`action.config.write` evaluates an expression and stores the (type-coerced, via
`coerceParamValue`) value into a parameter, creating a new instance revision;
`action.config.create` creates a new instance for a set, optionally seeding its values from
another instance; and `action.config.snapshot` reads every target signal's current live value
(via the one-shot `Broker.ReadNow`, type-coerced by `confmgr.Snapshot`) and stores them as a
new instance. All mutating nodes are audited. The panel-logic engine
(`web/src/lib/logic.ts`) mirrors all five client-side via the REST endpoints
(`/config/instances/{id}/apply`, GET/PUT `/config/instances/{id}`, POST `/config/instances`,
POST `/config/sets/{id}/snapshot`).
Panel-logic apply/read/write nodes additionally support an `instanceSource: 'var'` mode that
reads the target instance id (a string) from a panel-local variable rather than a fixed id —
fed by the **Config Selector** widget (`web/src/widgets/ConfigSelect.tsx`), which lists a
set's instances in a combo and writes the chosen id to that variable. Control-logic nodes use
fixed ids only (its variables are numeric, instance ids are strings). To let the selector
filter instances by set without a GET per instance, `confmgr.Store.List` now returns each
instance's `setId` in its `Meta`.
### 3.10 Access Control
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
identity is read per request from `server.trusted_user_header` (with a `default_user`
fallback) and stored on the request context. Alternatively, **native SPNEGO/Kerberos**
authentication (`[server.kerberos]`) lets uopi identify users directly from their Kerberos
ticket without a reverse proxy: `internal/server/kerberos.go` wraps the REST and WebSocket
handlers, challenges API requests with `401 WWW-Authenticate: Negotiate`, validates the
ticket against the configured service keytab (`github.com/jcmturner/gokrb5`), and writes the
short principal name (realm stripped) into the same `userHeader` the access pipeline reads
— so downstream identity resolution is identical. Any client-supplied header value is
discarded before validation to prevent spoofing. WebSocket upgrades (where browsers cannot
attach an `Authorization` header) validate proactively-sent credentials best-effort and
otherwise fall back to `default_user`. Browsers must be configured to perform SPNEGO for the
server origin (Firefox: `network.negotiate-auth.trusted-uris`), which fixes the case where
Firefox would otherwise resolve to `default_user`. A third standalone option is **built-in
HTTP Basic authentication** (`[server.basic_auth]`, mutually exclusive with Kerberos):
`internal/server/basicauth.go` challenges with `401 WWW-Authenticate: Basic` and validates
credentials against the host PAM stack (`internal/pamauth`, `/etc/pam.d/<pam_service>`), so
on an SSSD/LDAP-joined host users authenticate with their normal login password and no
directory schema is needed in uopi. Successful logins are memoised in a short-TTL salted-hash
cache (`credCache`) to avoid a PAM round-trip per request, and the validated username is
written into the same `userHeader`. PAM requires a cgo build (`make backend-pam`, build tag
`pam`); the default static binary uses a stub that refuses Basic auth. As a **pure-Go**
alternative that keeps the fully-static (`CGO_ENABLED=0`) binary, `[server.ldap]`
(`internal/ldapauth`) validates the same Basic credentials against an LDAP directory with a
"search then bind" — anonymously (or via a service `bind_dn`) locate the user entry under
`search_base`, then bind as its DN with the supplied password — mirroring an SSSD/LDAP
client (defaults: `user_attr=uid`, `user_object_class=posixAccount`). Empty passwords are
rejected before any bind to avoid LDAP "unauthenticated bind", and the login name is
filter-escaped against injection. The challenge front-end is shared: both PAM and LDAP feed
the same `basicAuth` middleware, and the page load (`/`) is challenged too so the browser's
native login dialog actually appears (a background `fetch('/me')` 401 does not prompt).
Because Basic credentials are sent on every request, uopi can terminate **TLS** itself
(`[server.tls]``ListenAndServeTLS`) without a reverse proxy; an optional
`redirect_from` plain-HTTP listener 301-redirects `http://` visitors to the HTTPS service
so they are upgraded instead of hitting the TLS port with cleartext ("client sent an HTTP
request to an HTTPS server"). The four identity sources
(proxy header, Kerberos, PAM-Basic, LDAP-Basic) all resolve to the same `userHeader` and are
mutually exclusive where they overlap. Access is **role-based** through group
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
capability is the highest role across all their memberships. Roles map to capabilities as:
operator+ → write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`);
logiceditor+ → `CanEditLogic`; auditor+ → `CanViewAudit`; admin → `CanAdmin`.
`accessMiddleware` gates mutating HTTP methods by the derived global level. Per-panel
ownership and ACL evaluation (with folder inheritance) live in `internal/panelacl`, backed
by the `acl.json` sidecar.
A built-in **public** group is always present: every user (and anonymous) is an implicit
viewer member, so an identified caller is at least read-only. Groups may **nest** via a
`parent` pointer (a forest rooted at top-level groups); a member of a parent group inherits
its role on every descendant group too, unless overridden lower. Cycles are rejected
(offending parent dropped to root), and the public group is protected from rename, reparent,
and delete. As a bootstrap convenience, a `Policy` with **no roles assigned anywhere** is
treated as unconfigured → fully open (everyone is admin), matching trusted-LAN/dev use;
assigning any role switches to strict mode where unlisted users are read-only viewers.
The capability checks (`CanEditLogic`, `CanViewAudit`, `CanAdmin`) are surfaced to the
frontend through `/api/v1/me` (`canEditLogic`, `canViewAudit`, `canAdmin`) so the UI can
hide affordances. The `Policy` is seeded from the TOML config at startup but is
**runtime-mutable** through the admin pane. `Policy.EnablePersistence(storageDir)` points
it at an `access.json` sidecar; once any admin mutation is made, that file is written (tmp +
atomic rename) and, on a later startup, supersedes the TOML access config (which then only
bootstraps an empty install). All policy state is guarded by an `RWMutex` (reads share,
mutations are exclusive and persist), keeping the shared `*Policy` pointer wiring intact.
The admin REST routes live under `/api/v1/admin/*` (all `requireAdmin`-gated):
`GET /admin/access` returns an `AccessSnapshot` (users with effective role + per-group
roles, groups with members and parents, the role ladder, and the configured flag);
`PUT /admin/users/{user}` replaces a user's full set of per-group roles (body
`{roles: {group: role}}`, creating missing groups); `POST|PUT|DELETE /admin/groups[/{name}]`
create (name + optional parent), rename + set parent and member roles, and delete groups;
and `GET /admin/stats` reports live server statistics (the `internal/metrics` counters via
`metrics.Snapshot`, the broker's observed-signal count and data-source list, Go runtime
stats, and the Linux `/proc/loadavg` load average). The frontend `AdminPane.tsx` (a modal
opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-group role
assignment with an effective-role badge), Groups (nesting + per-member roles), and
Server-stats tabs.
#### Visibility scope (selector-tree filtering)
Every user-owned, list-able object — panels, synthetic signals, config sets/instances, and
control-logic graphs — carries a uniform **visibility scope** so each selector tree can be
filtered by **Mine / Group / Global**. The model is `owner` (stamped server-side from the
trusted identity on create, immutable across updates) plus a scope token
`∈ {private, group, global}` and, for group scope, a list of group names. An empty or
unknown token resolves to **global**, so legacy objects with no scope stay visible to
everyone. This is a *visibility filter*, not a hard security boundary: an owner always sees
their own objects regardless of scope, and the per-panel ACL (§3.10) remains the real
access-control mechanism for panels.
The shared backend helper is `access.CanSee(user, owner, scope, itemGroups, userGroups)`
(`internal/access/scope.go`), with a `(*Policy).CanSee` method that resolves the caller's
groups via `GroupsOf`. Each list endpoint filters its results through it:
`internal/confmgr` stores `owner/scope/groups` on sets and instances (filtered by
`filterConfigMetas`); synthetic `SignalDef` gained a `group` visibility mode routed through
`synVisible`; control-logic `Graph` carries `owner/scope/scopeGroups` (named to avoid the
pre-existing cosmetic `Groups []NodeGroup`) filtered in `listControlLogic`. Panels reuse the
existing ACL rather than a new field: `panelScope` (`internal/api/api.go`) derives the
bucket from the ACL record (public → global, a group grant → group, otherwise → private;
unmanaged → global) and surfaces it on `InterfaceListItem.scope`/`groups`.
The shared frontend lib is `web/src/lib/scope.tsx`: `bucketOf` assigns an item to exactly
one bucket (owned → mine, else group-scoped → group, else global), `filterByScope` narrows a
list to the active bucket (with an optional groups-accessor for control-logic's
`scopeGroups`), `ScopeFilter` is the segmented `[Mine | Group ▾ | Global]` selector shown
above each tree (the Group segment carries a combo to pick a group when the user is in
several), and `ScopePicker` is the create/save visibility editor. `ConfigManager.tsx`,
`SyntheticGraphEditor.tsx`, and `ControlLogicEditor.tsx` use the picker to set scope on save;
panel visibility is instead edited through the existing Share dialog. `InterfaceList.tsx`
applies the filter to its folder tree, hiding folders that have no in-scope descendant.
### 3.11 Configuration Manager
The configuration manager is `internal/confmgr`: a two-tier model of **sets** (typed
parameter schemas binding target signals) and **instances** (values for a chosen set).
`model.go` defines `ConfigSet`/`Parameter`/`ConfigInstance`; `apply.go` validates an
instance against its set (`checkValue`) and writes each value to its target signal,
returning a per-parameter apply report; `diff.go` produces the structural per-parameter
diff used by the `/config/{sets,instances}/diff` endpoints.
`store.go` persists each object as `configs/{sets,instances,rules}/{id}.json`, with superseded
revisions backed up as `{id}.vN.json` alongside — the same git-style scheme as panels and
synthetic/control-logic, so `Versions`/`GetVersion`/`Promote`/`Fork` behave identically.
`Delete` is non-destructive: the object and all its backups are moved to a timestamped
`trash/configs/…` folder.
**CUE rules (`cue.go`, `KindRule`).** A third versioned object type carries a CUE source
(`cuelang.org/go`) bound to a set via `SetID`. `EvaluateRule` compiles the source, unifies it
with the instance values (`ctx.Encode`), and validates with `cue.Concrete(true)`: regular
fields whose key matches a parameter constrain its value (failures become `RuleViolation`s),
while concrete derivations whose value differs from the input are reported (and persisted) as
**transformations**; hidden fields `_x` and definitions `#X` are helpers, excluded from both.
`CreateInstance`/`UpdateInstance` call `applyRules` after the structural `ValidateAgainst`:
all rules bound to the set are run in order (`evaluateRules`), a violation aborts the save as
a `*RuleError`, and successful transformations are merged back into the stored values (set
parameters only). `ValidateInstanceRules` is the read-only counterpart behind
`/config/instances/{id}/validate`; `EvaluateRule` is exposed directly via
`/config/rules/check` for the live editor.
### 3.12 Document Versioning
Versioning is implemented per storage layer but follows one shared contract: the live
revision lives in the primary file; each save backs up the previous revision as
`{id}.v{N}.{ext}`; `VersionMeta{Version,Name,Tag,Current,SavedAt}` describes each. `Promote`
re-saves an older revision on top (creating a new current revision, never destroying
history); `Fork` writes the revision out under a fresh id with its version reset to 1. The
frontend `web/src/VersionHistory.tsx` (`VersionTree` + `DiffViewer`) consumes these
generically; line diffs are computed client-side in `web/src/lib/linediff.ts`, while
config sets/instances use the backend structural diff instead. Config **rules** reuse the
generic client-side `DiffViewer` (line diff over the source).
---
## 4. Frontend Architecture
### 4.1 WebSocket Client (`ws.ts`)
### 4.1 WebSocket Client (`lib/ws.ts`)
- Singleton WebSocket connection, reconnects with exponential back-off.
- Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message.
- Incoming updates are dispatched to signal stores.
- Incoming updates are dispatched to per-signal stores.
- `wsClient.history(sig, start, end, maxPoints)` returns a Promise resolving to timestamped point arrays.
### 4.2 Signal Stores (`stores.ts`)
### 4.2 Signal Stores (`lib/stores.ts`)
```typescript
// One writable store per subscribed signal
const signalStores = new Map<string, Writable<SignalValue>>();
// One nanostores atom per subscribed signal
const signalStores = new Map<string, SignalStore>();
function getStore(signal: string): Readable<SignalValue> { ... }
export function getSignalStore(ref: SignalRef): SignalStore { ... }
export function getMetaStore(ref: SignalRef): MetaStore { ... }
```
Widgets import `getStore(signalName)` and bind to it reactively. Svelte's fine-grained reactivity ensures only the relevant widgets re-render on each update.
Widgets subscribe to stores directly; store updates trigger re-renders only in the consuming component.
### 4.3 Edit Mode Canvas
### 4.3 Edit Mode Canvas (`EditCanvas.tsx`)
The edit-mode canvas is implemented with **Konva.js** via `svelte-konva`:
The edit canvas is a free-form HTML div with absolutely positioned widget components:
- Each widget is a Konva Group containing its visual elements.
- A `Transformer` node provides resize handles and enforces minimum sizes.
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from `stage.getPointerPosition()`.
- Undo/redo uses a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100).
- Align/distribute operations compute target positions geometrically and generate a single grouped undo entry.
- Each widget renders as an absolutely positioned `<div>` at `(x, y)` with `(w, h)` dimensions.
- Selection shows a CSS-outlined bounding box with 8 resize handles rendered as small squares.
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from the drop event offset.
- Undo/redo uses an array of past interface snapshots (max depth 50).
- Align/distribute operations compute target positions geometrically and generate a single undo entry.
- Multi-select via Ctrl+click or rubber-band area select.
### 4.4 Widget Rendering in View Mode
### 4.4 Widget Rendering in View Mode (`Canvas.tsx`)
In view mode the Konva canvas is replaced with a lightweight SVG/HTML layer. Each widget is a Svelte component that:
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
1. Subscribes to its signal store(s) in `onMount`.
2. Receives reactive updates and re-renders only its own DOM subtree.
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser, waveform) manage their own canvas elements inside their widget component. The `waveform` plot renders a waveform (array) signal's latest `[]float64` as an x-vs-index trace, replacing the trace on each update.
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own canvas elements inside the Svelte component.
### 4.5 Plot Panels (`SplitLayout.tsx`, `PlotPanelCanvas.tsx`)
### 4.5 DPI Adaptation
Plot panels are interfaces of `kind === 'plot'` whose plots fill the viewport in a recursive
split layout (`lib/plotLayout.ts` holds the pure tree helpers; `SplitLayout.tsx` is the
shared presentational renderer with draggable dividers):
- CSS uses `rem` units throughout for text.
- Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed.
- Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`.
- In View mode, `Canvas` renders the saved layout read-only with live `PlotWidget`s.
- In Edit mode, `PlotPanelCanvas.tsx` adds per-pane overlays: split (⬌/⬍), close (✕), click
to select, and signal-drop to add a signal to that pane's plot.
- Each pane reuses the standard `PlotWidget`, so all plot sub-types and options apply.
- Layout edits (split/close/resize) participate in the editor's undo/redo.
### 4.6 Resizable Panels
Both edit and view modes support mouse-drag panel resizing:
- **View mode**: drag handle between the interface list pane and the main content area.
- **Edit mode**: drag handles on both sides of the central canvas (signal tree ↔ canvas, canvas ↔ properties pane).
- Handle width: 5 px, cursor changes to `ew-resize` on hover.
- Minimum panel widths enforced to prevent collapse below usable size.
### 4.7 HiDPI / Zoom Support
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
- **ZoomControl** (A / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
- The root font-size also folds in `window.devicePixelRatio` (`applyZoom``16 × zoom × dpr`) so high-DPI displays auto-scale the UI by default; Firefox in particular does not enlarge the root px on its own, so the DPR must be applied explicitly. `watchDpr()` re-applies the zoom when the ratio changes (e.g. the window moves to a differently-scaled monitor).
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
### 4.8 Lua Editor (`LuaEditor.tsx`)
A syntax-highlighted code editor for Lua scripts in the Synthetic signal wizard:
- Implemented as a `<textarea>` overlaid on a `<pre>` element; the textarea has `color: transparent; caret-color: #e2e8f0` so only the caret is visible — the `<pre>` provides the coloured text behind it.
- Tokeniser handles: `--` line comments, `"..."` / `'...'` string literals, `[[...]]` long strings, hex and float numeric literals, and all Lua 5.1 keywords.
- Scroll position is synchronised between textarea and pre on every scroll event.
---
@@ -278,34 +669,42 @@ Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own ca
### 5.1 Backend
```makefile
# Build static binary (requires EPICS base installed or cross-compiled libca)
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
```bash
# Full build (frontend then backend)
make all
# Run tests
go test ./...
# Backend only (frontend must already be built)
make backend
# Run a single test
# All tests
make test
# Single Go test
go test ./internal/broker/... -run TestFanOut
# Go vet
go vet ./...
```
EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`.
### 5.2 Frontend
The frontend is built by a Go tool in `tools/buildfrontend/` that invokes the esbuild Go API:
```bash
cd web
npm install
npm run dev # dev server at http://localhost:5173 (proxies /api to backend)
npm run build # outputs to web/dist/
npm run check # svelte-check type checking
npm run lint # eslint + prettier
make frontend
# or equivalently:
go generate ./web/...
```
### 5.3 Combined Build
No Node.js, npm, or any JS build tool is required on the host. The bundler:
- Reads entry point `web/src/main.tsx`.
- Resolves `preact`, `uplot`, and `echarts` imports from `web/vendor/`.
- Outputs `web/dist/main.js` and `web/dist/main.css`.
- Copies `web/dist/index.html`, vendor CSS, and other static assets.
A `Makefile` at the repo root:
### 5.3 Combined Build
```makefile
.PHONY: all frontend backend clean
@@ -313,20 +712,19 @@ A `Makefile` at the repo root:
all: frontend backend
frontend:
cd web && npm ci && npm run build
go run ./tools/buildfrontend
backend: frontend
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
backend:
CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
test:
go test ./...
cd web && npm run check
go test ./...
clean:
rm -rf dist/ web/dist/
rm -rf dist/ web/dist/
```
The backend's `//go:embed web/dist` directive picks up the built frontend automatically.
The backend's `//go:embed dist` directive in `web/embed.go` picks up the built frontend automatically. `main.go` does `fs.Sub(web.FS, "dist")` to serve a clean root.
---
@@ -339,17 +737,71 @@ Server is configured via a TOML file (default: `uopi.toml`, overridable via `--c
listen = ":8080"
storage_dir = "./interfaces"
# Access control (all optional)
trusted_user_header = "" # header carrying the proxy-authenticated user
default_user = "" # identity when the header is absent (LAN/dev)
# Native SPNEGO/Kerberos auth — alternative to a proxy; identifies users from
# their Kerberos ticket. Recommended when some browsers (e.g. Firefox) would
# otherwise fall through to default_user.
# [server.kerberos]
# enabled = true
# keytab = "/etc/uopi/http.keytab" # HTTP/host@REALM service key
# service_principal = "HTTP/host.example.com" # optional; empty = keytab default
# Built-in HTTP Basic auth (PAM) — standalone, mutually exclusive with Kerberos.
# Requires a PAM build: `make backend-pam`. Enable TLS below for production.
# [server.basic_auth]
# enabled = true
# pam_service = "uopi" # /etc/pam.d/<name>; empty = "uopi"
# Built-in HTTP Basic auth (LDAP) — pure-Go, works in the static binary; search
# then bind against the directory. Mutually exclusive with kerberos/basic_auth.
# [server.ldap]
# enabled = true
# uri = ["ldaps://ldap.example.com"]
# search_base = "dc=example,dc=com"
# user_attr = "uid" # "sAMAccountName" for AD; empty = uid
# bind_dn = "" # empty = anonymous search
# Built-in TLS/HTTPS — terminate HTTPS without a reverse proxy. Recommended
# whenever basic_auth is enabled. Both cert and key required when enabled.
# [server.tls]
# enabled = true
# cert = "/etc/uopi/tls/cert.pem"
# key = "/etc/uopi/tls/key.pem"
# Role-based access through group memberships. Roles (low→high):
# viewer < operator < logiceditor < auditor < admin
# Effective capability = highest role across all memberships. The built-in
# "public" group makes every user an implicit viewer. Groups may nest via
# "parent" (members inherit their role on descendants). No roles anywhere = open
# (everyone admin); once set, unlisted users are read-only viewers.
# [[groups]]
# name = "public"
# admins = ["alice"] # alice is a global admin
# [[groups]]
# name = "operations"
# operators = ["bob"]
# auditors = ["carol"]
# [[groups]]
# name = "engineers"
# parent = "operations" # bob inherits operator here
# logiceditors = ["dave"]
# viewers = ["erin"]
[datasource.epics]
enabled = true
ca_addr_list = "" # EPICS_CA_ADDR_LIST override
archive_url = "" # EPICS Archive Appliance URL
channel_finder_url = "" # EPICS Channel Finder URL
[datasource.synthetic]
enabled = true
definitions_file = "./synthetic.json"
```
All settings can also be overridden with environment variables: `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`, etc.
All settings can also be overridden with `UOPI_*` environment variables (e.g.
`UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`).
---
@@ -359,11 +811,11 @@ All settings can also be overridden with environment variables: `UOPI_SERVER_LIS
| ------------------ | --------------------------------------------------------------------------------- |
| Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle |
| Synthetic DSP | Table-driven unit tests against known signal inputs/outputs |
| Low-pass filter | Unit tests: step response, frequency attenuation vs. analytical expectation |
| Lua sandbox | Unit tests for sandbox isolation and API surface |
| REST API | `httptest` integration tests |
| WebSocket protocol | Integration tests with a test client |
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
| Frontend | Svelte component tests via `vitest` + `@testing-library/svelte` |
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
---
@@ -372,13 +824,21 @@ All settings can also be overridden with environment variables: `UOPI_SERVER_LIS
- Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets.
- WebSocket write operations: validate that the target signal is writable before forwarding to the data source.
- Interface XML parsing: use strict schema validation to prevent XXE.
- No authentication in v1; intended for trusted LAN / SSH-tunnel deployment.
- **Identity & access control:** the end-user identity is taken from a header set by a
trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied
values — the proxy MUST strip any inbound copy of that header or it can be spoofed.
Authorisation is role-based through group memberships (viewer/operator/logiceditor/
auditor/admin), with the highest role across memberships deciding global capability;
per-panel ACLs provide finer per-panel control. When **no** roles are assigned anywhere
the deployment is fully open (everyone admin), preserving the unproxied/SSH-tunnel/dev
model; once any role is set, unlisted and anonymous callers are read-only viewers.
---
## 9. Non-goals (v1)
- User authentication and authorisation.
- Built-in user authentication (a login page / credential store) — identity is delegated to
the front-end reverse proxy; authorisation (levels, ACLs, logic allowlist) is implemented.
- TLS termination (expected to be handled by SSH tunnel or a reverse proxy).
- Windows or macOS server binary.
- Mobile-optimised frontend layout.
+87
View File
@@ -0,0 +1,87 @@
# Test Coverage Report — uopi
**Date:** 2026-06-24
**Branch:** develop
**Task:** #167 — Raise backend coverage toward 90%
**Status:** All suites green — `go test ./... -race`, `go vet ./...`, and `gofmt -l` clean.
## Overall
- **Main module total coverage: 67.7%** of statements.
- **38 test files, 218 test/bench/fuzz functions** in the main module.
- **27 new test files** added across the coverage initiative.
- 3-module `go.work` workspace — each module is tested independently (`go test ./...`
from the root only exercises the main module; `pkg/ca` and `pkg/pva` must be tested
by `cd`-ing into them).
## Main module (`github.com/uopi/uopi`) — by package
| Coverage | Package | Notes |
|---|---|---|
| 100.0% | `internal/config` | |
| 100.0% | `internal/pamauth` | stub (non-PAM build) |
| 97.1% | `internal/metrics` | |
| 96.3% | `internal/broker` | signal fan-out core |
| 89.2% | `internal/panelacl` | |
| 89.0% | `internal/audit` | |
| 85.5% | `internal/access` | |
| 82.6% | `internal/storage` | |
| 82.4% | `internal/datasource/stub` | |
| 81.7% | `internal/confmgr` | |
| 81.0% | `internal/dsp` | |
| 79.1% | `internal/datasource/servervar` | |
| 72.9% | `internal/datasource/synthetic` | |
| 67.2% | `internal/api` | large handler surface |
| 66.6% | `internal/controllogic` | engine/Lua/cron uncovered |
| 55.6% | `internal/datasource` | iface + ctx helpers |
| 34.1% | `internal/server` | HTTP/WebSocket |
| 33.9% | `internal/ldapauth` | network-bound |
| 33.6% | `internal/datasource/epics` | CGo/libca-bound |
| 0.0% | `internal/datasource/pva` | network-bound |
| 0.0% | `cmd/uopi`, `cmd/catools`, `cmd/pvtools`, `tools/buildfrontend` | mains — no tests |
## Workspace modules
| Module | Coverage |
|---|---|
| `pkg/ca` (goca) | **83.9%** root · 90.0% `proto` · `testca` 0% (test harness) |
| `pkg/pva` (gopva) | 85.5% `pvdata` · 12.1% root (network client) |
## Remaining gaps toward 90%
The lowest packages are all **I/O- or platform-bound**, needing integration harnesses
rather than unit tests:
- `server` (34%) — HTTP/WebSocket handlers.
- `ldapauth` (34%) — needs a mock LDAP server.
- `datasource/epics` (34%) — CGo `libca` linkage.
- `datasource/pva` (0%) — needs a PVA test server (analogous to `testca` for CA).
- `cmd/*` mains — typically excluded from coverage targets.
Pure-logic packages are now in the 80100% range. The biggest realistic remaining
wins are `api` (67%) and `controllogic` (67%), where the uncovered code is the
control-logic **engine** (Lua runtime, cron scheduling, dialog emission) and the
network-dependent API handlers (channelFinder, archiverSearch).
## Coverage gains (this initiative)
| Package | Before | After |
|---|---|---|
| `internal/api` | 53.8% | 67.2% |
| `internal/audit` | 75.3% | 89.0% |
| `internal/broker` | 78.9% | 96.3% |
| `internal/panelacl` | 65.9% | 89.2% |
| `internal/confmgr` | 73.4% | 81.7% |
| `internal/dsp` | 66.3% | 81.0% |
| `internal/datasource` | 0% | 55.6% |
| `internal/pamauth` | 0% | 100% |
| `pkg/ca` | 82.2% | 83.9% |
## Notes
- Two inherently racy assertions were deliberately dropped (broker cancelled-context
`ReadNow`; audit closed-channel synchronous fallback): both depended on
nondeterministic `select` ordering and flaked under `-race`. The surrounding code
paths remain covered by other tests.
- All new test files are `gofmt`-clean, matching the CI gate
(`gofmt -l $(git ls-files '*.go')`).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,779 @@
# Local Array Values — Phase 1 (Panel Logic, TypeScript) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add first-class array-valued local variables to the panel-logic (client TS) flow engine — declaration with dynamic/capped/fixed sizing, an array-aware expression language, mutation nodes (unifying the legacy accumulate/export/clear arrays), persistence, and binding from the plot / table / multi-LED widgets.
**Architecture:** The expression evaluator (`expr.ts`) becomes value-polymorphic — `EvalValue = number | EvalValue[]` (tagged union, "Approach 1"). Array locals are declared as a new `type:'array'` `StateVar` and held per-panel in `localstate.ts`, which enforces the sizing policy on every write. Mutation is performed by new `action.array.*` nodes; reads/transforms are pure functions in the expression language. Widgets read array locals through the existing `ds:'local'` plumbing.
**Tech Stack:** Preact 10 + TypeScript, bundled by esbuild via the Go tool `tools/buildfrontend/main.go` (no npm/node). Stores are the hand-rolled `web/src/lib/store.ts` writable/readable primitives.
## Global Constraints
- No npm / no Node.js. Frontend builds **only** via `make frontend` (esbuild, pure Go). There is **no JS test runner and no typecheck gate** — esbuild strips types without checking them. Phase-1 verification = `make frontend` builds clean + the manual smoke checklist in Task 9.
- The authoritative automated tests for the shared array semantics live in **Phase 2 (Go `expr.go`)**, not here. Keep `expr.ts` semantics documented precisely so the Go port can mirror them exactly.
- Booleans are numbers (`1`/`0`) at array leaves; `elem:'bool'` is display metadata only.
- Expressions are **pure** (no side effects). Mutator-named functions (`push/set/insert/remove/pop/shift`) return **new** arrays; the sizing policy is enforced only at store time in `localstate.ts`.
- Dynamic arrays are guarded by a global safety cap `ARRAY_MAX = 1_000_000` elements.
- Preserve backward compatibility: existing `accumulate`/`clear`/`export` nodes keep working via aliasing + auto-declared locals.
- Follow existing file conventions; commit frequently with `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`.
---
## File Structure
- `web/src/lib/types.ts` — extend `StateVar` with array fields. (Modify)
- `web/src/lib/expr.ts` — polymorphic value model, array literals, indexing, array functions. (Modify — the core change)
- `web/src/lib/localstate.ts` — array init from `initial`, sizing-policy enforcement on write, array metadata. (Modify)
- `web/src/lib/arraypolicy.ts`**new**: pure helpers `applySizing(value, sv)` and `parseInitialArray(sv)` shared by `localstate.ts` and `logic.ts`. (Create)
- `web/src/lib/logic.ts` — new `action.array.*` node handlers; accumulate/clear aliasing; export-by-index with custom headers; auto-declare migration in `load()`. (Modify)
- `web/src/lib/types.ts` `LogicNodeKind` — add the new node kinds. (Modify, same file as above)
- `web/src/lib/xml.ts` — round-trip the new `<statevar>` array attributes. (Modify)
- `web/src/LogicEditor.tsx``LocalVars` array declaration form + array node palette entries + inspectors. (Modify)
- `web/src/lib/flowDebug.ts` — compact array stringify for node value badges. (Modify)
- `web/src/widgets/PlotWidget.tsx`, `web/src/widgets/TableWidget.tsx`, `web/src/widgets/MultiLed.tsx` (exact filenames verified in Task 7) — array source modes. (Modify)
---
## Task 1: Extend `StateVar` with array fields
**Files:**
- Modify: `web/src/lib/types.ts` (the `StateVar` interface, ~lines 69-76)
**Interfaces:**
- Produces: `StateVar` with new optional fields `elem?: 'number'|'bool'|'array'`, `sizing?: 'dynamic'|'capped'|'fixed'`, `capacity?: number`, and `type` union extended with `'array'`.
- [ ] **Step 1: Extend the interface**
In `web/src/lib/types.ts`, change the `StateVar` interface to:
```ts
export interface StateVar {
name: string;
type?: 'number' | 'bool' | 'string' | 'array';
initial: string;
unit?: string;
low?: number;
high?: number;
// array-only (present when type === 'array'):
elem?: 'number' | 'bool' | 'array';
sizing?: 'dynamic' | 'capped' | 'fixed';
capacity?: number;
}
```
- [ ] **Step 2: Build**
Run: `make frontend`
Expected: `Frontend built successfully → …/web/dist`
- [ ] **Step 3: Commit**
```bash
git add web/src/lib/types.ts
git commit -m "feat(logic): add array fields to StateVar type"
```
---
## Task 2: Array sizing-policy helpers (`arraypolicy.ts`)
**Files:**
- Create: `web/src/lib/arraypolicy.ts`
**Interfaces:**
- Consumes: `StateVar`, `EvalValue` (Task 3 finalizes `EvalValue`; here use `type EvalValue = number | EvalValue[]` locally re-exported from `expr.ts` once Task 3 lands — for ordering, define the alias in this file and have `expr.ts` import it).
- Produces:
- `export type ArrVal = number | ArrVal[];`
- `export const ARRAY_MAX = 1_000_000;`
- `export function parseInitialArray(sv: StateVar): ArrVal[]` — initial contents for an array local.
- `export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[]` — enforce dynamic/capped/fixed on a candidate array value.
- [ ] **Step 1: Create the module**
```ts
// web/src/lib/arraypolicy.ts
// Pure helpers for array-valued local state: parse the declared initial value
// and enforce the declared sizing policy (dynamic / capped / fixed). Shared by
// localstate.ts (write path) and logic.ts (node handlers + migration).
import type { StateVar } from './types';
export type ArrVal = number | ArrVal[];
export const ARRAY_MAX = 1_000_000;
// zeroFill builds a length-n array of zeros (flat; fixed nested init must come
// from an explicit `initial` literal).
function zeroFill(n: number): ArrVal[] {
return new Array(Math.max(0, n)).fill(0);
}
// parseInitialArray returns the starting contents of an array local.
export function parseInitialArray(sv: StateVar): ArrVal[] {
const cap = sv.capacity ?? 0;
const raw = (sv.initial ?? '').trim();
let parsed: ArrVal[] | null = null;
if (raw) {
try {
const j = JSON.parse(raw);
if (Array.isArray(j)) parsed = j as ArrVal[];
} catch { parsed = null; }
}
if (sv.sizing === 'fixed') {
if (!parsed) return zeroFill(cap);
// truncate / zero-pad to capacity
const out = parsed.slice(0, cap);
while (out.length < cap) out.push(0);
return out;
}
return parsed ?? [];
}
// applySizing returns arr clamped to the declared policy.
// dynamic → unchanged (but globally capped at ARRAY_MAX, dropping oldest)
// capped → keep at most capacity elements, dropping oldest (ring/FIFO)
// fixed → exactly capacity elements (truncate / zero-pad); never grow/shrink
export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[] {
const cap = sv.capacity ?? 0;
switch (sv.sizing) {
case 'fixed': {
const out = arr.slice(0, cap);
while (out.length < cap) out.push(0);
return out;
}
case 'capped':
return arr.length > cap ? arr.slice(arr.length - cap) : arr;
default:
return arr.length > ARRAY_MAX ? arr.slice(arr.length - ARRAY_MAX) : arr;
}
}
```
- [ ] **Step 2: Build**
Run: `make frontend`
Expected: builds clean (module compiles; not yet imported anywhere).
- [ ] **Step 3: Commit**
```bash
git add web/src/lib/arraypolicy.ts
git commit -m "feat(logic): add array sizing-policy helpers"
```
---
## Task 3: Make `expr.ts` value-polymorphic
This is the core change. `Resolver` and the evaluator move from `number` to `ArrVal = number | ArrVal[]`. Add array-literal and indexing syntax plus the array function set. Keep all existing scalar behavior identical (numbers, booleans-as-1/0, operators, ternary, the existing math funcs).
**Files:**
- Modify: `web/src/lib/expr.ts` (whole file — see current contents)
- Modify import in: any caller of `Resolver`/`evalExpr` that assumed a `number` return — audit in Step 5.
**Interfaces:**
- Consumes: `ArrVal` from `./arraypolicy`.
- Produces:
- `export type Resolver = (ds: string, name: string) => ArrVal;`
- `export function evalValue(src: string, resolve: Resolver): ArrVal` — full value (number or array).
- `export function evalExpr(src: string, resolve: Resolver): number`**kept** for scalar callers; returns `NaN` if the value is an array or unparseable.
- `export function evalBool(src, resolve): boolean` — unchanged signature.
- `export function collectRefs(src): RefLite[]` — now also walks array-literal / index AST nodes.
- `export function checkExpr(src): string|null` — unchanged signature; parser now accepts the new syntax.
- [ ] **Step 1: Add AST nodes for array literal and indexing**
In the `Node` union add:
```ts
| { t: 'arr'; items: Node[] }
| { t: 'index'; a: Node; i: Node }
```
- [ ] **Step 2: Tokenizer — add `[` and `]`**
In `tokenize`, extend the single-char punctuation set to include brackets:
```ts
if ('+-*/%<>!()?:,[]'.includes(c)) { toks.push({ k: c }); i++; continue; }
```
- [ ] **Step 3: Parser — array literals + postfix indexing**
Replace `primary()` so that after producing a base node it consumes any chain of `[ expr ]`, and add the `[ … ]` literal:
```ts
function atom(): Node {
const t = peek();
if (!t) throw new Error('unexpected end of expression');
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
if (t.k === '[') {
eat('[');
const items: Node[] = [];
if (peek()?.k !== ']') {
items.push(ternary());
while (peek()?.k === ',') { eat(','); items.push(ternary()); }
}
eat(']');
return { t: 'arr', items };
}
if (t.k === 'sig') {
eat();
const raw = t.v!;
const idx = raw.indexOf(':');
const ds = idx < 0 ? raw : raw.slice(0, idx);
const name = idx < 0 ? '' : raw.slice(idx + 1);
return { t: 'sig', ds, name };
}
if (t.k === 'ident') {
eat();
const id = t.v!;
if (id === 'true') return { t: 'num', v: 1 };
if (id === 'false') return { t: 'num', v: 0 };
if (peek()?.k === '(') {
eat('(');
const args: Node[] = [];
if (peek()?.k !== ')') {
args.push(ternary());
while (peek()?.k === ',') { eat(','); args.push(ternary()); }
}
eat(')');
return { t: 'call', fn: id, args };
}
return { t: 'var', name: id };
}
if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; }
throw new Error(`unexpected token '${t.k}' in expression`);
}
function primary(): Node {
let n = atom();
while (peek()?.k === '[') {
eat('[');
const i = ternary();
eat(']');
n = { t: 'index', a: n, i };
}
return n;
}
```
(`unary` still calls `primary`; no other parser change.)
- [ ] **Step 4: Evaluator — return `ArrVal`, add array funcs + indexing**
Replace the evaluation section. Helpers `asNum` (coerce to number, throw on array) and `asArr` (require array) gate type errors.
```ts
import type { ArrVal } from './arraypolicy';
export type Resolver = (ds: string, name: string) => ArrVal;
function asNum(v: ArrVal): number {
if (typeof v !== 'number') throw new Error('expected a number, got an array');
return v;
}
function asArr(v: ArrVal): ArrVal[] {
if (!Array.isArray(v)) throw new Error('expected an array, got a number');
return v;
}
// resolve a possibly-negative index against length
function idx(i: number, len: number): number {
const k = Math.trunc(i) < 0 ? len + Math.trunc(i) : Math.trunc(i);
if (k < 0 || k >= len) throw new Error(`index ${i} out of range (len ${len})`);
return k;
}
const ARR_FUNCS: Record<string, (a: ArrVal[]) => ArrVal> = {
len: a => asArr(a[0]).length,
sum: a => asArr(a[0]).reduce((s, x) => s + asNum(x), 0),
mean: a => { const r = asArr(a[0]); return r.length ? r.reduce((s, x) => s + asNum(x), 0) / r.length : 0; },
// min/max: scalar-variadic OR single-array — see ev() dispatch below
slice: a => { const r = asArr(a[0]); const s = a[1] === undefined ? 0 : asNum(a[1]); const e = a[2] === undefined ? r.length : asNum(a[2]); return r.slice(s, e); },
concat: a => asArr(a[0]).concat(asArr(a[1])),
reverse: a => asArr(a[0]).slice().reverse(),
sort: a => asArr(a[0]).slice().sort((x, y) => asNum(x) - asNum(y)),
scale: a => asArr(a[0]).map(x => asNum(x) * asNum(a[1])),
add: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) + asNum(y[k])); return o; },
sub: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) - asNum(y[k])); return o; },
push: a => asArr(a[0]).concat([a[1]]),
set: a => { const r = asArr(a[0]).slice(); r[idx(asNum(a[1]), r.length)] = a[2]; return r; },
insert: a => { const r = asArr(a[0]).slice(); const k = Math.max(0, Math.min(r.length, Math.trunc(asNum(a[1])))); r.splice(k, 0, a[2]); return r; },
remove: a => { const r = asArr(a[0]).slice(); r.splice(idx(asNum(a[1]), r.length), 1); return r; },
pop: a => { const r = asArr(a[0]).slice(); r.pop(); return r; },
shift: a => { const r = asArr(a[0]).slice(); r.shift(); return r; },
indexOf: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return k; return -1; },
contains: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return 1; return 0; },
fill: a => new Array(Math.max(0, Math.trunc(asNum(a[0])))).fill(a[1]),
};
function ev(n: Node, R: Resolver): ArrVal {
switch (n.t) {
case 'num': return n.v;
case 'arr': return n.items.map(it => ev(it, R));
case 'sig': return R(n.ds, n.name);
case 'var': return R('local', n.name);
case 'index': return asArr(ev(n.a, R))[idx(asNum(ev(n.i, R)), asArr(ev(n.a, R)).length)];
case 'un': return n.op === '-' ? -asNum(ev(n.a, R)) : (asNum(ev(n.a, R)) === 0 ? 1 : 0);
case 'tern': return asNum(ev(n.c, R)) !== 0 ? ev(n.a, R) : ev(n.b, R);
case 'call': {
const args = n.args.map(a => ev(a, R));
// min/max keep scalar-variadic form, plus 1-arg array form
if ((n.fn === 'min' || n.fn === 'max') && !(args.length === 1 && Array.isArray(args[0]))) {
const nums = args.map(asNum);
return n.fn === 'min' ? Math.min(...nums) : Math.max(...nums);
}
if (n.fn === 'min' || n.fn === 'max') {
const r = asArr(args[0]).map(asNum);
return n.fn === 'min' ? Math.min(...r) : Math.max(...r);
}
const af = ARR_FUNCS[n.fn];
if (af) return af(args);
const sf = SCALAR_FUNCS[n.fn];
if (sf) return sf(args.map(asNum));
throw new Error(`unknown function '${n.fn}'`);
}
case 'bin': {
const a = asNum(ev(n.a, R)), b = asNum(ev(n.b, R));
switch (n.op) {
case '+': return a + b; case '-': return a - b; case '*': return a * b;
case '/': return a / b; case '%': return a % b;
case '<': return a < b ? 1 : 0; case '<=': return a <= b ? 1 : 0;
case '>': return a > b ? 1 : 0; case '>=': return a >= b ? 1 : 0;
case '==': return a === b ? 1 : 0; case '!=': return a !== b ? 1 : 0;
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
default: throw new Error(`unknown operator '${n.op}'`);
}
}
}
}
```
Rename the existing `FUNCS` table to `SCALAR_FUNCS` (same entries: abs/min/max/sqrt/floor/ceil/round/sign/pow/log/exp/sin/cos) — but **remove** `min`/`max` from it since they are now handled in the `call` dispatch above.
- [ ] **Step 5: Public functions — split `evalValue` / `evalExpr`**
```ts
export function evalValue(src: string, resolve: Resolver): ArrVal {
return ev(parseCached(src), resolve);
}
export function evalExpr(src: string, resolve: Resolver): number {
try {
const v = ev(parseCached(src), resolve);
return typeof v === 'number' ? v : NaN;
} catch {
return NaN;
}
}
```
`evalBool` keeps calling `evalExpr` (array → NaN → false, acceptable). Update `collectRefs`'s `walk` switch to also recurse the new nodes:
```ts
case 'arr': n.items.forEach(walk); break;
case 'index': walk(n.a); walk(n.i); break;
```
- [ ] **Step 6: Audit callers of `Resolver`/`evalExpr`**
Run: `grep -rn "Resolver\|evalExpr\|evalValue" web/src` and confirm every resolver implementation can return `ArrVal` (returning a plain `number` still satisfies `ArrVal`). The write/condition callers that need a number keep using `evalExpr`; only array-targeting nodes (Task 4) use `evalValue`.
- [ ] **Step 7: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 8: Commit**
```bash
git add web/src/lib/expr.ts
git commit -m "feat(logic): array-aware expression engine (literals, indexing, array funcs)"
```
---
## Task 4: Array values + sizing in `localstate.ts`
**Files:**
- Modify: `web/src/lib/localstate.ts`
**Interfaces:**
- Consumes: `parseInitialArray`, `applySizing`, `ArrVal` from `./arraypolicy`; `StateVar` from `./types`.
- Produces: `writeLocalState(name, value, sv?)` — when `sv` is an array declaration, `applySizing` is enforced; `initLocalState` instantiates array locals from `parseInitialArray`; metadata carries `elem`/`sizing`/`capacity`.
- Produces: `export function declaredVar(name): StateVar | undefined` — lookup used by `logic.ts` node handlers to know a local's sizing policy.
- [ ] **Step 1: Track declarations + array init**
Add a `decls = new Map<string, StateVar>()` populated in `initLocalState`; extend `coerce` for `type==='array'`:
```ts
import { parseInitialArray, applySizing, type ArrVal } from './arraypolicy';
const decls = new Map<string, StateVar>();
export function declaredVar(name: string): StateVar | undefined { return decls.get(name); }
function coerce(v: StateVar): any {
switch (v.type) {
case 'bool': return v.initial === 'true' || v.initial === '1';
case 'string': return v.initial;
case 'array': return parseInitialArray(v);
default: { const n = parseFloat(v.initial); return isNaN(n) ? 0 : n; }
}
}
```
In `initLocalState`, `decls.set(v.name, v)` before publishing, and extend the metadata object with `elem: v.elem, sizing: v.sizing, capacity: v.capacity` (add these optional fields to `SignalMeta` in `types.ts`).
- [ ] **Step 2: Enforce sizing on write**
```ts
export function writeLocalState(name: string, value: any): void {
const sv = decls.get(name);
let v = value;
if (sv?.type === 'array' && Array.isArray(value)) v = applySizing(value as ArrVal[], sv);
valueW(name).set({ value: v, quality: 'good', ts: new Date().toISOString() });
}
```
- [ ] **Step 3: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 4: Commit**
```bash
git add web/src/lib/localstate.ts web/src/lib/types.ts
git commit -m "feat(logic): array local init + sizing enforcement in localstate"
```
---
## Task 5: Array action nodes + accumulate/export unification in `logic.ts`
**Files:**
- Modify: `web/src/lib/types.ts` (`LogicNodeKind` union — add `'action.array.push' | 'action.array.set' | 'action.array.remove' | 'action.array.pop' | 'action.array.clear'`)
- Modify: `web/src/lib/logic.ts`
**Interfaces:**
- Consumes: `evalValue`, `evalExpr` from `./expr`; `writeLocalState`, `getLocalValueStore`, `declaredVar` from `./localstate`; `applySizing` from `./arraypolicy`.
- Produces: node handlers for the five `action.array.*` kinds; `accumulate`→push and `clear`→array.clear aliasing; export-by-index with custom header labels; `ensureArrayDecls(graph)` auto-declare migration called from `load()`.
- [ ] **Step 1: Read current array machinery**
Run: `grep -n "arrays\|accumulate\|action.export\|action.clear\|runNode\|case 'action" web/src/lib/logic.ts` to locate the dispatch switch and the legacy `arrays:Map` (around the lines noted in the design's code map: store ~225, accumulate ~614, export ~626, clear ~632, exportArrays ~733).
- [ ] **Step 2: Replace the `{t,v}` store with array-local reads/writes**
Array locals are the single source of truth. Implement a helper to read the current array value of a local:
```ts
import { get } from './store';
import { getLocalValueStore, writeLocalState, declaredVar } from './localstate';
import { applySizing, type ArrVal } from './arraypolicy';
function curArray(name: string): ArrVal[] {
const v = get(getLocalValueStore(name)).value;
return Array.isArray(v) ? (v as ArrVal[]) : [];
}
```
(If `store.ts` lacks a synchronous `get`, read via a one-shot subscribe; confirm in Step 1.)
- [ ] **Step 3: Implement the five node handlers**
In the node dispatch switch:
```ts
case 'action.array.push': {
const arr = curArray(p.array);
writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]);
break;
}
case 'action.array.set': {
const arr = curArray(p.array).slice();
const path = String(p.index ?? '').split(',').map(s => Math.trunc(evalExpr(s, ctx.resolve)));
setPath(arr, path, evalValue(p.expr, ctx.resolve)); // setPath: nested index assignment, defined below
writeLocalState(p.array, arr);
break;
}
case 'action.array.remove': {
const arr = curArray(p.array).slice();
const i = Math.trunc(evalExpr(p.index, ctx.resolve));
const k = i < 0 ? arr.length + i : i;
if (k >= 0 && k < arr.length) arr.splice(k, 1);
writeLocalState(p.array, arr);
break;
}
case 'action.array.pop': {
const arr = curArray(p.array).slice(); arr.pop();
writeLocalState(p.array, arr);
break;
}
case 'action.array.clear': {
const sv = declaredVar(p.array);
writeLocalState(p.array, sv ? applySizing([], sv) : []); // fixed → zero-refill via applySizing
break;
}
```
Add `setPath`:
```ts
function setPath(arr: ArrVal[], path: number[], v: ArrVal): void {
let cur: ArrVal[] = arr;
for (let d = 0; d < path.length - 1; d++) {
let k = path[d]; if (k < 0) k = cur.length + k;
if (!Array.isArray(cur[k])) cur[k] = [];
cur = cur[k] as ArrVal[];
}
let last = path[path.length - 1]; if (last < 0) last = cur.length + last;
cur[last] = v;
}
```
- [ ] **Step 4: Alias accumulate/clear and rewrite export**
In the dispatch switch, make the legacy kinds delegate:
```ts
case 'action.accumulate': // legacy alias → push
{ const arr = curArray(p.array); writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]); }
break;
case 'action.clear': // legacy alias → array.clear
{ const sv = declaredVar(p.array); writeLocalState(p.array, sv ? applySizing([], sv) : []); }
break;
```
Rewrite `action.export` to read array locals by column and emit index-aligned CSV with custom headers. Parse `p.columns` as `[{array, label}]`; header row uses `label || array`; row `r` joins `col[r] ?? ''`:
```ts
case 'action.export': {
const cols = JSON.parse(p.columns || '[]') as { array: string; label?: string }[];
const data = cols.map(c => curArray(c.array));
const rows = Math.max(0, ...data.map(d => d.length));
const header = cols.map(c => csvCell(c.label || c.array)).join(',');
const lines = [header];
for (let r = 0; r < rows; r++) {
lines.push(data.map(d => (r < d.length ? csvCell(String(d[r])) : '')).join(','));
}
downloadCsv(lines.join('\n'), p.filename || 'export.csv'); // reuse existing blob-download helper
break;
}
```
(Keep/rename the existing CSV-escape and blob-download helpers as `csvCell`/`downloadCsv`; drop `exportArrays`/`interpAt` and the `align` param handling.)
- [ ] **Step 5: Auto-declare migration in `load()`**
After the graph is loaded but before subscriptions, ensure any array referenced by an array node / accumulate / export but **not** declared gets a dynamic numeric array local:
```ts
function ensureArrayDecls(graph: LogicGraph, vars: StateVar[]): StateVar[] {
const have = new Set(vars.map(v => v.name));
const out = vars.slice();
const need = (name: string) => {
if (name && !have.has(name)) { have.add(name); out.push({ name, type: 'array', elem: 'number', sizing: 'dynamic', initial: '' }); }
};
for (const n of graph.nodes) {
if (n.kind === 'action.accumulate' || n.kind === 'action.clear' || n.kind.startsWith('action.array.')) need(n.params.array);
if (n.kind === 'action.export') { try { (JSON.parse(n.params.columns || '[]') as any[]).forEach(c => need(c.array)); } catch {} }
}
return out;
}
```
Call this so the resulting list is passed to `initLocalState` (wherever `load()` currently calls it; if `load()` doesn't own statevars, thread the merged list through the same path the panel uses to init local state).
- [ ] **Step 6: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 7: Commit**
```bash
git add web/src/lib/logic.ts web/src/lib/types.ts
git commit -m "feat(logic): array action nodes + accumulate/export unification + migration"
```
---
## Task 6: Persist array `<statevar>` attributes (`xml.ts`)
**Files:**
- Modify: `web/src/lib/xml.ts` (statevar read ~lines 67-77 and the corresponding write path)
**Interfaces:**
- Produces: `<statevar>` round-trips `elem`, `sizing`, `capacity` in addition to existing attrs, only emitting them when present.
- [ ] **Step 1: Write path — emit array attrs**
Where statevars are serialized, append the optional attrs:
```ts
function statevarXml(v: StateVar): string {
const a = [`name="${esc(v.name)}"`, `type="${v.type ?? 'number'}"`, `initial="${esc(v.initial)}"`];
if (v.unit) a.push(`unit="${esc(v.unit)}"`);
if (v.low !== undefined) a.push(`low="${v.low}"`);
if (v.high !== undefined) a.push(`high="${v.high}"`);
if (v.type === 'array') {
if (v.elem) a.push(`elem="${v.elem}"`);
if (v.sizing) a.push(`sizing="${v.sizing}"`);
if (v.capacity !== undefined) a.push(`capacity="${v.capacity}"`);
}
return `<statevar ${a.join(' ')}/>`;
}
```
(Adapt to the file's existing serialization style — match how `unit`/`low`/`high` are currently emitted.)
- [ ] **Step 2: Read path — parse array attrs**
Where `<statevar>` is parsed into a `StateVar`, add:
```ts
elem: el.getAttribute('elem') as any || undefined,
sizing: el.getAttribute('sizing') as any || undefined,
capacity: el.hasAttribute('capacity') ? Number(el.getAttribute('capacity')) : undefined,
```
- [ ] **Step 3: Build + round-trip sanity (manual)**
Run: `make frontend`. Then in Task 9's smoke test confirm an array statevar survives save→reload.
- [ ] **Step 4: Commit**
```bash
git add web/src/lib/xml.ts
git commit -m "feat(logic): round-trip array statevar attributes in panel XML"
```
---
## Task 7: Editor — array declaration form + array nodes
**Files:**
- Modify: `web/src/LogicEditor.tsx` (the `LocalVars` subcomponent + the palette node list + the inspector)
- Modify: `web/src/lib/flowDebug.ts` (compact array badge formatting)
**Interfaces:**
- Consumes: `StateVar` shape (Task 1), the new node kinds (Task 5).
- Produces: UI to declare array locals and place/inspect `action.array.*` nodes; debug badges that stringify arrays compactly.
- [ ] **Step 1: `LocalVars` array form**
In the `LocalVars` subcomponent, when the type select value is `array`, reveal: an `elem` select (number/bool/array), a `sizing` select (dynamic/capped/fixed), a `capacity` number input (shown for capped/fixed), and the existing `initial` text field repurposed as a JSON literal with inline `JSON.parse` validation (red hint on parse error). Persist via the existing `onStateVarsChange` path.
- [ ] **Step 2: Palette + inspector for array nodes**
Add the five `action.array.*` kinds to the Actions palette group (label/icon consistent with existing entries). In the inspector `switch`, add cases rendering: `array` (a select of declared array-local names), and `expr`/`index` fields using the existing `ExprField` (which already runs `checkExpr`). `action.array.set` shows both `index` (path, comma-separated) and `expr`.
- [ ] **Step 3: Compact array badges**
In `flowDebug.ts`, where a node value is stringified for the badge, format arrays as e.g. `[1, 2, 3, …](n=N)` truncated to the first ~3 elements:
```ts
export function fmtBadge(v: unknown): string {
if (Array.isArray(v)) {
const head = v.slice(0, 3).map(x => Array.isArray(x) ? '[…]' : String(x)).join(', ');
return `[${head}${v.length > 3 ? ', …' : ''}](n=${v.length})`;
}
return typeof v === 'number' ? String(+v.toFixed(4)) : String(v);
}
```
Wire `fmtBadge` into the existing badge render site (replace the inline number formatting).
- [ ] **Step 4: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 5: Commit**
```bash
git add web/src/LogicEditor.tsx web/src/lib/flowDebug.ts
git commit -m "feat(logic): array local declaration form + array nodes + array debug badges"
```
---
## Task 8: Widgets read array locals
**Files (verify exact paths first):**
- Run: `ls web/src/widgets` and `grep -rln "bitset\|MultiLed\|multi-led\|TableWidget\|PlotWidget" web/src/widgets`
- Modify: the plot widget, table widget, multi-LED/bitset widget.
**Interfaces:**
- Consumes: a bound local whose `SignalValue.value` may be `ArrVal[]`; metadata `elem`/`sizing`/`capacity` from `getLocalMetaStore`.
- [ ] **Step 1: Plot — accept a 1-D numeric array local as a waveform**
In the plot widget's value-ingest path, when a bound signal's value is a numeric array, feed it through the **same** code path already used for EPICS `float64[]` waveform samples (multidimensional/FFT/waterfall). Nested arrays → render the existing "unsupported" placeholder. (Locate the waveform branch via `grep -n "Array.isArray\|waveform\|float64" web/src/widgets/PlotWidget.tsx`.)
- [ ] **Step 2: Table — array source mode**
Add an `array` source mode (config flag) that, when the bound value is an array, renders one row per element (index + value with the per-signal value format). For `elem:'array'` (2-D), rows = outer index, configured columns map to inner positions. Scalars → existing multi-signal behavior.
- [ ] **Step 3: Multi-LED — array source mode**
Add an `array` source mode: render one LED per element, lit by element truthiness; LED count tracks array length live; when meta `elem==='bool'`, use the declared on/off labels.
- [ ] **Step 4: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 5: Commit**
```bash
git add web/src/widgets
git commit -m "feat(widgets): array source modes for plot, table, multi-LED"
```
---
## Task 9: Manual smoke verification + docs
**Files:**
- Modify: `TODO.md` (do NOT check the box yet — Phase 2 Go port still pending; add a sub-note that panel-logic arrays are done)
- Modify: `docs/TECHNICAL_SPEC.md` (document array statevars + array expression functions + the export change)
- [ ] **Step 1: Build the whole app**
Run: `make all`
Expected: frontend + backend build clean.
- [ ] **Step 2: Manual smoke checklist** (run `go run ./cmd/uopi`, open a panel in edit mode → Logic tab)
- Declare a `capped` array `hist` capacity 5; add a `trigger.timer``action.array.push{array:hist, expr:{ds:stub:sine_1hz}}`; in view mode confirm `hist` rings at 5 elements (debug badge shows `[…](n=5)`).
- Add `action.write{target: avg, expr: mean(hist)}` (scalar local `avg`); confirm it tracks.
- Indexing: `action.write{target: last, expr: hist[-1]}` updates to the newest sample.
- `fixed` array `grid` capacity 4 initial `[0,0,0,0]`; `action.array.set{array:grid, index:"2", expr:42}`; confirm element 2 = 42 and length stays 4.
- Save the panel, reload it, confirm the array statevars + nodes persist (Task 6).
- Legacy: open/confirm an existing panel using `action.accumulate`/`action.export` still records and exports CSV (now index-aligned; header uses custom labels).
- Widgets: bind `hist` to a plot (waveform), a table (one row per element), and a multi-LED (one LED per element) and confirm they render and update live.
- [ ] **Step 3: Commit docs**
```bash
git add TODO.md docs/TECHNICAL_SPEC.md
git commit -m "docs(logic): document panel-logic array locals"
```
---
## Self-Review Notes (spec coverage)
- Data model (spec §2) → Task 1 + Task 2 (`arraypolicy`).
- Expression engine (spec §3) → Task 3 (all functions, indexing, literals, negative index, errors, ref-collection).
- Mutation nodes + accumulate/export unification + migration (spec §4) → Task 5.
- Persistence panel XML (spec §5) → Task 6. (Control-logic Go persistence = Phase 2.)
- Editor UI + debug badges (spec §6, panel side) → Task 7. (Control editor = Phase 2.)
- Widgets (spec §7) → Task 8.
- Testing (spec §8): TS has no runner → manual smoke (Task 9); the authoritative automated suite + cross-engine parity table is **Phase 2 (Go)**.
**Deferred to Phase 2 (separate plan):** all of `internal/controllogic` (boxed `value` in `expr.go`, `Graph.StateVars`, `getLocal`/`setLocal` policy, JSON round-trip, config-apply/snapshot boxing), `ControlLogicEditor.tsx` LocalVars parity, the Go debug-event array payload, and the full Go test + parity suite.
@@ -0,0 +1,266 @@
# Design: Local array values for the node-editor flow engines
**Date:** 2026-06-24
**Status:** Approved (design phase)
**TODO refs:** "Logic editor → add full support to local array values: dynamic,
dynamic but capped max, fixed size etc; array functions should work with new
local array" and "Control loop → add full support to server side array values".
## 1. Goal & scope
Add first-class **array-valued local variables** to both flow engines:
- **Panel logic** (client TS): `web/src/lib/logic.ts`, `LogicEditor.tsx`,
`web/src/lib/types.ts`, `web/src/lib/expr.ts`, `web/src/lib/localstate.ts`,
`web/src/lib/xml.ts`.
- **Control logic** (server Go): `internal/controllogic/` (`model.go`,
`engine.go`, `expr.go`), editor `web/src/ControlLogicEditor.tsx`.
**Build order:** design both together; implement **panel logic (TS) first**,
then port the same design to control logic (Go). The panel `StateVar` schema is
the richer reference and doing TS first de-risks the expression-language change
before the Go port.
**In scope for v1:** declaration + sizing policies, an array-aware expression
language (shared by both engines), array mutation nodes, persistence, **and
widget binding** (multi-LED/bitset, table, plot read array locals).
## 2. Data model — array local declaration
A new array kind on the existing `StateVar` (TS `types.ts`; mirrored as a Go
struct in control logic, which gains state-var declarations for the first time):
```
StateVar {
name: string
type: 'number' | 'bool' | 'string' | 'array' // 'array' is new
initial: string // arrays: JSON literal e.g. "[0,0,0]" / "[[1,2],[3,4]]", or "" = empty/zero-fill
unit?, low?, high? // existing scalar fields
// present only when type === 'array':
elem?: 'number' | 'bool' | 'array' // element type; 'array' ⇒ nested (recursive, arbitrary depth, jagged allowed)
sizing?: 'dynamic' | 'capped' | 'fixed' // default 'dynamic'
capacity?: number // required for capped/fixed
}
```
Semantics:
- **dynamic** — unbounded, guarded by a global safety cap (1e6 elements) to
prevent runaway growth.
- **capped** — `capacity` max; pushing past full **drops the oldest** element
(ring / FIFO).
- **fixed** — exactly `capacity` slots. Initialised from the `initial` literal
(truncated / zero-padded to `capacity`), else **zero-filled**. `push` is a
no-op (write via index/set); `clear` zero-refills rather than emptying.
- **initial** — non-empty JSON literal is parsed and used; empty ⇒ dynamic and
capped start `[]`, fixed starts zero-filled.
- `elem: 'bool'` is **display metadata only**. Runtime leaves are numeric
`1`/`0` (consistent with the engine's existing "booleans are 1/0" rule);
widgets use the declaration to render on/off.
**Declaration-time validation:** capped/fixed require `capacity >= 1`;
`initial`, if non-empty, must parse as JSON and match the declared element
type / nesting. Errors surface inline in the editor.
## 3. Expression engine (`expr.ts` + `expr.go`)
**Value model (tagged union — "Approach 1"):**
- TS: `type EvalValue = number | EvalValue[]`. The evaluator returns
`EvalValue`; functions/indexing type-check at runtime and throw on misuse
(caught → node error badge).
- Go: a boxed `value{ num float64; arr []value; isArr bool }`. `Resolver`
returns `value`; every `*Node.eval` returns `value` (a contained, mechanical
refactor of `expr.go`, which today returns `float64`). The `float64` leaf
stays the fast path.
**New syntax (both parsers):**
- **Array literal:** `[a, b, c]`, nested `[[1,2],[3,4]]`.
- **Indexing:** postfix `expr[expr]`, chainable `a[i][j]`. Index rounds to int;
**negative index counts from the end** (`a[-1]` = last); out-of-range → node
error.
**Functions** — added to the existing `abs/min/max/sqrt/...` table. All the
read/transform functions are **pure** (return new values; never mutate a local):
| Function | Result | Meaning / notes |
|---|---|---|
| `len(a)` | number | element count (top level) |
| `sum(a)`, `mean(a)`, `min(a)`, `max(a)` | number | over a 1-D numeric array; error if elements are arrays |
| `slice(a, s, e)` | array | subrange, `e` exclusive, negative indices allowed |
| `concat(a, b)` | array | join |
| `reverse(a)` | array | |
| `sort(a)` | array | ascending numeric |
| `scale(a, k)` | array | element-wise `a[i]*k` |
| `add(a, b)`, `sub(a, b)` | array | element-wise pairwise; length = min(len a, len b) |
| `push(a, v)` | array | copy with `v` appended |
| `set(a, i, v)` | array | copy with element `i` replaced (negative `i` ok) |
| `insert(a, i, v)` | array | copy with `v` inserted at `i` |
| `remove(a, i)` | array | copy without element `i` |
| `pop(a)` | array | copy without the last element (read it with `a[-1]`) |
| `shift(a)` | array | copy without the first element (read with `a[0]`) |
| `indexOf(a, v)` | number | first index of `v`, else `-1` |
| `contains(a, v)` | number | `1`/`0` |
| `fill(n, v)` | array | new length-`n` array of `v` |
- `min`/`max` keep their existing **scalar variadic** form (`min(x,y,z)`) and
gain a 1-arg **array** form (`min(a)`) — dispatch on arg count + type.
- **Resolution:** a bare identifier naming an array local returns the whole
array value; `{ds:sig}` waveform signals (EPICS `float64[]`) become first-class
array values usable by every function above.
**Purity & persistence interplay:** the mutator-named functions
(`push/set/insert/remove/pop/shift`) are **immutable transforms** — they return a
new array and do not touch the local. You persist a result by writing it back;
the **sizing policy is enforced at store time** in `writeLocalState` (TS) /
`setLocal` (Go) whenever the write target is a typed array local (ring-drop for
capped, clamp / no-op for fixed). The mutation nodes (§4) are convenient, visible
sugar for "store with policy".
**Errors** (e.g. `sum` of nested array, indexing a scalar, `add` of non-arrays)
throw in the evaluator and surface as the node's error reason via the existing
`checkExpr` validation + runtime-catch / badge path.
**Ref-collection** (`collectRefs` / `CollectRefs`) walks the new literal/index
AST so subscriptions still discover every `{ds:sig}` inside array expressions.
## 4. Mutation nodes + accumulate/export unification
**New action nodes** (panel `LogicNodeKind`, mirrored in Go control-logic kinds):
- `action.array.push{array, expr}` — append `eval(expr)`; sizing-policy aware
(ring-drop if capped; no-op if fixed).
- `action.array.set{array, index, expr}` — store at `eval(index)`. Supports
**nested targets via an index path**: `index = "i, j"``a[i][j]`. Negative
indices allowed; out-of-range → node error. (This is the imperative
path-assignment style.)
- `action.array.remove{array, index}` — remove element at index (in place).
- `action.array.pop{array}` — remove last element (in place).
- `action.array.clear{array}` — empty (dynamic/capped) or zero-refill (fixed).
These are the sizing-policy-aware, in-place counterparts to the pure expression
functions.
**Unification of the existing `{t,v}` array system (decision: unify):**
- `action.accumulate{array, expr}` → reframed as `action.array.push` (append,
policy-enforced). Old kind **kept as a compile alias** so saved panels run.
- `action.clear{array}``action.array.clear` (alias retained).
- `action.export{columns, align, filename}` → serializes **array locals** by
column. Array locals are plain numeric (no per-sample `t`), so **time-based
alignment (`common`/`any`/`interpolate`) is dropped**; columns are emitted
**side-by-side by index** (ragged columns padded blank). The `align` param is
ignored and hidden in the inspector. To keep a timestamp column, push
`{sys:time}` into a parallel array local and add it as a column.
- **Custom column names:** each export column keeps its `label`, surfaced as an
**editable header name** in the inspector (default = array-local name); the CSV
header row uses the chosen names.
**Migration (non-destructive, at engine `load()`):** any
`accumulate`/`clear`/`export` node referencing an array name with **no** matching
`StateVar` declaration triggers an **auto-declared dynamic numeric array local**
of that name. No file rewrite.
## 5. Persistence
**Panel logic (XML, `xml.ts`):** `<statevar>` gains optional array attributes,
written only for arrays:
```xml
<statevar name="hist" type="array" elem="number" sizing="capped" capacity="100" initial=""/>
<statevar name="grid" type="array" elem="array" sizing="fixed" capacity="4" initial="[[0,0],[0,0]]"/>
```
Round-trips through the existing verbatim-body store (no Go change for panels).
New nodes serialize via the existing `<node><param/></node>` mechanism.
**Control logic (Go, `model.go`):** control logic has **no** state-var
declarations today (`locals` is an untyped `map[string]float64`). Additions:
- `Graph` gains `StateVars []StateVar` (Go struct mirroring the TS shape:
`Name, Type, Elem, Sizing string; Capacity int; Initial, Unit string; Low,
High float64`), serialized in `controllogic.json`.
- `compiledGraph.locals` changes from `map[string]float64` to
`map[string]value`, initialised from `StateVars` (applying sizing/initial) at
compile time.
- `getLocal`/`setLocal` operate on `value`; `setLocal` enforces sizing policy.
Config-apply / snapshot paths that read/write locals as `float64` box/unbox.
**Versioning:** control-logic graphs are already git-style versioned; the new
`StateVars` field rides along in each revision (no diff-engine change — just more
JSON).
## 6. Editor UI + debug/live badges
**Panel `LogicEditor.tsx`:** the `LocalVars` palette subcomponent gains an array
declaration form (type=array reveals element-type / sizing / capacity / `initial`
JSON with inline validation). New array action nodes added to the Actions palette
group with inspectors (array name + expr/index fields, reusing `ExprField` with
array-aware `checkExpr`).
**Control `ControlLogicEditor.tsx`:** control logic has **no** local-var
declaration UI today. Add a `LocalVars` panel mirroring the panel editor (same
component, driven by `Graph.StateVars`) plus the array nodes in its palette. This
brings control-logic locals to parity — scalars *and* arrays become declarable
there for the first time.
**Debug/live badges (`flowDebug.ts` + Go `DebugObserver` / synthetic trace):**
array node values render as a truncated literal, e.g. `[1, 2, 3, …](n=100)`. The
Go debug event payload (`debugNode`) and the synthetic trace already serialize a
value — extended to carry array JSON; the badge formatter stringifies arrays
compactly.
## 7. Widgets reading array locals
The `ds:'local'` plumbing already routes through `stores.ts`/`ws.ts`; the change
is that a local's `SignalValue.value` can be an array (number / nested), held and
initialised per panel instance by `localstate.ts`. No new widget types — new
source modes on three existing widgets:
- **Plot** — a 1-D numeric array local binds as a **waveform sample** (same path
EPICS `float64[]` waveforms already use for multidimensional/FFT/waterfall).
Each engine tick that rewrites the array updates the trace. Nested arrays show
"unsupported shape".
- **Table widget** — gains an **array source mode**: bound to one array local,
renders **one row per element** (index + value, per-signal value-format applied).
For an `elem:'array'` (2-D) local, rows are indices and the configured columns
map to inner-array positions. Falls back to multi-signal mode for scalars.
- **Multi-LED / bitset** — gains an **array source mode**: one LED per element,
lit per element truthiness; when `elem:'bool'`, on/off labels come from the
declaration. Existing integer-bitset mode unchanged.
`getLocalMetaStore` carries `elem`/`sizing`/`capacity` so widgets self-configure
(e.g. multi-LED LED count = array length, growing/shrinking live for dynamic
arrays).
## 8. Testing
- **TS unit (expr):** literals; indexing (negative, nested, out-of-range error);
every new function incl. type-error cases; sizing-policy enforcement on store
(ring drop-oldest, fixed no-op / zero-refill); accumulate→push migration; CSV
export by-index with custom headers.
- **Go unit (`internal/controllogic`):** port of the expr suite (boxed `value`,
all functions/indexing/errors); `StateVars` init from declarations; `setLocal`
policy enforcement; JSON round-trip of `StateVars`; config-apply/snapshot with
boxed locals.
- **Cross-engine parity:** a `(expr, expected)` table asserted identical in both
`expr.ts` and `expr.go` to keep them in lockstep.
- **Widgets:** light component tests for the new array source modes if widget
tests exist; else manual verification.
- **Gates:** `gofmt`, `go vet`, `go test ./... -race`, frontend typecheck/build.
## 9. Risks
- **Go `expr.go` refactor (`float64` → boxed `value`):** touches every node's
`eval` and the `Resolver`. Mechanical but broad; the parity test guards
behavioral drift from `expr.ts`.
- **Backward compatibility of `accumulate`/`export`:** aliasing + auto-declared
locals keep old panels running, but the **dropped time-alignment in export** is
a behavioral change for any panel relying on `interpolate`/`common` align. Call
this out in release notes.
- **Two engines staying in lockstep:** the function set and semantics must match
exactly across TS and Go; the cross-engine parity fixture is the safeguard.
- **Hot-path purity:** array expression functions are evaluated repeatedly; they
must remain allocation-light and side-effect-free (mutation only at store time).
+21 -1
View File
@@ -3,10 +3,30 @@ module github.com/uopi/uopi
go 1.26.2
require (
cuelang.org/go v0.16.1
github.com/BurntSushi/toml v1.6.0
github.com/coder/websocket v1.8.14
github.com/evanw/esbuild v0.28.0
github.com/yuin/gopher-lua v1.1.2
)
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
+92
View File
@@ -1,10 +1,102 @@
cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE=
cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
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/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
+7
View File
@@ -0,0 +1,7 @@
go 1.26.2
use (
.
./pkg/ca
./pkg/pva
)
+686
View File
@@ -0,0 +1,686 @@
// Package access implements uopi's role-based access policy. Access is granted
// through group memberships: every user belongs implicitly to the built-in
// "public" group (granting the baseline viewer role), and may additionally be
// assigned a higher role in any number of named groups. Roles form a cumulative
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
// includes all powers below it. A user's effective capability is the highest
// role they hold across all their groups.
//
// Groups can be nested: a member of a parent group holds that role on every
// descendant group too (unless the descendant assigns them a different role),
// so an admin of an organisation group administers its sub-teams.
//
// As a bootstrap convenience, a policy with no role assignments at all is treated
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
// Assigning any role switches to strict mode where unlisted users are viewers.
//
// The policy is seeded from the TOML config at startup but is runtime-mutable
// through the admin pane: once EnablePersistence is called, every mutation is
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
// later startup, becomes the source of truth (the TOML config is then only a
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
// use; the *Policy pointer is stable so existing shared-pointer wiring is
// preserved.
package access
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// PublicGroup is the built-in group every user implicitly belongs to. It cannot
// be renamed or deleted and is always a top-level (parentless) group.
const PublicGroup = "public"
// Role is a cumulative access level granted by a group membership. Higher roles
// include every power of the lower ones.
type Role int
const (
// RoleViewer permits reads only (no signal writes). It is the baseline every
// user receives via the public group.
RoleViewer Role = iota
// RoleOperator adds signal writes (full HMI interaction).
RoleOperator
// RoleLogic adds editing panel and server-side control logic.
RoleLogic
// RoleAuditor adds viewing the audit log.
RoleAuditor
// RoleAdmin adds managing users, groups and access, and viewing server stats.
RoleAdmin
)
// RoleNames lists the role tokens from lowest to highest, for the admin UI.
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}
// String renders the role using the tokens accepted by ParseRole.
func (r Role) String() string {
switch r {
case RoleOperator:
return "operator"
case RoleLogic:
return "logiceditor"
case RoleAuditor:
return "auditor"
case RoleAdmin:
return "admin"
default:
return "viewer"
}
}
// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
// least-privileged viewer.
func ParseRole(s string) Role {
switch strings.ToLower(strings.TrimSpace(s)) {
case "operator", "write", "operate", "rw":
return RoleOperator
case "logiceditor", "logic", "logic_editor", "editor":
return RoleLogic
case "auditor", "audit":
return RoleAuditor
case "admin", "administrator":
return RoleAdmin
default:
return RoleViewer
}
}
// Level is a coarse global access level retained for the rest of the codebase
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
// user's effective role: viewer → read-only, operator and above → write.
type Level int
const (
// LevelNone denies all access. Never produced by the role model, but kept so
// existing switch statements remain exhaustive.
LevelNone Level = iota
// LevelRead permits reads only.
LevelRead
// LevelWrite permits full write access.
LevelWrite
)
// String renders the level using the tokens surfaced to the frontend via
// /api/v1/me.
func (l Level) String() string {
switch l {
case LevelNone:
return "none"
case LevelRead:
return "readonly"
default:
return "write"
}
}
func roleToLevel(r Role) Level {
if r >= RoleOperator {
return LevelWrite
}
return LevelRead
}
// group holds a group's parent (for nesting) and explicit per-user role
// assignments.
type group struct {
parent string
members map[string]Role
}
// Policy holds the resolved role-based access configuration. It is safe for
// concurrent use; reads take a shared lock and admin mutations take an exclusive
// lock and persist to disk.
type Policy struct {
mu sync.RWMutex
path string // access.json path; "" disables persistence
defaultUser string // immutable after construction/load
groups map[string]*group
}
// GroupSpec seeds one group at construction time: its name, optional parent, and
// explicit user→role assignments.
type GroupSpec struct {
Name string
Parent string
Members map[string]Role
}
// New builds a Policy from group specs. The built-in public group is always
// created. A spec listing the same user in multiple roles keeps the last one;
// callers should pass the highest intended role.
func New(defaultUser string, specs []GroupSpec) *Policy {
p := &Policy{
defaultUser: strings.TrimSpace(defaultUser),
groups: make(map[string]*group),
}
for _, s := range specs {
name := strings.TrimSpace(s.Name)
if name == "" {
continue
}
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(s.Parent)
for u, r := range s.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = r
}
}
}
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return p
}
// ensureGroupLocked returns the named group, creating an empty one if needed.
// The caller must hold p.mu for writing (or be in construction).
func (p *Policy) ensureGroupLocked(name string) *group {
g, ok := p.groups[name]
if !ok {
g = &group{members: make(map[string]Role)}
p.groups[name] = g
}
return g
}
// normalizeParentsLocked drops parent references to missing groups or that would
// form a cycle, and forces the public group to be a root.
func (p *Policy) normalizeParentsLocked() {
for name, g := range p.groups {
if name == PublicGroup {
g.parent = ""
continue
}
if g.parent == "" {
continue
}
if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
g.parent = ""
}
}
}
// hasCycleLocked reports whether following parent links from start loops back.
func (p *Policy) hasCycleLocked(start string) bool {
seen := make(map[string]bool)
for cur := start; cur != ""; {
if seen[cur] {
return true
}
seen[cur] = true
g, ok := p.groups[cur]
if !ok {
return false
}
cur = g.parent
}
return false
}
// ── effective role ─────────────────────────────────────────────────────────
// configuredLocked reports whether any explicit role is assigned anywhere. An
// unconfigured policy is treated as fully open (bootstrap-safe).
func (p *Policy) configuredLocked() bool {
for _, g := range p.groups {
if len(g.members) > 0 {
return true
}
}
return false
}
// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
// users (and anonymous callers) get the viewer baseline once the policy is
// configured; an unconfigured policy grants everyone admin.
func (p *Policy) effectiveRoleLocked(user string) Role {
if !p.configuredLocked() {
return RoleAdmin
}
best := RoleViewer
if user = strings.TrimSpace(user); user == "" {
return best
}
for _, g := range p.groups {
if r, ok := g.members[user]; ok && r > best {
best = r
}
}
return best
}
// ── persistence ────────────────────────────────────────────────────────────
// persisted is the on-disk schema for access.json.
type persisted struct {
DefaultUser string `json:"defaultUser"`
Groups map[string]persistedGroup `json:"groups"`
}
type persistedGroup struct {
Parent string `json:"parent"`
Members map[string]string `json:"members"` // user → role token
}
// EnablePersistence points the policy at {storageDir}/access.json. If the file
// exists its contents replace the TOML-seeded state (the file is the source of
// truth once written); otherwise the current state is kept and the file is only
// created on the first mutation.
func (p *Policy) EnablePersistence(storageDir string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.path = filepath.Join(storageDir, "access.json")
data, err := os.ReadFile(p.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var ps persisted
if err := json.Unmarshal(data, &ps); err != nil {
return err
}
p.defaultUser = strings.TrimSpace(ps.DefaultUser)
p.groups = make(map[string]*group)
for name, pg := range ps.Groups {
if name = strings.TrimSpace(name); name == "" {
continue
}
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(pg.Parent)
for u, token := range pg.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = ParseRole(token)
}
}
}
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return nil
}
// saveLocked atomically persists the current state when persistence is enabled.
func (p *Policy) saveLocked() error {
if p.path == "" {
return nil
}
ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
for name, g := range p.groups {
pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
for u, r := range g.members {
pg.Members[u] = r.String()
}
ps.Groups[name] = pg
}
data, err := json.MarshalIndent(ps, "", " ")
if err != nil {
return err
}
tmp := p.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, p.path)
}
// ── reads ──────────────────────────────────────────────────────────────────
// ResolveUser trims the proxy-provided header value and falls back to the
// configured default_user when it is empty (e.g. unproxied/dev deployments).
func (p *Policy) ResolveUser(headerValue string) string {
u := strings.TrimSpace(headerValue)
if u == "" {
return p.defaultUser
}
return u
}
// Level returns the coarse global access level for a user.
func (p *Policy) Level(user string) Level {
p.mu.RLock()
defer p.mu.RUnlock()
return roleToLevel(p.effectiveRoleLocked(user))
}
// EffectiveRole returns the highest role a user holds across all groups.
func (p *Policy) EffectiveRole(user string) Role {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user)
}
// CanEditLogic reports whether a user may add or edit panel and control logic
// (logic-editor role or higher).
func (p *Policy) CanEditLogic(user string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleLogic
}
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
func (p *Policy) CanViewAudit(user string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleAuditor
}
// CanAdmin reports whether a user may use the admin pane (admin role).
func (p *Policy) CanAdmin(user string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleAdmin
}
// GroupNames returns a copy of every group name, sorted.
func (p *Policy) GroupNames() []string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.groupNamesLocked()
}
func (p *Policy) groupNamesLocked() []string {
out := make([]string, 0, len(p.groups))
for n := range p.groups {
out = append(out, n)
}
sort.Strings(out)
return out
}
// GroupsOf returns the groups a user effectively belongs to: every group they
// are an explicit member of, plus that group's descendants (membership flows
// parent→child). The implicit public-group viewer baseline is not included.
// Used by per-panel/folder sharing rules.
func (p *Policy) GroupsOf(user string) []string {
p.mu.RLock()
defer p.mu.RUnlock()
if user = strings.TrimSpace(user); user == "" {
return nil
}
set := make(map[string]bool)
for name, g := range p.groups {
if _, ok := g.members[user]; ok {
set[name] = true
p.addDescendantsLocked(name, set)
}
}
out := make([]string, 0, len(set))
for n := range set {
out = append(out, n)
}
sort.Strings(out)
return out
}
// addDescendantsLocked adds every transitive child of parent into set.
func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) {
for name, g := range p.groups {
if g.parent == parent && !set[name] {
set[name] = true
p.addDescendantsLocked(name, set)
}
}
}
// ── admin snapshot ─────────────────────────────────────────────────────────
// MemberInfo is one user's role within a group.
type MemberInfo struct {
User string `json:"user"`
Role string `json:"role"`
}
// GroupInfo describes one group for the admin pane.
type GroupInfo struct {
Name string `json:"name"`
Parent string `json:"parent"`
Members []MemberInfo `json:"members"`
}
// UserInfo describes one user's memberships and resulting effective role.
type UserInfo struct {
Name string `json:"name"`
EffectiveRole string `json:"effectiveRole"`
Roles map[string]string `json:"roles"` // group → role token
}
// AccessSnapshot is the full mutable access state, rendered for the admin pane.
type AccessSnapshot struct {
DefaultUser string `json:"defaultUser"`
PublicGroup string `json:"publicGroup"`
Roles []string `json:"roles"` // role ladder, low→high
Configured bool `json:"configured"`
Users []UserInfo `json:"users"`
Groups []GroupInfo `json:"groups"`
}
// Snapshot returns a copy of the full access configuration for the admin pane.
func (p *Policy) Snapshot() AccessSnapshot {
p.mu.RLock()
defer p.mu.RUnlock()
snap := AccessSnapshot{
DefaultUser: p.defaultUser,
PublicGroup: PublicGroup,
Roles: append([]string(nil), RoleNames...),
Configured: p.configuredLocked(),
}
userSet := make(map[string]bool)
for _, name := range p.groupNamesLocked() {
g := p.groups[name]
gi := GroupInfo{Name: name, Parent: g.parent}
users := make([]string, 0, len(g.members))
for u := range g.members {
users = append(users, u)
userSet[u] = true
}
sort.Strings(users)
for _, u := range users {
gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()})
}
snap.Groups = append(snap.Groups, gi)
}
users := make([]string, 0, len(userSet))
for u := range userSet {
users = append(users, u)
}
sort.Strings(users)
for _, u := range users {
ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)}
for name, g := range p.groups {
if r, ok := g.members[u]; ok {
ui.Roles[name] = r.String()
}
}
snap.Users = append(snap.Users, ui)
}
return snap
}
// ── mutations (admin pane) ─────────────────────────────────────────────────
// ErrNotFound is returned when a named group does not exist.
var ErrNotFound = errors.New("group not found")
// SetMemberRole assigns a user a role within an existing group.
func (p *Policy) SetMemberRole(groupName, user string, role Role) error {
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
if groupName == "" {
return errors.New("empty group name")
}
if user == "" {
return errors.New("empty user")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[groupName]
if !ok {
return ErrNotFound
}
g.members[user] = role
return p.saveLocked()
}
// RemoveMember drops a user's explicit role in a group.
func (p *Policy) RemoveMember(groupName, user string) error {
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[groupName]
if !ok {
return ErrNotFound
}
delete(g.members, user)
return p.saveLocked()
}
// SetUserRoles replaces a user's full set of memberships: the user is placed in
// exactly the listed groups with the given roles and removed from all others.
// Listed groups that do not exist are created.
func (p *Policy) SetUserRoles(user string, roles map[string]Role) error {
user = strings.TrimSpace(user)
if user == "" {
return errors.New("empty user")
}
p.mu.Lock()
defer p.mu.Unlock()
want := make(map[string]Role, len(roles))
for g, r := range roles {
if g = strings.TrimSpace(g); g != "" {
want[g] = r
p.ensureGroupLocked(g)
}
}
for name, g := range p.groups {
if r, ok := want[name]; ok {
g.members[user] = r
} else {
delete(g.members, user)
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// CreateGroup adds an empty group with an optional parent if it does not exist.
func (p *Policy) CreateGroup(name, parent string) error {
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
if name == "" {
return errors.New("empty group name")
}
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.groups[name]; ok {
return nil
}
g := p.ensureGroupLocked(name)
g.parent = parent
p.normalizeParentsLocked()
return p.saveLocked()
}
// SetGroup replaces an existing group's parent and members. The public group's
// parent is always forced to root.
func (p *Policy) SetGroup(name, parent string, members map[string]Role) error {
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
if name == "" {
return errors.New("empty group name")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[name]
if !ok {
return ErrNotFound
}
if name != PublicGroup {
g.parent = parent
}
g.members = make(map[string]Role, len(members))
for u, r := range members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = r
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// RenameGroup renames a group, re-pointing any children at the new name. The
// public group cannot be renamed.
func (p *Policy) RenameGroup(oldName, newName string) error {
oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return errors.New("empty group name")
}
if oldName == PublicGroup {
return errors.New("cannot rename the public group")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[oldName]
if !ok {
return ErrNotFound
}
if oldName == newName {
return nil
}
if _, exists := p.groups[newName]; exists {
return errors.New("group already exists: " + newName)
}
delete(p.groups, oldName)
p.groups[newName] = g
for _, other := range p.groups {
if other.parent == oldName {
other.parent = newName
}
}
return p.saveLocked()
}
// DeleteGroup removes a group, reparenting its children to the deleted group's
// parent. The public group cannot be deleted; member users keep other groups.
func (p *Policy) DeleteGroup(name string) error {
name = strings.TrimSpace(name)
if name == PublicGroup {
return errors.New("cannot delete the public group")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[name]
if !ok {
return ErrNotFound
}
parent := g.parent
delete(p.groups, name)
for _, other := range p.groups {
if other.parent == name {
other.parent = parent
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// ── request-scoped user identity ───────────────────────────────────────────
type ctxKey struct{}
// WithUser returns a copy of ctx carrying the resolved end-user identity.
func WithUser(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, ctxKey{}, user)
}
// UserFrom returns the identity stored by WithUser, or "" if none.
func UserFrom(ctx context.Context) string {
u, _ := ctx.Value(ctxKey{}).(string)
return u
}
+266
View File
@@ -0,0 +1,266 @@
package access
import (
"os"
"path/filepath"
"sync"
"testing"
)
// spec is a small helper to build a GroupSpec.
func spec(name, parent string, members map[string]Role) GroupSpec {
return GroupSpec{Name: name, Parent: parent, Members: members}
}
func TestUnconfiguredIsOpen(t *testing.T) {
// No roles assigned anywhere → fully open (everyone is admin), matching a
// fresh/dev deployment.
p := New("", nil)
for _, u := range []string{"", "alice", "carol"} {
if !p.CanAdmin(u) {
t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u)
}
if p.Level(u) != LevelWrite {
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u))
}
}
}
func TestRoleLadder(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"adm": RoleAdmin}),
spec("ops", "", map[string]Role{
"viewer": RoleViewer,
"op": RoleOperator,
"logic": RoleLogic,
"aud": RoleAuditor,
}),
})
// Configured now → unlisted users (and anonymous) are viewers (read-only).
if p.Level("") != LevelRead {
t.Errorf("anonymous Level = %v, want read", p.Level(""))
}
if p.Level("nobody") != LevelRead {
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
}
cases := []struct {
user string
level Level
editLogic bool
audit bool
admin bool
}{
{"viewer", LevelRead, false, false, false},
{"op", LevelWrite, false, false, false},
{"logic", LevelWrite, true, false, false},
{"aud", LevelWrite, true, true, false},
{"adm", LevelWrite, true, true, true},
}
for _, c := range cases {
if p.Level(c.user) != c.level {
t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level)
}
if p.CanEditLogic(c.user) != c.editLogic {
t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic)
}
if p.CanViewAudit(c.user) != c.audit {
t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit)
}
if p.CanAdmin(c.user) != c.admin {
t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin)
}
}
}
func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"x": RoleViewer}),
spec("a", "", map[string]Role{"x": RoleOperator}),
spec("b", "", map[string]Role{"x": RoleAuditor}),
})
if got := p.EffectiveRole("x"); got != RoleAuditor {
t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got)
}
}
func TestNestingInheritsMembership(t *testing.T) {
// org → team-a → squad-1. A member of org is effectively in the descendants
// too (membership flows parent→child), surfaced via GroupsOf for panel ACLs.
p := New("", []GroupSpec{
spec("org", "", map[string]Role{"boss": RoleAdmin}),
spec("team-a", "org", nil),
spec("squad-1", "team-a", nil),
})
groups := p.GroupsOf("boss")
for _, want := range []string{"org", "team-a", "squad-1"} {
if !containsStr(groups, want) {
t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want)
}
}
}
func TestMutationsRoundTrip(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"root": RoleAdmin}),
spec("ops", "", map[string]Role{"carol": RoleOperator}),
})
// Set a user's full membership set: dave operator in ops, admin in eng (new).
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil {
t.Fatal(err)
}
if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") {
t.Errorf("GroupsOf(dave) = %v, want ops+eng", g)
}
if !p.CanAdmin("dave") { // admin in eng
t.Error("CanAdmin(dave) = false after admin role in eng")
}
// Replacing membership removes dave from ops.
if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil {
t.Fatal(err)
}
if containsStr(p.GroupsOf("dave"), "ops") {
t.Error("dave still in ops after membership replaced")
}
// Rename carries dave's admin grant from eng → engineering.
if err := p.RenameGroup("eng", "engineering"); err != nil {
t.Fatal(err)
}
if !p.CanAdmin("dave") {
t.Error("CanAdmin(dave) = false after eng renamed to engineering")
}
if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") {
t.Errorf("GroupNames after rename = %v", p.GroupNames())
}
// Delete drops the group; dave loses admin but root (in public) keeps it.
if err := p.DeleteGroup("engineering"); err != nil {
t.Fatal(err)
}
if p.CanAdmin("dave") {
t.Error("CanAdmin(dave) = true after engineering deleted")
}
if !p.CanAdmin("root") {
t.Error("CanAdmin(root) = false; root grant in public should survive")
}
if err := p.DeleteGroup("nope"); err != ErrNotFound {
t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err)
}
}
func TestPublicGroupProtected(t *testing.T) {
p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})})
if err := p.DeleteGroup(PublicGroup); err == nil {
t.Error("DeleteGroup(public) = nil, want error")
}
if err := p.RenameGroup(PublicGroup, "other"); err == nil {
t.Error("RenameGroup(public) = nil, want error")
}
if !containsStr(p.GroupNames(), PublicGroup) {
t.Error("public group missing after protected mutations")
}
}
func TestParentCycleRejected(t *testing.T) {
p := New("", []GroupSpec{
spec("a", "", map[string]Role{"x": RoleViewer}),
spec("b", "a", nil),
})
// Making a's parent b would create a cycle a→b→a; it must be dropped to root.
if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil {
t.Fatal(err)
}
snap := p.Snapshot()
for _, g := range snap.Groups {
if g.Name == "a" && g.Parent != "" {
t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent)
}
}
}
func TestPersistenceRoundTrip(t *testing.T) {
dir := t.TempDir()
p := New("admin", []GroupSpec{
spec("public", "", map[string]Role{"alice": RoleLogic}),
spec("ops", "", map[string]Role{"carol": RoleAdmin}),
})
if err := p.EnablePersistence(dir); err != nil {
t.Fatal(err)
}
// A mutation triggers the first write of access.json.
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil {
t.Fatalf("access.json not written: %v", err)
}
// A fresh policy loading the same dir should see the persisted state, not the
// (different) seed it was constructed with.
p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})})
if err := p2.EnablePersistence(dir); err != nil {
t.Fatal(err)
}
if p2.ResolveUser("") != "admin" {
t.Errorf("default user = %q, want admin", p2.ResolveUser(""))
}
if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") {
t.Error("logic-editor role not persisted")
}
if !p2.CanAdmin("carol") {
t.Error("admin role not persisted")
}
if !containsStr(p2.GroupsOf("dave"), "ops") {
t.Error("dave membership not persisted")
}
if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead {
// seeduser came from the discarded seed; should be an unlisted viewer now.
t.Error("persisted state did not supersede the seed")
}
}
func TestConcurrentAccess(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"root": RoleAdmin}),
spec("ops", "", map[string]Role{"carol": RoleOperator}),
})
if err := p.EnablePersistence(t.TempDir()); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(2)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
p.CanAdmin("carol")
p.Level("carol")
p.GroupsOf("carol")
p.Snapshot()
}
}()
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
p.SetMemberRole("ops", "carol", RoleAuditor)
p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin})
p.RemoveMember("ops", "carol")
}
}()
}
wg.Wait()
}
func containsStr(xs []string, v string) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
+55
View File
@@ -0,0 +1,55 @@
package access
import (
"slices"
"strings"
)
// Visibility scope tokens shared by user-owned, filterable objects across uopi
// (config sets/instances, control-logic graphs, synthetic signals). They drive
// the per-tree "Mine / Group / Global" selector. Storage carries the scope as a
// plain string so each subsystem can embed it in its own JSON without depending
// on this package's types.
const (
// ScopePrivate: visible only to the owner.
ScopePrivate = "private"
// ScopeGroup: visible to the owner and members of any listed group.
ScopeGroup = "group"
// ScopeGlobal: visible to everyone. This is also the legacy default — an
// empty/unknown scope is treated as global so objects created before scopes
// existed stay visible to all.
ScopeGlobal = "global"
)
// CanSee reports whether user (a member of userGroups) may see an object with the
// given owner, scope and itemGroups. An empty or unrecognised scope is treated as
// global, so legacy objects without a scope remain visible to everyone.
//
// This is a visibility filter for selector trees, not a hard security boundary:
// it governs which objects are offered in listings, and intentionally always
// shows an object to its owner regardless of scope.
func CanSee(user, owner, scope string, itemGroups, userGroups []string) bool {
switch strings.ToLower(strings.TrimSpace(scope)) {
case ScopePrivate:
return owner != "" && owner == user
case ScopeGroup:
if owner != "" && owner == user {
return true
}
for _, g := range itemGroups {
if slices.Contains(userGroups, g) {
return true
}
}
return false
default: // global / empty / unknown
return true
}
}
// CanSee reports whether user may see an object with the given owner, scope and
// itemGroups, resolving the user's group memberships from the policy. It is the
// convenience wrapper list handlers use.
func (p *Policy) CanSee(user, owner, scope string, itemGroups []string) bool {
return CanSee(user, owner, scope, itemGroups, p.GroupsOf(user))
}
+47
View File
@@ -0,0 +1,47 @@
package access
import "testing"
func TestCanSee(t *testing.T) {
cases := []struct {
name string
user string
owner string
scope string
itemGroups []string
userGroups []string
want bool
}{
{"global visible to anyone", "bob", "alice", ScopeGlobal, nil, nil, true},
{"empty scope = global", "bob", "alice", "", nil, nil, true},
{"unknown scope = global", "bob", "alice", "weird", nil, nil, true},
{"private hidden from others", "bob", "alice", ScopePrivate, nil, nil, false},
{"private visible to owner", "alice", "alice", ScopePrivate, nil, nil, true},
{"private with empty owner hidden", "bob", "", ScopePrivate, nil, nil, false},
{"group visible to member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"ops"}, true},
{"group hidden from non-member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"eng"}, false},
{"group visible to owner even if not a member", "alice", "alice", ScopeGroup, []string{"ops"}, nil, true},
{"group with multiple item groups", "bob", "alice", ScopeGroup, []string{"ops", "eng"}, []string{"eng"}, true},
{"case-insensitive scope token", "bob", "alice", "PRIVATE", nil, nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := CanSee(c.user, c.owner, c.scope, c.itemGroups, c.userGroups); got != c.want {
t.Errorf("CanSee(%q,%q,%q,%v,%v) = %v, want %v",
c.user, c.owner, c.scope, c.itemGroups, c.userGroups, got, c.want)
}
})
}
}
func TestPolicyCanSeeResolvesGroups(t *testing.T) {
p := New("", []GroupSpec{{Name: "ops", Members: map[string]Role{"bob": RoleOperator}}})
// bob is a member of ops; a group-scoped item shared with ops is visible.
if !p.CanSee("bob", "alice", ScopeGroup, []string{"ops"}) {
t.Errorf("expected bob to see an ops-scoped item")
}
// carol is in no group; the same item is hidden.
if p.CanSee("carol", "alice", ScopeGroup, []string{"ops"}) {
t.Errorf("expected carol not to see an ops-scoped item")
}
}
+1484 -18
View File
File diff suppressed because it is too large Load Diff
+179 -6
View File
@@ -12,9 +12,14 @@ import (
"strings"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
@@ -38,9 +43,25 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
t.Fatal("storage.New:", err)
}
acl, err := panelacl.New(dir)
if err != nil {
t.Fatal("panelacl.New:", err)
}
clStore, err := controllogic.NewStore(dir)
if err != nil {
t.Fatal("controllogic.NewStore:", err)
}
cfgStore, err := confmgr.New(dir)
if err != nil {
t.Fatal("confmgr.New:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
mux := http.NewServeMux()
api.New(brk, nil, store, "", log).Register(mux, "/api/v1")
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -201,7 +222,9 @@ func TestSearchSignals(t *testing.T) {
resp := get(t, srv, "/api/v1/signals/search?q=sine")
assertStatus(t, resp, http.StatusOK)
var signals []struct{ Name string `json:"name"` }
var signals []struct {
Name string `json:"name"`
}
readJSON(t, resp, &signals)
for _, s := range signals {
@@ -246,7 +269,9 @@ func TestInterfaceCRUD(t *testing.T) {
// Create
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected non-empty ID from create")
@@ -290,7 +315,9 @@ func TestInterfaceCRUD(t *testing.T) {
t.Fatal("clone:", err)
}
assertStatus(t, resp, http.StatusCreated)
var cloned struct{ ID string `json:"id"` }
var cloned struct {
ID string `json:"id"`
}
readJSON(t, resp, &cloned)
if cloned.ID == created.ID {
t.Error("clone produced same ID as original")
@@ -396,7 +423,7 @@ func TestStorageValidateID(t *testing.T) {
}
// Create a real interface first to get a valid ID.
id, err := store.Create([]byte(sampleXML))
id, err := store.Create([]byte(sampleXML), "")
if err != nil {
t.Fatal("create:", err)
}
@@ -416,7 +443,7 @@ func TestStorageValidateID(t *testing.T) {
if _, err := store.Get(bad); err == nil {
t.Errorf("Get(%q) should have failed, got nil error", bad)
}
if err := store.Update(bad, []byte(sampleXML)); err == nil {
if err := store.Update(bad, []byte(sampleXML), ""); err == nil {
t.Errorf("Update(%q) should have failed", bad)
}
if err := store.Delete(bad); err == nil {
@@ -425,6 +452,152 @@ func TestStorageValidateID(t *testing.T) {
}
}
// ── /api/v1/admin ─────────────────────────────────────────────────────────────
// adminSetup builds a server whose mux injects the user named by the
// "X-Test-User" header into the request context (mirroring the real access
// middleware), so admin-gating can be exercised. The policy restricts admin to
// the "ops" group, of which "alice" is a member.
func adminSetup(t *testing.T) (*httptest.Server, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
brk := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal("stub connect:", err)
}
brk.Register(ds)
dir := t.TempDir()
store, _ := storage.New(dir)
acl, _ := panelacl.New(dir)
clStore, _ := controllogic.NewStore(dir)
cfgStore, _ := confmgr.New(dir)
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
// "alice" is an admin (via the ops group); everyone else is a viewer.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
})
if err := policy.EnablePersistence(dir); err != nil {
t.Fatal("EnablePersistence:", err)
}
inner := http.NewServeMux()
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1")
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if u := r.Header.Get("X-Test-User"); u != "" {
r = r.WithContext(access.WithUser(r.Context(), u))
}
inner.ServeHTTP(w, r)
})
srv := httptest.NewServer(mux)
return srv, func() { srv.Close(); cancel() }
}
func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
t.Helper()
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, srv.URL+path, rdr)
if err != nil {
t.Fatal("NewRequest:", err)
}
if user != "" {
req.Header.Set("X-Test-User", user)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(method, path, err)
}
return resp
}
func TestAdminForbiddenForNonAdmin(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
// "bob" is only a viewer → 403 on every admin route.
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
}
func TestAdminUserAndGroupMutations(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
// alice (admin) assigns carol an auditor role in a new "team" group.
resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
[]byte(`{"roles":{"team":"auditor"}}`))
assertStatus(t, resp, http.StatusOK)
var snap access.AccessSnapshot
readJSON(t, resp, &snap)
var carol *access.UserInfo
for i := range snap.Users {
if snap.Users[i].Name == "carol" {
carol = &snap.Users[i]
}
}
if carol == nil {
t.Fatal("carol missing from snapshot")
}
if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
t.Errorf("carol = %+v, want auditor in team", carol)
}
// Create (with parent), rename, and delete a group.
assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
assertStatus(t, resp, http.StatusOK)
readJSON(t, resp, &snap)
if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
t.Errorf("groups after rename = %+v", snap.Groups)
}
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
// The built-in public group cannot be deleted.
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
}
func hasGroup(s access.AccessSnapshot, name string) bool {
for _, g := range s.Groups {
if g.Name == name {
return true
}
}
return false
}
func TestAdminStats(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
assertStatus(t, resp, http.StatusOK)
var stats map[string]any
readJSON(t, resp, &stats)
for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
if _, ok := stats[k]; !ok {
t.Errorf("stats missing key %q", k)
}
}
}
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
var _ = os.DevNull
+820
View File
@@ -0,0 +1,820 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
)
// configEnabled guards every config-manager handler; the store is always
// constructed today, but the nil check keeps the handlers safe if it is ever
// made optional.
func (h *Handler) configEnabled(w http.ResponseWriter) bool {
if h.cfg == nil {
jsonError(w, http.StatusServiceUnavailable, "configuration manager not enabled")
return false
}
return true
}
func pathVersion(r *http.Request) (int, error) {
return strconv.Atoi(r.PathValue("version"))
}
func configStatus(err error) int {
if errors.Is(err, confmgr.ErrNotFound) {
return http.StatusNotFound
}
return http.StatusBadRequest
}
// ── config sets ─────────────────────────────────────────────────────────────
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
sets, err := h.cfg.List(confmgr.KindSet)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, h.filterConfigMetas(r, sets))
}
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
set, err := h.cfg.GetSet(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, set)
}
func (h *Handler) createConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var set confmgr.ConfigSet
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
set.ID = ""
set.Owner = caller(r)
out, err := h.cfg.CreateSet(set, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.create", out.ID+" "+out.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, out)
}
func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
var set confmgr.ConfigSet
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if prev, err := h.cfg.GetSet(id); err == nil {
set.Owner = prev.Owner // owner is immutable across revisions
}
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.update", out.ID+" v"+strconv.Itoa(out.Version))
jsonOK(w, out)
}
func (h *Handler) deleteConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
if err := h.cfg.Delete(confmgr.KindSet, id); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listConfigSetVersions(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
versions, err := h.cfg.Versions(confmgr.KindSet, r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, versions)
}
func (h *Handler) getConfigSetVersion(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
set, err := h.cfg.GetSetVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, set)
}
func (h *Handler) promoteConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
if err := h.cfg.Promote(confmgr.KindSet, id, version); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.promote", id+" v"+strconv.Itoa(version))
set, err := h.cfg.GetSet(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, set)
}
func (h *Handler) forkConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
newID, err := h.cfg.Fork(confmgr.KindSet, id, version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
set, err := h.cfg.GetSet(newID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, set)
}
// diffConfigSets compares two set revisions. Query params: a, b (ids);
// optional av, bv (versions, default current).
func (h *Handler) diffConfigSets(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
q := r.URL.Query()
left, err := h.resolveSet(q.Get("a"), q.Get("av"))
if err != nil {
jsonError(w, configStatus(err), "left set: "+err.Error())
return
}
right, err := h.resolveSet(q.Get("b"), q.Get("bv"))
if err != nil {
jsonError(w, configStatus(err), "right set: "+err.Error())
return
}
jsonOK(w, confmgr.DiffSets(left, right))
}
func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
if id == "" {
return confmgr.ConfigSet{}, errors.New("missing set id")
}
if version == "" {
return h.cfg.GetSet(id)
}
v, err := strconv.Atoi(version)
if err != nil {
return confmgr.ConfigSet{}, errors.New("invalid version")
}
return h.cfg.GetSetVersion(id, v)
}
// ── config instances ────────────────────────────────────────────────────────
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
insts, err := h.cfg.List(confmgr.KindInstance)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, h.filterConfigMetas(r, insts))
}
// filterConfigMetas drops entries the caller may not see per their scope
// (private/group/global). Owner always sees their own; empty scope = global.
func (h *Handler) filterConfigMetas(r *http.Request, metas []confmgr.Meta) []confmgr.Meta {
user := caller(r)
out := make([]confmgr.Meta, 0, len(metas))
for _, m := range metas {
if h.policy.CanSee(user, m.Owner, m.Scope, m.Groups) {
out = append(out, m)
}
}
return out
}
func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
inst, err := h.cfg.GetInstance(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, inst)
}
func (h *Handler) createConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var inst confmgr.ConfigInstance
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
inst.ID = ""
inst.Owner = caller(r)
out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.create", out.ID+" "+out.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, out)
}
func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
var inst confmgr.ConfigInstance
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if prev, err := h.cfg.GetInstance(id); err == nil {
inst.Owner = prev.Owner // owner is immutable across revisions
}
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.update", out.ID+" v"+strconv.Itoa(out.Version))
jsonOK(w, out)
}
func (h *Handler) deleteConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
if err := h.cfg.Delete(confmgr.KindInstance, id); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listConfigInstanceVersions(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
versions, err := h.cfg.Versions(confmgr.KindInstance, r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, versions)
}
func (h *Handler) getConfigInstanceVersion(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
inst, err := h.cfg.GetInstanceVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, inst)
}
func (h *Handler) promoteConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
if err := h.cfg.Promote(confmgr.KindInstance, id, version); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.promote", id+" v"+strconv.Itoa(version))
inst, err := h.cfg.GetInstance(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, inst)
}
func (h *Handler) forkConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
newID, err := h.cfg.Fork(confmgr.KindInstance, id, version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
inst, err := h.cfg.GetInstance(newID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, inst)
}
// applyConfigInstance writes every resolvable parameter value of an instance to
// its target signal via the broker. Per-parameter outcomes are returned so a
// partial apply is reported faithfully.
func (h *Handler) applyConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
inst, err := h.cfg.GetInstance(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
set, err := h.cfg.SetForInstance(inst)
if err != nil {
jsonError(w, configStatus(err), "load set: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
write := func(ds, signal string, value any) error {
src, ok := h.broker.Source(ds)
if !ok {
return errors.New("unknown data source: " + ds)
}
return src.Write(ctx, signal, value)
}
res := confmgr.Apply(set, inst, write)
h.recordMutation(r, "config.instance.apply", id+" applied="+strconv.Itoa(res.Applied)+" failed="+strconv.Itoa(res.Failed))
jsonOK(w, res)
}
// diffConfigInstances compares two instance revisions. Query params: a, b
// (ids); optional av, bv (versions, default current).
func (h *Handler) diffConfigInstances(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
q := r.URL.Query()
left, err := h.resolveInstance(q.Get("a"), q.Get("av"))
if err != nil {
jsonError(w, configStatus(err), "left instance: "+err.Error())
return
}
right, err := h.resolveInstance(q.Get("b"), q.Get("bv"))
if err != nil {
jsonError(w, configStatus(err), "right instance: "+err.Error())
return
}
jsonOK(w, confmgr.DiffInstances(left, right))
}
func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, error) {
if id == "" {
return confmgr.ConfigInstance{}, errors.New("missing instance id")
}
if version == "" {
return h.cfg.GetInstance(id)
}
v, err := strconv.Atoi(version)
if err != nil {
return confmgr.ConfigInstance{}, errors.New("invalid version")
}
return h.cfg.GetInstanceVersion(id, v)
}
// validateConfigInstance evaluates the CUE rules bound to an instance's set
// against its stored values, without persisting anything. The structured
// RuleResult (violations + any transformed values) lets the UI surface
// per-parameter failures.
func (h *Handler) validateConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
inst, err := h.cfg.GetInstance(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
res, err := h.cfg.ValidateInstanceRules(inst)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, res)
}
// ── config snapshot / live diff ─────────────────────────────────────────────
// readResult is the per-signal outcome of a parallel live read.
type readResult struct {
val any
err error
}
// readSetSignals reads the current value of every distinct target signal of a
// set in parallel (deduplicated by ds+signal), bounded by ctx. The returned map
// is keyed by {ds, signal}.
func (h *Handler) readSetSignals(ctx context.Context, set confmgr.ConfigSet) map[[2]string]readResult {
jobs := make(map[[2]string]struct{}, len(set.Parameters))
for _, p := range set.Parameters {
jobs[[2]string{p.DS, p.Signal}] = struct{}{}
}
results := make(map[[2]string]readResult, len(jobs))
var mu sync.Mutex
var wg sync.WaitGroup
for k := range jobs {
wg.Add(1)
go func(ds, signal string) {
defer wg.Done()
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
mu.Lock()
results[[2]string{ds, signal}] = readResult{val: v.Data, err: err}
mu.Unlock()
}(k[0], k[1])
}
wg.Wait()
return results
}
// snapshotReader builds a confmgr.ReadFunc backed by a pre-read results map.
func snapshotReader(results map[[2]string]readResult) confmgr.ReadFunc {
return func(ds, signal string) (any, error) {
r, ok := results[[2]string{ds, signal}]
if !ok {
return nil, errors.New("signal not read")
}
return r.val, r.err
}
}
// snapshotConfigSet captures the current value of every target signal of a set
// and stores them as a new config instance. Body: optional {name}. Returns the
// created instance plus the per-parameter capture result.
func (h *Handler) snapshotConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
set, err := h.cfg.GetSet(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
var body struct {
Name string `json:"name"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results := h.readSetSignals(ctx, set)
snap := confmgr.Snapshot(set, snapshotReader(results))
name := body.Name
if name == "" {
name = set.Name + " snapshot " + time.Now().Format("2006-01-02 15:04:05")
}
inst := confmgr.ConfigInstance{
Name: name,
SetID: set.ID,
Owner: caller(r),
Values: snap.Values,
}
out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.snapshot", out.ID+" "+out.Name+" captured="+strconv.Itoa(snap.Captured)+" failed="+strconv.Itoa(snap.Failed))
w.WriteHeader(http.StatusCreated)
jsonOK(w, struct {
Instance confmgr.ConfigInstance `json:"instance"`
Snapshot confmgr.SnapshotResult `json:"snapshot"`
}{out, snap})
}
// diffConfigInstanceLive compares a stored instance against the current live
// values of its set's target signals, so an operator can see how the saved
// config differs from what the hardware currently holds. The stored side uses
// resolved values (instance value or parameter default) so default-backed
// parameters are not reported as spurious additions.
func (h *Handler) diffConfigInstanceLive(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
inst, err := h.cfg.GetInstance(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
set, err := h.cfg.SetForInstance(inst)
if err != nil {
jsonError(w, configStatus(err), "load set: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results := h.readSetSignals(ctx, set)
snap := confmgr.Snapshot(set, snapshotReader(results))
left := confmgr.ConfigInstance{ID: inst.ID, Name: inst.Name, Values: map[string]any{}}
for _, p := range set.Parameters {
if v, ok := inst.Resolve(p); ok {
left.Values[p.Key] = v
}
}
current := confmgr.ConfigInstance{Name: "current", Values: snap.Values}
jsonOK(w, confmgr.DiffInstances(left, current))
}
// ── config rules (CUE validation/transformation) ────────────────────────────
func (h *Handler) listConfigRules(w http.ResponseWriter, _ *http.Request) {
if !h.configEnabled(w) {
return
}
rules, err := h.cfg.List(confmgr.KindRule)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, rules)
}
func (h *Handler) getConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
rule, err := h.cfg.GetRule(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) createConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var rule confmgr.ConfigRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
rule.ID = ""
rule.Owner = caller(r)
out, err := h.cfg.CreateRule(rule, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.create", out.ID+" "+out.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, out)
}
func (h *Handler) updateConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
var rule confmgr.ConfigRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
out, err := h.cfg.UpdateRule(id, rule, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.update", out.ID+" v"+strconv.Itoa(out.Version))
jsonOK(w, out)
}
func (h *Handler) deleteConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
if err := h.cfg.Delete(confmgr.KindRule, id); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listConfigRuleVersions(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
versions, err := h.cfg.Versions(confmgr.KindRule, r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, versions)
}
func (h *Handler) getConfigRuleVersion(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
rule, err := h.cfg.GetRuleVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) promoteConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
if err := h.cfg.Promote(confmgr.KindRule, id, version); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.promote", id+" v"+strconv.Itoa(version))
rule, err := h.cfg.GetRule(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) forkConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
newID, err := h.cfg.Fork(confmgr.KindRule, id, version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
rule, err := h.cfg.GetRule(newID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, rule)
}
// checkConfigRule compiles a (possibly unsaved) CUE source and, when sample
// values are supplied, evaluates it against them. It powers the editor's live
// validation panel; nothing is persisted.
func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var body struct {
Source string `json:"source"`
Values map[string]any `json:"values"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
}
// previewConfigRule evaluates a (possibly unsaved) CUE source against a live
// snapshot of the bound set's target signals, without storing anything. Unlike
// check (which uses sample/default values), preview reads the current hardware
// values so the operator sees exactly what the rule would derive from the real
// configuration. Body: {setId, source}. Returns the captured snapshot plus the
// rule result (violations + transformed values).
func (h *Handler) previewConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var body struct {
SetID string `json:"setId"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
set, err := h.cfg.GetSet(body.SetID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results := h.readSetSignals(ctx, set)
snap := confmgr.Snapshot(set, snapshotReader(results))
res := confmgr.EvaluateRule(body.Source, snap.Values)
jsonOK(w, struct {
Snapshot confmgr.SnapshotResult `json:"snapshot"`
Result confmgr.RuleResult `json:"result"`
}{snap, res})
}
+146
View File
@@ -0,0 +1,146 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigRuleCRUD exercises the full config-rule REST surface: list, create,
// get, update, versioning (list/get/promote/fork), check, preview, and delete,
// plus the principal error paths.
func TestConfigRuleCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set the rule (and preview) can bind to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "S",
"parameters": []map[string]any{
{"key": "voltage", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 12.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Empty list initially.
resp = get(t, srv, "/api/v1/config/rules")
assertStatus(t, resp, http.StatusOK)
var list []map[string]any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no rules, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "cap",
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusCreated)
var rule struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &rule)
if rule.ID == "" {
t.Fatal("rule missing id")
}
// Create with invalid CUE → 400.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "bad",
"setId": set.ID,
"source": "voltage: <=",
})
assertStatus(t, resp, http.StatusBadRequest)
// Create with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/config/rules/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates backup).
resp = putRaw(t, srv, "/api/v1/config/rules/"+rule.ID, "application/json",
[]byte(`{"name":"cap2","setId":"`+set.ID+`","source":"voltage: <=30"}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 rule versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new id.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == rule.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Check (live validation of an unsaved source against sample values).
resp = postJSON(t, srv, "/api/v1/config/rules/check", map[string]any{
"source": "voltage: <=24",
"values": map[string]any{"voltage": 20.0},
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Check with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules/check", "application/json", []byte(`{`))
assertStatus(t, resp, http.StatusBadRequest)
// Preview against the bound set's live signals.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Preview with an unknown set → 404.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": "does-not-exist",
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusNotFound)
// Delete.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNotFound)
}
+107
View File
@@ -0,0 +1,107 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigSetCRUD exercises create → get → version → apply over HTTP, using
// the stub data source's writable "setpoint" signal as the apply target.
func TestConfigManagerEndToEnd(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Create a config set targeting the stub's writable setpoint.
setBody := map[string]any{
"name": "Stub PSU",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 42.0, "mandatory": true, "min": 0.0, "max": 100.0},
},
}
resp := postJSON(t, srv, "/api/v1/config/sets", setBody)
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &set)
if set.ID == "" || set.Version != 1 {
t.Fatalf("unexpected created set: %+v", set)
}
// List sets includes it.
resp = get(t, srv, "/api/v1/config/sets")
assertStatus(t, resp, http.StatusOK)
var sets []map[string]any
readJSON(t, resp, &sets)
if len(sets) != 1 {
t.Fatalf("want 1 set, got %d", len(sets))
}
// Create an instance assigning a concrete value.
instBody := map[string]any{
"name": "nominal",
"setId": set.ID,
"values": map[string]any{"sp": 73.0},
}
resp = postJSON(t, srv, "/api/v1/config/instances", instBody)
assertStatus(t, resp, http.StatusCreated)
var inst struct {
ID string `json:"id"`
}
readJSON(t, resp, &inst)
if inst.ID == "" {
t.Fatal("instance missing id")
}
// Apply writes the value to the target signal.
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/apply", nil)
assertStatus(t, resp, http.StatusOK)
var res struct {
Applied int `json:"applied"`
Failed int `json:"failed"`
Entries []struct {
Signal string `json:"signal"`
OK bool `json:"ok"`
} `json:"entries"`
}
readJSON(t, resp, &res)
if res.Applied != 1 || res.Failed != 0 {
t.Fatalf("apply summary: applied=%d failed=%d", res.Applied, res.Failed)
}
if len(res.Entries) != 1 || res.Entries[0].Signal != "setpoint" || !res.Entries[0].OK {
t.Errorf("unexpected apply entries: %+v", res.Entries)
}
// Delete the set soft-deletes it.
resp = deleteReq(t, srv, "/api/v1/config/sets/"+set.ID)
assertStatus(t, resp, http.StatusNoContent)
resp = get(t, srv, "/api/v1/config/sets/"+set.ID)
assertStatus(t, resp, http.StatusNotFound)
}
// TestConfigInstanceRejectsInvalidValue verifies server-side validation against
// the bound set (value above the parameter's max).
func TestConfigInstanceRejectsInvalidValue(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "S",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "max": 10.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
"name": "bad",
"setId": set.ID,
"values": map[string]any{"sp": 999.0},
})
assertStatus(t, resp, http.StatusBadRequest)
}
+158
View File
@@ -0,0 +1,158 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigSetVersioning exercises the set update/version/promote/fork/diff and
// snapshot endpoints that the base end-to-end test does not reach.
func TestConfigSetVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func(name string) string {
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": name,
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var s struct {
ID string `json:"id"`
}
readJSON(t, resp, &s)
return s.ID
}
id := mk("Set A")
// Update → version bump + backup.
resp := putRaw(t, srv, "/api/v1/config/sets/"+id, "application/json",
[]byte(`{"name":"Set A v2","parameters":[{"key":"sp","ds":"stub","signal":"setpoint","type":"float64","default":2.0}]}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 set versions, got %d", len(versions))
}
// Get a specific version.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new set id.
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == id {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Diff the original against the fork.
resp = get(t, srv, "/api/v1/config/sets/diff?a="+id+"&b="+fork.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Diff with a missing left id → 400.
resp = get(t, srv, "/api/v1/config/sets/diff?a=&b="+fork.ID)
assertStatus(t, resp, http.StatusBadRequest)
// Snapshot the set into a new instance.
resp = postJSON(t, srv, "/api/v1/config/sets/"+id+"/snapshot", map[string]any{"name": "snap-1"})
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
}
// TestConfigInstanceVersioning exercises the instance update/version/promote/
// fork/validate and live-diff endpoints.
func TestConfigInstanceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set to bind instances to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "Set B",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Create an instance.
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
"name": "inst", "setId": set.ID, "values": map[string]any{"sp": 5.0},
})
assertStatus(t, resp, http.StatusCreated)
var inst struct {
ID string `json:"id"`
}
readJSON(t, resp, &inst)
// Get it.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// List instances.
resp = get(t, srv, "/api/v1/config/instances")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update → version bump.
resp = putRaw(t, srv, "/api/v1/config/instances/"+inst.ID, "application/json",
[]byte(`{"name":"inst v2","setId":"`+set.ID+`","values":{"sp":7.0}}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 instance versions, got %d", len(versions))
}
// Get a version.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote + fork.
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Validate against the (empty) rule set → 200.
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/validate", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Live diff against current signal values → 200.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/livediff")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Delete the instance.
resp = deleteReq(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusNoContent)
}
+438
View File
@@ -0,0 +1,438 @@
package api_test
import (
"encoding/json"
"io"
"net/http"
"testing"
)
// These tests exercise the many handlers reachable through the default setup()
// harness, whose policy is unconfigured (anonymous caller → write/admin), so
// permission gates default to "allow" and the focus is handler behaviour.
// ── /me ───────────────────────────────────────────────────────────────────────
func TestGetMe(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/me")
assertStatus(t, resp, http.StatusOK)
var me map[string]any
readJSON(t, resp, &me)
for _, k := range []string{"user", "level", "groups", "canEditLogic", "canViewAudit", "canAdmin", "defaultZoom"} {
if _, ok := me[k]; !ok {
t.Errorf("/me missing key %q", k)
}
}
// Unconfigured policy → anonymous caller has write level.
if me["level"] != "write" {
t.Errorf("level = %v, want write", me["level"])
}
}
// ── /groups (signal group tree) ───────────────────────────────────────────────
func TestGroupsRoundTrip(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Initially valid JSON.
resp := get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Replace with a JSON array → 204.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`[{"name":"A"},{"name":"B"}]`))
assertStatus(t, resp, http.StatusNoContent)
// Read back what we wrote.
resp = get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var tree []map[string]any
if err := json.Unmarshal(body, &tree); err != nil {
t.Fatalf("groups body not a JSON array: %v (%s)", err, body)
}
if len(tree) != 2 {
t.Fatalf("expected 2 groups, got %d", len(tree))
}
// A non-array body is rejected.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`{"not":"array"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /usergroups ───────────────────────────────────────────────────────────────
func TestListUserGroups(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/usergroups")
assertStatus(t, resp, http.StatusOK)
var names []string
readJSON(t, resp, &names)
// Unconfigured policy has no named groups; the handler must still return [].
if names == nil {
t.Error("expected non-nil (possibly empty) group list")
}
}
// ── /folders ──────────────────────────────────────────────────────────────────
func TestFolderCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no folders, got %d", len(list))
}
// Missing name → 400.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": ""})
assertStatus(t, resp, http.StatusBadRequest)
// Create.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": "Reactor"})
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected folder id")
}
// Update.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json",
[]byte(`{"name":"Reactor Hall"}`))
assertStatus(t, resp, http.StatusNoContent)
// Update with empty name → 400.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json", []byte(`{"name":""}`))
assertStatus(t, resp, http.StatusBadRequest)
// Update unknown folder → 404.
resp = putRaw(t, srv, "/api/v1/folders/fld-nope", "application/json", []byte(`{"name":"x"}`))
assertStatus(t, resp, http.StatusNotFound)
// It now appears in the list.
resp = get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
readJSON(t, resp, &list)
if len(list) != 1 {
t.Fatalf("expected 1 folder, got %d", len(list))
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/folders/"+created.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete unknown → 404.
resp = deleteReq(t, srv, "/api/v1/folders/fld-nope")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /interfaces/{id}/acl ──────────────────────────────────────────────────────
func TestPanelACL(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
// GET ACL of a fresh panel.
resp = get(t, srv, "/api/v1/interfaces/"+created.ID+"/acl")
assertStatus(t, resp, http.StatusOK)
var acl map[string]any
readJSON(t, resp, &acl)
if _, ok := acl["perm"]; !ok {
t.Error("ACL response missing perm")
}
// PUT ACL — set a public read level.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"public":"read","grants":[]}`))
assertStatus(t, resp, http.StatusNoContent)
// PUT ACL on a non-existent panel → 404.
resp = putRaw(t, srv, "/api/v1/interfaces/nope/acl", "application/json",
[]byte(`{"public":"read"}`))
assertStatus(t, resp, http.StatusNotFound)
// PUT ACL referencing an unknown folder → 400.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"folder":"fld-nope"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/reorder ───────────────────────────────────────────────────────
func TestReorderInterfaces(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func() string {
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var c struct {
ID string `json:"id"`
}
readJSON(t, resp, &c)
return c.ID
}
a, b := mk(), mk()
// Reorder at root (no folder).
resp := postJSON(t, srv, "/api/v1/interfaces/reorder", map[string]any{"ids": []string{b, a}})
assertStatus(t, resp, http.StatusNoContent)
// Reorder into a non-existent folder → 404.
resp = postJSON(t, srv, "/api/v1/interfaces/reorder",
map[string]any{"folder": "fld-nope", "ids": []string{a}})
assertStatus(t, resp, http.StatusNotFound)
// Malformed body → 400.
resp = postRaw(t, srv, "/api/v1/interfaces/reorder", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/{id}/versions ─────────────────────────────────────────────────
func TestInterfaceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
id := created.ID
// Update once to create a backup (v1) and bump current to v2.
upd := `<interface id="" name="Test Panel" version="2" w="800" h="600"></interface>`
resp = putRaw(t, srv, "/api/v1/interfaces/"+id, "application/xml", []byte(upd))
assertStatus(t, resp, http.StatusNoContent)
// List versions — at least the backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 versions, got %d", len(versions))
}
// Get the v1 backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/abc")
assertStatus(t, resp, http.StatusBadRequest)
// Tag v1.
resp = putRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/tag?tag=golden", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Promote v1 → becomes new current.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Fork v1 → brand-new panel.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == id {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Version ops on a missing panel → 404.
resp = get(t, srv, "/api/v1/interfaces/missing/versions")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /controllogic ─────────────────────────────────────────────────────────────
func TestControlLogicCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Empty list initially.
resp := get(t, srv, "/api/v1/controllogic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no control logic, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "Watchdog", "enabled": true})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &g)
if g.ID == "" {
t.Fatal("expected control logic id")
}
// Get.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/controllogic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates a backup).
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json",
[]byte(`{"name":"Watchdog v2","enabled":false}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 control-logic versions, got %d", len(versions))
}
// Promote v1.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new graph id.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == g.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNotFound)
}
// ── /audit (Nop audit log, anonymous can view on an open policy) ───────────────
func TestGetAuditOpen(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/audit")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad start time → 400.
resp = get(t, srv, "/api/v1/audit?start=not-a-time")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /synthetic disabled guards ────────────────────────────────────────────────
// Synthetic is nil in the default harness, so every route must report 503
// (service unavailable) rather than panicking.
func TestSyntheticDisabledRoutes(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
cases := []struct {
method, path string
body []byte
}{
{http.MethodGet, "/api/v1/synthetic/x", nil},
{http.MethodPut, "/api/v1/synthetic/x", []byte(`{}`)},
{http.MethodDelete, "/api/v1/synthetic/x", nil},
{http.MethodPost, "/api/v1/synthetic/trace", []byte(`{}`)},
{http.MethodGet, "/api/v1/synthetic/x/versions", nil},
{http.MethodGet, "/api/v1/synthetic/x/versions/1", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/promote", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/fork", nil},
}
for _, c := range cases {
var resp *http.Response
switch c.method {
case http.MethodGet:
resp = get(t, srv, c.path)
case http.MethodPut:
resp = putRaw(t, srv, c.path, "application/json", c.body)
case http.MethodDelete:
resp = deleteReq(t, srv, c.path)
case http.MethodPost:
resp = postRaw(t, srv, c.path, "application/json", c.body)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("%s %s: status %d, want 503", c.method, c.path, resp.StatusCode)
}
resp.Body.Close()
}
}
// ── /controllogic/{id}/versions/{version} ─────────────────────────────────────
func TestControlLogicGetVersion(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "G"})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
}
readJSON(t, resp, &g)
// Bump so a v1 backup exists.
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json", []byte(`{"name":"G2"}`))
assertStatus(t, resp, http.StatusOK)
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version number → 400.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /config/instances/diff ────────────────────────────────────────────────────
func TestDiffConfigInstancesMissing(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Missing left id → 400.
resp := get(t, srv, "/api/v1/config/instances/diff?a=&b=")
assertStatus(t, resp, http.StatusBadRequest)
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
)
// TestExtractLogicBlock covers the three branches of the <logic> extractor.
func TestExtractLogicBlock(t *testing.T) {
if got := extractLogicBlock([]byte(`<i><logic><n/></logic></i>`)); got != "<logic><n/></logic>" {
t.Errorf("extract = %q", got)
}
if got := extractLogicBlock([]byte(`<i></i>`)); got != "" {
t.Errorf("no logic: want empty, got %q", got)
}
if got := extractLogicBlock([]byte(`<i><logic>unterminated`)); got != "" {
t.Errorf("unterminated: want empty, got %q", got)
}
}
// TestDataTypeName covers every DataType→token mapping plus the default.
func TestDataTypeName(t *testing.T) {
cases := map[datasource.DataType]string{
datasource.TypeFloat64: "float64",
datasource.TypeFloat64Array: "float64[]",
datasource.TypeString: "string",
datasource.TypeInt64: "int64",
datasource.TypeBool: "bool",
datasource.TypeEnum: "enum",
datasource.DataType(255): "unknown",
}
for typ, want := range cases {
if got := dataTypeName(typ); got != want {
t.Errorf("dataTypeName(%v) = %q, want %q", typ, got, want)
}
}
}
// TestSynVisible covers each visibility branch of synVisible.
func TestSynVisible(t *testing.T) {
cases := []struct {
name string
def synthetic.SignalDef
user string
panel string
groups []string
want bool
}{
{"user own", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "alice", "", nil, true},
{"user other", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "bob", "", nil, false},
{"panel match", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p1", nil, true},
{"panel mismatch", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p2", nil, false},
{"global", synthetic.SignalDef{Visibility: "global"}, "", "", nil, true},
{"legacy empty", synthetic.SignalDef{}, "", "", nil, true},
{"group member", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"ops"}, true},
{"group outsider", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"hr"}, false},
}
for _, tc := range cases {
if got := synVisible(tc.def, tc.user, tc.panel, tc.groups); got != tc.want {
t.Errorf("%s: synVisible = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestPanelScope covers the nil/public→global, group, and private branches.
func TestPanelScope(t *testing.T) {
if sc, _ := panelScope(nil); sc != access.ScopeGlobal {
t.Errorf("nil acl: scope = %q, want global", sc)
}
if sc, _ := panelScope(&panelacl.PanelACL{Public: "read"}); sc != access.ScopeGlobal {
t.Errorf("public acl: scope = %q, want global", sc)
}
sc, groups := panelScope(&panelacl.PanelACL{
Grants: []panelacl.Grant{{Kind: "group", Name: "ops"}, {Kind: "user", Name: "x"}},
})
if sc != access.ScopeGroup || len(groups) != 1 || groups[0] != "ops" {
t.Errorf("group acl: scope=%q groups=%v", sc, groups)
}
if sc, _ := panelScope(&panelacl.PanelACL{Owner: "alice"}); sc != access.ScopePrivate {
t.Errorf("private acl: scope = %q, want private", sc)
}
}
// TestClientIP covers the XFF, X-Real-IP, host:port, and bare-RemoteAddr cases.
func TestClientIP(t *testing.T) {
mk := func(set func(*http.Request)) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
set(r)
return r
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") })); got != "1.2.3.4" {
t.Errorf("XFF list: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "9.9.9.9") })); got != "9.9.9.9" {
t.Errorf("XFF single: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Real-IP", "8.8.8.8") })); got != "8.8.8.8" {
t.Errorf("X-Real-IP: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "10.0.0.1:5555" })); got != "10.0.0.1" {
t.Errorf("host:port: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "bare-addr" })); got != "bare-addr" {
t.Errorf("bare addr: got %q", got)
}
}
+175
View File
@@ -0,0 +1,175 @@
package api_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
// setupSynthetic builds an API server whose synthetic data source is enabled, so
// the synthetic CRUD/versioning/trace handlers run their real bodies.
func setupSynthetic(t *testing.T) (*httptest.Server, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
brk := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal("stub connect:", err)
}
brk.Register(ds)
dir := t.TempDir()
store, _ := storage.New(dir)
acl, _ := panelacl.New(dir)
clStore, _ := controllogic.NewStore(dir)
cfgStore, _ := confmgr.New(dir)
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
synthDS := synthetic.New(dir, brk, log)
if err := synthDS.Connect(ctx); err != nil {
t.Fatal("synthetic connect:", err)
}
brk.Register(synthDS)
mux := http.NewServeMux()
api.New(brk, synthDS, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() { srv.Close(); cancel() }
}
// putJSON marshals body and issues a PUT, mirroring postJSON.
func putJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal("json.Marshal:", err)
}
return putRaw(t, srv, path, "application/json", b)
}
// syntheticBody returns a minimal valid graph signal sourced from the stub's
// "sine" signal through a gain op.
func syntheticBody(name string) map[string]any {
return map[string]any{
"name": name,
"visibility": "global",
"graph": map[string]any{
"output": "out",
"nodes": []map[string]any{
{"id": "a", "kind": "source", "ds": "stub", "signal": "sine"},
{"id": "g", "kind": "op", "op": "gain", "inputs": []string{"a"},
"params": map[string]any{"k": 2.0}},
{"id": "out", "kind": "output", "inputs": []string{"g"}},
},
},
}
}
func TestSyntheticCRUDEnabled(t *testing.T) {
srv, teardown := setupSynthetic(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/synthetic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no synthetic signals, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Duplicate create → 400 (AddSignal rejects an existing name).
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusBadRequest)
// Invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/synthetic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (changes gain factor).
upd := syntheticBody("doubled")
upd["graph"].(map[string]any)["nodes"].([]map[string]any)[1]["params"] = map[string]any{"k": 3.0}
resp = putJSON(t, srv, "/api/v1/synthetic/doubled", upd)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update missing → 404.
resp = putJSON(t, srv, "/api/v1/synthetic/nope", syntheticBody("nope"))
assertStatus(t, resp, http.StatusNotFound)
// Versions list (the update created a backup).
resp = get(t, srv, "/api/v1/synthetic/doubled/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 synthetic versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version → 400.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Fork v1 → new signal.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Trace an unsaved graph.
resp = postJSON(t, srv, "/api/v1/synthetic/trace", syntheticBody("scratch"))
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Trace with invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic/trace", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Delete.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNotFound)
}
+65
View File
@@ -0,0 +1,65 @@
// Package audit records system-affecting actions (signal writes by users or the
// control-logic engine, interface and control-logic mutations) to an append-only
// SQLite log that audit staff can query later. It is enabled via [audit] in the
// config; when disabled a no-op Recorder is used so call sites need no guards.
package audit
import "time"
// Actor types distinguish human-initiated actions from automated ones.
const (
ActorUser = "user" // action attributed to an authenticated end-user
ActorSystem = "system" // action performed by the control-logic engine
)
// Outcomes record whether the action succeeded.
const (
OutcomeOK = "ok"
OutcomeError = "error"
)
// Event is a single audit record. Optional fields are omitted from the database
// when empty. Time defaults to the current time when zero.
type Event struct {
Time time.Time `json:"time"`
Actor string `json:"actor"` // username, or graph name for system actions
ActorType string `json:"actorType"` // ActorUser | ActorSystem
Action string `json:"action"` // e.g. "signal.write", "interface.update"
DS string `json:"ds,omitempty"` // data source (signal writes)
Signal string `json:"signal,omitempty"` // signal / target name
Value string `json:"value,omitempty"` // serialised written value
Detail string `json:"detail,omitempty"` // free-form context (id, graph, trigger)
IP string `json:"ip,omitempty"` // client address (user actions)
Outcome string `json:"outcome"` // OutcomeOK | OutcomeError
Error string `json:"error,omitempty"` // message when Outcome==OutcomeError
}
// Filter selects a subset of events for Query. Zero-valued fields are ignored.
type Filter struct {
Start time.Time
End time.Time
Actor string
Action string
DS string
Signal string
Limit int // <=0 means a default cap is applied
}
// Recorder appends events and answers queries. Implementations must be safe for
// concurrent use and Record must never block the caller for long.
type Recorder interface {
Record(Event)
Query(Filter) ([]Event, error)
Close() error
}
// nopRecorder is used when auditing is disabled. It discards every event.
type nopRecorder struct{}
func (nopRecorder) Record(Event) {}
func (nopRecorder) Query(Filter) ([]Event, error) { return []Event{}, nil }
func (nopRecorder) Close() error { return nil }
// Nop returns a Recorder that discards everything. Call sites can hold a Recorder
// unconditionally and call Record without checking whether auditing is enabled.
func Nop() Recorder { return nopRecorder{} }
+91
View File
@@ -0,0 +1,91 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
// TestNopRecorder covers the disabled-auditing no-op implementation.
func TestNopRecorder(t *testing.T) {
rec := Nop()
rec.Record(Event{Action: "x"}) // must not panic
got, err := rec.Query(Filter{})
if err != nil {
t.Fatalf("Nop Query: %v", err)
}
if len(got) != 0 {
t.Errorf("Nop Query returned %d events, want 0", len(got))
}
if err := rec.Close(); err != nil {
t.Errorf("Nop Close: %v", err)
}
}
// TestQueryFilters covers the End, DS, and Signal (LIKE) filter branches plus
// the default-outcome and zero-time stamping in Record.
func TestQueryFilters(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
// Anchor base in the past so the zero-Time 'c' record (stamped with now)
// sorts after a/b and stays out of the End window below.
base := time.Now().Add(-time.Hour)
rec.Record(Event{Time: base, Actor: "a", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:TEMP"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "b", ActorType: ActorUser, Action: "signal.write", DS: "synthetic", Signal: "PV:FLOW"})
// Zero Time and empty Outcome exercise the defaulting branches in Record.
rec.Record(Event{Actor: "c", ActorType: ActorUser, Action: "interface.update"})
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
// End filter: only events at or before base.
upTo, err := rec.Query(Filter{End: base.Add(500 * time.Millisecond)})
if err != nil {
t.Fatal("Query end:", err)
}
if len(upTo) != 1 || upTo[0].Actor != "a" {
t.Errorf("end filter = %+v, want one 'a' event", upTo)
}
// DS filter.
byDS, err := rec.Query(Filter{DS: "synthetic"})
if err != nil {
t.Fatal("Query ds:", err)
}
if len(byDS) != 1 || byDS[0].Signal != "PV:FLOW" {
t.Errorf("ds filter = %+v, want one PV:FLOW event", byDS)
}
// Signal LIKE filter (substring match).
bySignal, err := rec.Query(Filter{Signal: "TEMP"})
if err != nil {
t.Fatal("Query signal:", err)
}
if len(bySignal) != 1 || bySignal[0].DS != "epics" {
t.Errorf("signal filter = %+v, want one epics event", bySignal)
}
// The zero-time/empty-outcome record was persisted with a default outcome.
def, err := rec.Query(Filter{Actor: "c"})
if err != nil {
t.Fatal("Query actor c:", err)
}
if len(def) != 1 || def[0].Outcome != OutcomeOK {
t.Errorf("defaulted record = %+v, want one event with outcome ok", def)
}
if def[0].Time.IsZero() {
t.Error("zero Time should have been stamped with now")
}
}
+183
View File
@@ -0,0 +1,183 @@
package audit
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no CGo, keeps the single static binary)
)
// defaultQueryLimit caps how many rows Query returns when the filter does not
// specify a smaller limit, protecting the API and UI from unbounded result sets.
const defaultQueryLimit = 1000
// writeBuffer is the depth of the async insert queue. When full, Record falls
// back to a synchronous insert so events are never silently dropped.
const writeBuffer = 4096
// sqliteRecorder appends events to a SQLite database. Inserts are normally
// handled by a background goroutine so Record does not block the calling write
// path; the queue has a synchronous fallback to guarantee durability under load.
type sqliteRecorder struct {
db *sql.DB
log *slog.Logger
ch chan Event
wg sync.WaitGroup
closed chan struct{}
once sync.Once
}
// NewSQLite opens (creating if needed) the audit database at path and starts the
// background writer. The returned Recorder must be Closed on shutdown.
func NewSQLite(path string, log *slog.Logger) (Recorder, error) {
// WAL + a busy timeout let the async writer and synchronous query/fallback
// paths share the file without "database is locked" errors.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open audit db: %w", err)
}
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init audit schema: %w", err)
}
r := &sqliteRecorder{
db: db,
log: log,
ch: make(chan Event, writeBuffer),
closed: make(chan struct{}),
}
r.wg.Add(1)
go r.writeLoop()
return r, nil
}
const schema = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ns INTEGER NOT NULL,
actor TEXT NOT NULL,
actor_type TEXT NOT NULL,
action TEXT NOT NULL,
ds TEXT NOT NULL DEFAULT '',
signal TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok',
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts_ns);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
`
func (r *sqliteRecorder) Record(e Event) {
if e.Time.IsZero() {
e.Time = time.Now()
}
if e.Outcome == "" {
e.Outcome = OutcomeOK
}
select {
case <-r.closed:
// Recorder is shutting down; best-effort synchronous insert.
r.insert(e)
case r.ch <- e:
default:
// Queue full: write synchronously rather than drop an audit record.
r.insert(e)
}
}
func (r *sqliteRecorder) writeLoop() {
defer r.wg.Done()
for e := range r.ch {
r.insert(e)
}
}
func (r *sqliteRecorder) insert(e Event) {
_, err := r.db.Exec(
`INSERT INTO audit_log (ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Time.UnixNano(), e.Actor, e.ActorType, e.Action,
e.DS, e.Signal, e.Value, e.Detail, e.IP, e.Outcome, e.Error,
)
if err != nil {
r.log.Error("audit: insert failed", "action", e.Action, "err", err)
}
}
func (r *sqliteRecorder) Query(f Filter) ([]Event, error) {
var where []string
var args []any
if !f.Start.IsZero() {
where = append(where, "ts_ns >= ?")
args = append(args, f.Start.UnixNano())
}
if !f.End.IsZero() {
where = append(where, "ts_ns <= ?")
args = append(args, f.End.UnixNano())
}
if f.Actor != "" {
where = append(where, "actor = ?")
args = append(args, f.Actor)
}
if f.Action != "" {
where = append(where, "action = ?")
args = append(args, f.Action)
}
if f.DS != "" {
where = append(where, "ds = ?")
args = append(args, f.DS)
}
if f.Signal != "" {
where = append(where, "signal LIKE ?")
args = append(args, "%"+f.Signal+"%")
}
limit := f.Limit
if limit <= 0 || limit > defaultQueryLimit {
limit = defaultQueryLimit
}
q := "SELECT ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error FROM audit_log"
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY ts_ns DESC LIMIT ?"
args = append(args, limit)
rows, err := r.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("query audit log: %w", err)
}
defer rows.Close()
out := []Event{}
for rows.Next() {
var e Event
var tsNs int64
if err := rows.Scan(&tsNs, &e.Actor, &e.ActorType, &e.Action,
&e.DS, &e.Signal, &e.Value, &e.Detail, &e.IP, &e.Outcome, &e.Error); err != nil {
return nil, fmt.Errorf("scan audit row: %w", err)
}
e.Time = time.Unix(0, tsNs)
out = append(out, e)
}
return out, rows.Err()
}
func (r *sqliteRecorder) Close() error {
r.once.Do(func() {
close(r.closed)
close(r.ch)
})
r.wg.Wait()
return r.db.Close()
}
+68
View File
@@ -0,0 +1,68 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestSQLiteRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
base := time.Now()
rec.Record(Event{Time: base, Actor: "alice", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:A", Value: "1.5"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "flow1", ActorType: ActorSystem, Action: "signal.write", DS: "epics", Signal: "PV:B", Value: "0"})
rec.Record(Event{Time: base.Add(2 * time.Second), Actor: "bob", ActorType: ActorUser, Action: "interface.update", Detail: "panel-1", Outcome: OutcomeError, Error: "denied"})
// Flush the async writer.
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
all, err := rec.Query(Filter{})
if err != nil {
t.Fatal("Query:", err)
}
if len(all) != 3 {
t.Fatalf("got %d events, want 3", len(all))
}
// Newest first.
if all[0].Actor != "bob" {
t.Errorf("first actor = %q, want bob", all[0].Actor)
}
byActor, err := rec.Query(Filter{Actor: "alice"})
if err != nil {
t.Fatal("Query actor:", err)
}
if len(byActor) != 1 || byActor[0].Signal != "PV:A" {
t.Errorf("actor filter = %+v, want one PV:A event", byActor)
}
byAction, err := rec.Query(Filter{Action: "signal.write"})
if err != nil {
t.Fatal("Query action:", err)
}
if len(byAction) != 2 {
t.Errorf("action filter returned %d, want 2", len(byAction))
}
since, err := rec.Query(Filter{Start: base.Add(1500 * time.Millisecond)})
if err != nil {
t.Fatal("Query start:", err)
}
if len(since) != 1 || since[0].Actor != "bob" {
t.Errorf("time filter = %+v, want one bob event", since)
}
}
+94 -11
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
@@ -41,18 +42,38 @@ type Broker struct {
sources map[string]datasource.DataSource
subs map[SignalRef]*signalSub
log *slog.Logger
maxInterval time.Duration // 0 = unlimited
log *slog.Logger
}
// Option is a functional option for Broker configuration.
type Option func(*Broker)
// WithMaxUpdateRate limits fan-out to at most hz updates per second per signal.
// When upstream delivers faster, intermediate values are coalesced: the most
// recent value in each interval is forwarded once the interval elapses.
// A value ≤ 0 disables rate limiting (the default).
func WithMaxUpdateRate(hz float64) Option {
return func(b *Broker) {
if hz > 0 {
b.maxInterval = time.Duration(float64(time.Second) / hz)
}
}
}
// New creates a Broker whose upstream subscriptions are bound to ctx.
// Cancel ctx (or the parent context passed to main) to shut everything down.
func New(ctx context.Context, log *slog.Logger) *Broker {
return &Broker{
func New(ctx context.Context, log *slog.Logger, opts ...Option) *Broker {
b := &Broker{
ctx: ctx,
sources: make(map[string]datasource.DataSource),
subs: make(map[SignalRef]*signalSub),
log: log,
}
for _, opt := range opts {
opt(b)
}
return b
}
// Register adds a DataSource to the broker. Must be called before Subscribe.
@@ -169,7 +190,36 @@ func (b *Broker) unsubscribe(ref SignalRef, ch chan<- Update) {
// fanOut reads values from rawCh and dispatches them to all registered clients.
// It exits when sub.done is closed or rawCh is closed.
//
// When b.maxInterval > 0, updates that arrive faster than the interval are
// coalesced: only the most recent value in each interval window is forwarded,
// delivered at the end of the interval by a flush ticker. This ensures the
// latest value is always eventually seen even when the source fires rapidly.
func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.Value) {
maxInterval := b.maxInterval
var lastSent time.Time
var pending *Update
// Only allocate a ticker when rate-limiting is configured.
var flushC <-chan time.Time
if maxInterval > 0 {
t := time.NewTicker(maxInterval)
defer t.Stop()
flushC = t.C
}
dispatch := func(u Update) {
sub.mu.RLock()
for ch := range sub.clients {
select {
case ch <- u:
default: // slow consumer: drop rather than block
}
}
sub.mu.RUnlock()
}
for {
select {
case v, ok := <-rawCh:
@@ -177,15 +227,23 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
return
}
update := Update{Ref: ref, Value: v}
sub.mu.RLock()
for ch := range sub.clients {
select {
case ch <- update:
default:
// slow consumer: drop rather than block
}
// Rate-limit: if an interval is configured and the last delivery
// was recent, hold this update as pending (coalesce).
if maxInterval > 0 && !lastSent.IsZero() && time.Since(lastSent) < maxInterval {
pending = &update
continue
}
lastSent = time.Now()
pending = nil
dispatch(update)
case <-flushC:
// Deliver the most recent coalesced value, if any.
if pending != nil {
lastSent = time.Now()
dispatch(*pending)
pending = nil
}
sub.mu.RUnlock()
case <-sub.done:
return
@@ -193,6 +251,31 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
}
}
// ReadNow performs a one-shot synchronous read of a signal's current value.
// It starts a dedicated upstream subscription, returns the first value the data
// source delivers, then tears the subscription down. The read is bounded by ctx
// (callers should pass a timeout). This bypasses the shared fan-out cache because
// not every signal of interest (e.g. arbitrary config-set targets) is otherwise
// subscribed.
func (b *Broker) ReadNow(ctx context.Context, ref SignalRef) (datasource.Value, error) {
ds, ok := b.Source(ref.DS)
if !ok {
return datasource.Value{}, fmt.Errorf("unknown data source %q", ref.DS)
}
ch := make(chan datasource.Value, 1)
cancel, err := ds.Subscribe(ctx, ref.Name, ch)
if err != nil {
return datasource.Value{}, fmt.Errorf("read %s/%s: %w", ref.DS, ref.Name, err)
}
defer cancel()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return datasource.Value{}, ctx.Err()
}
}
// ActiveSubscriptions returns the number of currently active upstream signal
// subscriptions. Useful for diagnostics and tests.
func (b *Broker) ActiveSubscriptions() int {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// BenchmarkFanOut measures the end-to-end latency and throughput of the broker
// fan-out with varying numbers of downstream clients.
func BenchmarkFanOut1Client(b *testing.B) { benchFanOut(b, 1) }
func BenchmarkFanOut1Client(b *testing.B) { benchFanOut(b, 1) }
func BenchmarkFanOut10Clients(b *testing.B) { benchFanOut(b, 10) }
func BenchmarkFanOut20Clients(b *testing.B) { benchFanOut(b, 20) }
func BenchmarkFanOut100Clients(b *testing.B) { benchFanOut(b, 100) }
+269
View File
@@ -0,0 +1,269 @@
package broker_test
import (
"context"
"fmt"
"log/slog"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/stub"
)
// newBrokerN builds a broker backed by a stub with n dynamically generated signals.
func newBrokerN(tb testing.TB, n int) (*broker.Broker, context.CancelFunc) {
tb.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.Default()
b := broker.New(ctx, log)
ds := stub.NewN(n)
if err := ds.Connect(ctx); err != nil {
cancel()
tb.Fatal(err)
}
b.Register(ds)
return b, cancel
}
// TestStress_ManySignalsManyClients subscribes 20 clients to 500 signals each,
// runs for 2 seconds, then verifies delivery counts and zero subscription leaks.
func TestStress_ManySignalsManyClients(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 500
nClients = 20
duration = 2 * time.Second
)
goroutinesBefore := runtime.NumGoroutine()
brk, cancel := newBrokerN(t, nSignals)
defer cancel()
// Each client has its own channel and subscribes to all nSignals signals.
type clientState struct {
ch chan broker.Update
unsub []func()
}
clients := make([]clientState, nClients)
for i := range nClients {
ch := make(chan broker.Update, 2048)
unsubs := make([]func(), nSignals)
for j := range nSignals {
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", j)}
unsub, err := brk.Subscribe(ref, ch)
if err != nil {
t.Fatalf("client %d subscribe pv_%d: %v", i, j, err)
}
unsubs[j] = unsub
}
clients[i] = clientState{ch: ch, unsub: unsubs}
}
if n := brk.ActiveSubscriptions(); n != nSignals {
t.Errorf("expected %d active upstream subscriptions, got %d", nSignals, n)
}
// Drain updates concurrently for the test duration.
var totalReceived atomic.Int64
stop := make(chan struct{})
var wg sync.WaitGroup
for i := range nClients {
wg.Add(1)
ch := clients[i].ch
go func() {
defer wg.Done()
var n int64
for {
select {
case <-ch:
n++
case <-stop:
totalReceived.Add(n)
return
}
}
}()
}
time.Sleep(duration)
close(stop)
wg.Wait()
total := totalReceived.Load()
// At 10 Hz for duration seconds: nSignals * nClients * duration.Seconds() * 10
minExpected := int64(nSignals) * int64(nClients) * int64(duration.Seconds()) * 5 // conservative: 50%
t.Logf("received %d updates (%d clients × %d signals × %.0fs @ 10Hz, min=%d)",
total, nClients, nSignals, duration.Seconds(), minExpected)
if total < minExpected {
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
}
// Unsubscribe all clients.
for _, c := range clients {
for _, unsub := range c.unsub {
unsub()
}
}
// Give the broker time to tear down upstream subscriptions.
time.Sleep(200 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak: %d active subscriptions remain after all clients unsubscribed", n)
}
cancel()
time.Sleep(300 * time.Millisecond)
goroutinesAfter := runtime.NumGoroutine()
leaked := goroutinesAfter - goroutinesBefore
t.Logf("goroutines: before=%d after=%d delta=%d", goroutinesBefore, goroutinesAfter, leaked)
if leaked > 20 {
t.Errorf("goroutine leak: started with %d, ended with %d (%d extra)", goroutinesBefore, goroutinesAfter, leaked)
}
}
// TestStress_RapidSubscribeUnsubscribe hammers subscribe/unsubscribe on a small
// signal set from many goroutines concurrently to expose races and deadlocks.
func TestStress_RapidSubscribeUnsubscribe(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nSignals = 20
nGoroutines = 50
duration = 2 * time.Second
)
brk, cancel := newBrokerN(t, nSignals)
defer cancel()
var wg sync.WaitGroup
stop := make(chan struct{})
var ops atomic.Int64
for range nGoroutines {
wg.Add(1)
go func() {
defer wg.Done()
ch := make(chan broker.Update, 64)
for {
select {
case <-stop:
return
default:
}
// Pick a signal and rapidly subscribe/unsubscribe.
idx := int(ops.Load()) % nSignals
ref := broker.SignalRef{DS: "stub", Name: fmt.Sprintf("pv_%d", idx)}
unsub, err := brk.Subscribe(ref, ch)
if err != nil {
t.Errorf("subscribe pv_%d: %v", idx, err)
return
}
ops.Add(1)
// Drain a bit before unsubscribing.
select {
case <-ch:
case <-time.After(50 * time.Millisecond):
}
unsub()
}
}()
}
time.Sleep(duration)
close(stop)
wg.Wait()
t.Logf("completed %d subscribe/unsubscribe cycles in %s", ops.Load(), duration)
time.Sleep(200 * time.Millisecond)
if n := brk.ActiveSubscriptions(); n != 0 {
t.Errorf("subscription leak after rapid churn: %d remain", n)
}
}
// TestStress_SharedSignalManyClients verifies that a single high-rate signal
// fans out correctly to many concurrent clients with no drops for fast consumers.
func TestStress_SharedSignalManyClients(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
const (
nClients = 50
duration = 2 * time.Second
)
brk, cancel := newBroker(t) // uses stub.New() which has counter_fast at 1 ms
defer cancel()
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
channels := make([]chan broker.Update, nClients)
unsubs := make([]func(), nClients)
for i := range nClients {
ch := make(chan broker.Update, 4096)
channels[i] = ch
unsub, err := brk.Subscribe(ref, ch)
if err != nil {
t.Fatalf("subscribe client %d: %v", i, err)
}
unsubs[i] = unsub
}
defer func() {
for _, u := range unsubs {
u()
}
}()
if n := brk.ActiveSubscriptions(); n != 1 {
t.Errorf("expected exactly 1 upstream subscription for shared signal, got %d", n)
}
var totalReceived atomic.Int64
stop := make(chan struct{})
var wg sync.WaitGroup
for i := range nClients {
wg.Add(1)
ch := channels[i]
go func() {
defer wg.Done()
var n int64
for {
select {
case <-ch:
n++
case <-stop:
totalReceived.Add(n)
return
}
}
}()
}
time.Sleep(duration)
close(stop)
wg.Wait()
total := totalReceived.Load()
// counter_fast ticks at 1ms → ~1000 Hz → 2000 updates × 50 clients = 100000 expected minimum
minExpected := int64(nClients) * int64(duration.Seconds()) * 500 // 50% delivery ok due to buffering
t.Logf("shared signal: received %d updates across %d clients (min=%d)", total, nClients, minExpected)
if total < minExpected {
t.Errorf("too few updates: got %d, want >= %d", total, minExpected)
}
}
+62
View File
@@ -122,6 +122,68 @@ func TestUnknownSignal(t *testing.T) {
}
}
func TestMaxUpdateRate(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
const maxHz = 5.0 // 5 Hz → 200 ms interval
ds := stub.New() // counter_fast fires at 1 kHz
if err := ds.Connect(ctx); err != nil {
t.Fatal(err)
}
b := broker.New(ctx, slog.Default(), broker.WithMaxUpdateRate(maxHz))
b.Register(ds)
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
ch := make(chan broker.Update, 512)
unsub, err := b.Subscribe(ref, ch)
if err != nil {
t.Fatal(err)
}
defer unsub()
// Collect updates for 2 seconds.
var count int
deadline := time.After(2 * time.Second)
loop:
for {
select {
case <-ch:
count++
case <-deadline:
break loop
}
}
// At 5 Hz for 2 s we expect ~10 updates (±3 for timer jitter + coalescing).
t.Logf("MaxUpdateRate: received %d updates at %.0f Hz limit over 2s (expect ~10)", count, maxHz)
if count < 5 || count > 20 {
t.Errorf("expected ~10 updates at 5 Hz, got %d", count)
}
}
func TestMaxUpdateRate_UnlimitedByDefault(t *testing.T) {
b, cancel := newBroker(t) // no WithMaxUpdateRate → unlimited
defer cancel()
ref := broker.SignalRef{DS: "stub", Name: "counter_fast"}
ch := make(chan broker.Update, 4096)
unsub, err := b.Subscribe(ref, ch)
if err != nil {
t.Fatal(err)
}
defer unsub()
// counter_fast fires at 1 kHz; over 500 ms we should see ≥ 400 updates.
time.Sleep(500 * time.Millisecond)
got := len(ch)
t.Logf("Unlimited: received %d updates in 500ms from 1kHz signal", got)
if got < 400 {
t.Errorf("expected >= 400 updates with no rate limit, got %d", got)
}
}
func TestMultipleSignals(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
+48
View File
@@ -0,0 +1,48 @@
package broker_test
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
)
// TestDataSourcesAndSource covers the registry accessors.
func TestDataSourcesAndSource(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
all := b.DataSources()
if len(all) != 1 || all[0].Name() != "stub" {
t.Fatalf("DataSources = %v, want one 'stub'", all)
}
if ds, ok := b.Source("stub"); !ok || ds.Name() != "stub" {
t.Errorf("Source(stub) = %v,%v want stub,true", ds, ok)
}
if _, ok := b.Source("nope"); ok {
t.Error("Source(nope): want ok=false")
}
}
// TestReadNow covers the one-shot read happy path plus the unknown-DS and
// context-timeout error branches.
func TestReadNow(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ctx, c := context.WithTimeout(context.Background(), time.Second)
defer c()
v, err := b.ReadNow(ctx, broker.SignalRef{DS: "stub", Name: "sine_1hz"})
if err != nil {
t.Fatalf("ReadNow: %v", err)
}
if v.Timestamp.IsZero() {
t.Error("ReadNow returned a zero-timestamp value")
}
// Unknown data source.
if _, err := b.ReadNow(ctx, broker.SignalRef{DS: "ghost", Name: "x"}); err == nil {
t.Error("ReadNow(unknown ds): want error")
}
}
+262 -6
View File
@@ -3,25 +3,202 @@ package config
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/BurntSushi/toml"
"github.com/uopi/uopi/internal/datasource/modbus"
"github.com/uopi/uopi/internal/datasource/scpi"
)
type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
UI UIConfig `toml:"ui"`
// Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"`
}
// UIConfig carries client-side presentation defaults sent to the browser at
// startup (via /api/v1/me).
type UIConfig struct {
// DefaultZoom is the base UI scale multiplier applied when a browser has no
// per-machine zoom override saved. Useful to enlarge the UI by default on
// HiDPI screens whose OS scaling is left at 100% (where the browser reports
// devicePixelRatio=1 and the UI would otherwise render small). The in-app
// A+/A control still overrides it locally. 0 or unset means 1.0 (no scaling).
DefaultZoom float64 `toml:"default_zoom"`
}
// AuditConfig controls the audit trail. When enabled, every user and automated
// action that could affect the controlled system (signal writes, control-logic
// changes) is recorded to a SQLite database for later review by audit staff. Who
// may *view* the log is governed by the auditor role (see GroupDef).
type AuditConfig struct {
Enabled bool `toml:"enabled"`
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
}
// GroupDef defines one access group as [[groups]] in the config file. Each group
// has an optional parent (for nesting) and lists its members by role. Roles form
// a cumulative ladder: viewer < operator < logiceditor < auditor < admin. A user
// listed in several role buckets keeps the highest. The built-in "public" group
// (every user is an implicit viewer member) may be configured by name to raise
// specific users globally.
type GroupDef struct {
Name string `toml:"name"`
Parent string `toml:"parent"`
Viewers []string `toml:"viewers"`
Operators []string `toml:"operators"`
LogicEditors []string `toml:"logiceditors"`
Auditors []string `toml:"auditors"`
Admins []string `toml:"admins"`
}
type ServerConfig struct {
Listen string `toml:"listen"`
StorageDir string `toml:"storage_dir"`
Listen string `toml:"listen"`
StorageDir string `toml:"storage_dir"`
MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
// TrustedUserHeader is the HTTP header from which the end-user identity is
// read on each WebSocket connection (set by a trusted reverse proxy doing
// authentication). The identity is used for EPICS writes so each session
// can act as a different user. Empty disables it (writes use the server
// identity). MUST only be enabled when a proxy strips any client-supplied
// value, otherwise the header can be spoofed.
TrustedUserHeader string `toml:"trusted_user_header"`
// DefaultUser is the identity used when the trusted user header is absent or
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
//
// Access levels (write/readonly), logic-edit, audit-view and admin rights are
// all granted via group roles (see GroupDef / [[groups]]). A config with no
// group roles at all is treated as fully open (everyone is admin), so an
// unconfigured deployment behaves like trusted LAN. Once the admin pane writes
// {storage_dir}/access.json, that file — not this config — is the source of
// truth for access.
DefaultUser string `toml:"default_user"`
// Kerberos enables native SPNEGO authentication (see KerberosConfig).
Kerberos KerberosConfig `toml:"kerberos"`
// BasicAuth enables built-in HTTP Basic authentication validated against PAM
// (see BasicAuthConfig). Mutually exclusive with Kerberos.
BasicAuth BasicAuthConfig `toml:"basic_auth"`
// LDAP enables built-in HTTP Basic authentication validated against an LDAP
// directory (see LDAPConfig). Pure-Go alternative to BasicAuth/PAM that keeps
// the static binary. Mutually exclusive with Kerberos and BasicAuth.
LDAP LDAPConfig `toml:"ldap"`
// TLS enables built-in HTTPS (see TLSConfig). Strongly recommended whenever
// BasicAuth is enabled, since Basic credentials are sent on every request.
TLS TLSConfig `toml:"tls"`
}
// KerberosConfig enables native SPNEGO/Kerberos ("Negotiate") authentication so
// uopi identifies users directly from their Kerberos ticket, without depending on
// a separate auth proxy to set TrustedUserHeader. This is the recommended setup
// for browsers like Firefox that do not silently fall back to a proxy default:
// uopi answers API requests with a 401 WWW-Authenticate: Negotiate challenge, the
// browser performs the SPNEGO handshake, and uopi resolves the user from the
// validated ticket (short principal name, realm stripped). That username feeds
// the same access pipeline as TrustedUserHeader.
//
// Browsers must be told to perform SPNEGO for this server's origin (Firefox:
// network.negotiate-auth.trusted-uris; Chrome/Edge: AuthServerAllowlist policy or
// OS integrated auth). When enabled, any inbound TrustedUserHeader value is
// ignored in favour of the Kerberos identity to prevent spoofing.
type KerberosConfig struct {
Enabled bool `toml:"enabled"`
// Keytab is the path to the service keytab holding the HTTP service
// principal's long-term key (e.g. HTTP/host.example.com@REALM). Required when
// Enabled.
Keytab string `toml:"keytab"`
// ServicePrincipal optionally selects which principal in the keytab to accept
// tickets for (e.g. "HTTP/host.example.com"). Empty accepts the keytab's
// entries by default.
ServicePrincipal string `toml:"service_principal"`
}
// BasicAuthConfig enables uopi's built-in HTTP Basic authentication: uopi
// answers API requests with 401 WWW-Authenticate: Basic, the browser prompts for
// a username/password, and uopi validates them through the host PAM stack
// (/etc/pam.d/<PAMService>). On hosts that are SSSD/LDAP clients this reuses the
// users' normal login credentials with no directory configuration in uopi. The
// validated username feeds the same access pipeline as TrustedUserHeader.
//
// PAM support requires a cgo build with the `pam` tag (make backend-pam); the
// default fully-static binary cannot validate and will refuse to start with
// BasicAuth enabled. Because Basic credentials travel on every request, enable
// TLS (see TLSConfig) unless uopi sits on a fully isolated network.
type BasicAuthConfig struct {
Enabled bool `toml:"enabled"`
// PAMService is the PAM service name under /etc/pam.d/ to authenticate
// against. Empty defaults to "uopi".
PAMService string `toml:"pam_service"`
}
// LDAPConfig enables uopi's built-in HTTP Basic authentication validated against
// an LDAP directory via the "search then bind" pattern — the same flow an
// SSSD/LDAP client uses. Because it speaks LDAP over the wire with no cgo, it
// works in the default fully-static binary (unlike the PAM backend), while still
// authenticating users against the same directory the host logs in with. The
// validated username feeds the same access pipeline as TrustedUserHeader. Enable
// TLS (ldaps:// or StartTLS) so passwords are not sent in clear text.
//
// Defaults mirror SSSD: empty UserAttr → "uid", empty UserObjectClass →
// "posixAccount", empty BindDN → anonymous search. Mutually exclusive with
// Kerberos and BasicAuth.
type LDAPConfig struct {
Enabled bool `toml:"enabled"`
// URIs are the directory endpoints (SSSD ldap_uri), tried in order, e.g.
// "ldaps://ldap.example.com". Required when Enabled.
URIs []string `toml:"uri"`
// SearchBase is the subtree user entries live under (SSSD ldap_search_base).
// Required when Enabled.
SearchBase string `toml:"search_base"`
// UserAttr is the attribute matched against the login name. Empty → "uid".
UserAttr string `toml:"user_attr"`
// UserObjectClass restricts the search. Empty → "posixAccount".
UserObjectClass string `toml:"user_object_class"`
// BindDN / BindPassword optionally authenticate the search (service account).
// Empty BindDN performs an anonymous search.
BindDN string `toml:"bind_dn"`
BindPassword string `toml:"bind_password"`
// StartTLS upgrades an ldap:// connection to TLS before binding. Ignored for
// ldaps://.
StartTLS bool `toml:"start_tls"`
// CACert is an optional PEM CA bundle to trust (private CA).
CACert string `toml:"ca_cert"`
// InsecureSkipVerify disables TLS certificate verification. Testing only.
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
}
// TLSConfig enables built-in HTTPS so uopi can terminate TLS itself (e.g. for
// Basic auth) without a reverse proxy. When Enabled, Cert and Key are required.
type TLSConfig struct {
Enabled bool `toml:"enabled"`
// Cert and Key are paths to the PEM certificate and private key.
Cert string `toml:"cert"`
Key string `toml:"key"`
// RedirectFrom, when set (e.g. ":8080"), starts an additional plain-HTTP
// listener on that address that 301-redirects every request to the HTTPS
// service. Without it, a browser that connects with http:// gets the opaque
// "client sent an HTTP request to an HTTPS server" error instead of being
// upgraded. Empty disables the redirector.
RedirectFrom string `toml:"redirect_from"`
}
type DatasourceConfig struct {
Stub StubConfig `toml:"stub"`
EPICS EPICSConfig `toml:"epics"`
PVA PVAConfig `toml:"pva"`
Synthetic SyntheticConfig `toml:"synthetic"`
Modbus modbus.Config `toml:"modbus"`
SCPI scpi.Config `toml:"scpi"`
}
type StubConfig struct {
@@ -29,10 +206,18 @@ type StubConfig struct {
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
AutoSyncFilter string `toml:"auto_sync_filter"`
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
PVNames []string `toml:"pv_names"`
}
type PVAConfig struct {
Enabled bool `toml:"enabled"`
AddrList []string `toml:"addr_list"`
}
type SyntheticConfig struct {
@@ -48,6 +233,7 @@ func Default() Config {
Datasource: DatasourceConfig{
Stub: StubConfig{Enabled: true},
EPICS: EPICSConfig{Enabled: true},
PVA: PVAConfig{Enabled: true},
Synthetic: SyntheticConfig{Enabled: true},
},
}
@@ -76,6 +262,62 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" {
cfg.Server.StorageDir = v
}
if v := env("UOPI_SERVER_MAX_UPDATE_RATE_HZ"); v != "" {
if hz, err := strconv.ParseFloat(v, 64); err == nil {
cfg.Server.MaxUpdateRateHz = hz
}
}
if v := env("UOPI_SERVER_TRUSTED_USER_HEADER"); v != "" {
cfg.Server.TrustedUserHeader = v
}
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
cfg.Server.DefaultUser = v
}
if v := env("UOPI_SERVER_KERBEROS_ENABLED"); v != "" {
cfg.Server.Kerberos.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_KERBEROS_KEYTAB"); v != "" {
cfg.Server.Kerberos.Keytab = v
}
if v := env("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL"); v != "" {
cfg.Server.Kerberos.ServicePrincipal = v
}
if v := env("UOPI_SERVER_BASIC_AUTH_ENABLED"); v != "" {
cfg.Server.BasicAuth.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE"); v != "" {
cfg.Server.BasicAuth.PAMService = v
}
if v := env("UOPI_SERVER_TLS_ENABLED"); v != "" {
cfg.Server.TLS.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_TLS_CERT"); v != "" {
cfg.Server.TLS.Cert = v
}
if v := env("UOPI_SERVER_TLS_KEY"); v != "" {
cfg.Server.TLS.Key = v
}
if v := env("UOPI_SERVER_LDAP_ENABLED"); v != "" {
cfg.Server.LDAP.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_LDAP_URI"); v != "" {
cfg.Server.LDAP.URIs = strings.Fields(v)
}
if v := env("UOPI_SERVER_LDAP_SEARCH_BASE"); v != "" {
cfg.Server.LDAP.SearchBase = v
}
if v := env("UOPI_SERVER_LDAP_BIND_DN"); v != "" {
cfg.Server.LDAP.BindDN = v
}
if v := env("UOPI_SERVER_LDAP_BIND_PASSWORD"); v != "" {
cfg.Server.LDAP.BindPassword = v
}
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
cfg.Audit.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
cfg.Audit.DBPath = v
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v
}
@@ -85,6 +327,20 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
cfg.Datasource.EPICS.ChannelFinderURL = v
}
if v := env("UOPI_EPICS_AUTO_SYNC_FILTER"); v != "" {
cfg.Datasource.EPICS.AutoSyncFilter = v
}
if v := env("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER"); v != "" {
cfg.Datasource.EPICS.AutoSyncFromArchiver = (v == "true" || v == "YES")
}
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
cfg.Datasource.PVA.AddrList = strings.Fields(v)
}
if v := env("UOPI_UI_DEFAULT_ZOOM"); v != "" {
if z, err := strconv.ParseFloat(v, 64); err == nil {
cfg.UI.DefaultZoom = z
}
}
}
func env(key string) string {
+206
View File
@@ -0,0 +1,206 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"github.com/uopi/uopi/internal/config"
)
func TestDefault(t *testing.T) {
cfg := config.Default()
if cfg.Server.Listen == "" {
t.Error("Default Listen must not be empty")
}
if cfg.Server.StorageDir == "" {
t.Error("Default StorageDir must not be empty")
}
if !cfg.Datasource.Stub.Enabled {
t.Error("Stub should be enabled by default")
}
if !cfg.Datasource.EPICS.Enabled {
t.Error("EPICS should be enabled by default")
}
if !cfg.Datasource.Synthetic.Enabled {
t.Error("Synthetic should be enabled by default")
}
}
func TestLoadEmptyPath(t *testing.T) {
// Empty path should return defaults without error.
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load with empty path: %v", err)
}
if cfg.Server.Listen != config.Default().Server.Listen {
t.Errorf("Listen = %q, want default %q", cfg.Server.Listen, config.Default().Server.Listen)
}
}
func TestLoadTOML(t *testing.T) {
toml := `
[server]
listen = ":9090"
storage_dir = "/tmp/uopi-test"
[datasource.stub]
enabled = false
[datasource.epics]
enabled = false
ca_addr_list = "192.168.1.1"
`
f := filepath.Join(t.TempDir(), "uopi.toml")
if err := os.WriteFile(f, []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(f)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != ":9090" {
t.Errorf("Listen = %q, want :9090", cfg.Server.Listen)
}
if cfg.Server.StorageDir != "/tmp/uopi-test" {
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
}
if cfg.Datasource.Stub.Enabled {
t.Error("Stub.Enabled should be false")
}
if cfg.Datasource.EPICS.Enabled {
t.Error("EPICS.Enabled should be false")
}
if cfg.Datasource.EPICS.CAAddrList != "192.168.1.1" {
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
}
}
func TestLoadBadPath(t *testing.T) {
_, err := config.Load("/no/such/file.toml")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestEnvOverrides(t *testing.T) {
t.Setenv("UOPI_SERVER_LISTEN", ":7777")
t.Setenv("UOPI_SERVER_STORAGE_DIR", "/env/storage")
t.Setenv("UOPI_EPICS_CA_ADDR_LIST", "10.0.0.1 10.0.0.2")
t.Setenv("UOPI_EPICS_ARCHIVE_URL", "http://archive/")
t.Setenv("UOPI_EPICS_CHANNEL_FINDER_URL", "http://cf/")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != ":7777" {
t.Errorf("Listen = %q, want :7777", cfg.Server.Listen)
}
if cfg.Server.StorageDir != "/env/storage" {
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
}
if cfg.Datasource.EPICS.CAAddrList != "10.0.0.1 10.0.0.2" {
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
}
if cfg.Datasource.EPICS.ArchiveURL != "http://archive/" {
t.Errorf("ArchiveURL = %q", cfg.Datasource.EPICS.ArchiveURL)
}
if cfg.Datasource.EPICS.ChannelFinderURL != "http://cf/" {
t.Errorf("ChannelFinderURL = %q", cfg.Datasource.EPICS.ChannelFinderURL)
}
}
func TestEnvOverridesAuthTLSAndMisc(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "25.5")
t.Setenv("UOPI_SERVER_TRUSTED_USER_HEADER", "X-Forwarded-User")
t.Setenv("UOPI_SERVER_DEFAULT_USER", "svc")
t.Setenv("UOPI_SERVER_KERBEROS_ENABLED", "true")
t.Setenv("UOPI_SERVER_KERBEROS_KEYTAB", "/etc/krb.keytab")
t.Setenv("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL", "HTTP/host")
t.Setenv("UOPI_SERVER_BASIC_AUTH_ENABLED", "YES")
t.Setenv("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE", "login")
t.Setenv("UOPI_SERVER_TLS_ENABLED", "true")
t.Setenv("UOPI_SERVER_TLS_CERT", "/c.pem")
t.Setenv("UOPI_SERVER_TLS_KEY", "/k.pem")
t.Setenv("UOPI_SERVER_LDAP_ENABLED", "true")
t.Setenv("UOPI_SERVER_LDAP_URI", "ldaps://a ldaps://b")
t.Setenv("UOPI_SERVER_LDAP_SEARCH_BASE", "dc=x,dc=y")
t.Setenv("UOPI_SERVER_LDAP_BIND_DN", "cn=svc")
t.Setenv("UOPI_SERVER_LDAP_BIND_PASSWORD", "secret")
t.Setenv("UOPI_AUDIT_ENABLED", "true")
t.Setenv("UOPI_AUDIT_DB_PATH", "/audit.db")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FILTER", "area=SR")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER", "true")
t.Setenv("EPICS_PVA_ADDR_LIST", "10.1.1.1 10.1.1.2")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "1.5")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.MaxUpdateRateHz != 25.5 {
t.Errorf("MaxUpdateRateHz = %v, want 25.5", cfg.Server.MaxUpdateRateHz)
}
if cfg.Server.TrustedUserHeader != "X-Forwarded-User" {
t.Errorf("TrustedUserHeader = %q", cfg.Server.TrustedUserHeader)
}
if cfg.Server.DefaultUser != "svc" {
t.Errorf("DefaultUser = %q", cfg.Server.DefaultUser)
}
if !cfg.Server.Kerberos.Enabled || cfg.Server.Kerberos.Keytab != "/etc/krb.keytab" || cfg.Server.Kerberos.ServicePrincipal != "HTTP/host" {
t.Errorf("Kerberos = %+v", cfg.Server.Kerberos)
}
if !cfg.Server.BasicAuth.Enabled || cfg.Server.BasicAuth.PAMService != "login" {
t.Errorf("BasicAuth = %+v", cfg.Server.BasicAuth)
}
if !cfg.Server.TLS.Enabled || cfg.Server.TLS.Cert != "/c.pem" || cfg.Server.TLS.Key != "/k.pem" {
t.Errorf("TLS = %+v", cfg.Server.TLS)
}
if !cfg.Server.LDAP.Enabled || len(cfg.Server.LDAP.URIs) != 2 ||
cfg.Server.LDAP.SearchBase != "dc=x,dc=y" || cfg.Server.LDAP.BindDN != "cn=svc" ||
cfg.Server.LDAP.BindPassword != "secret" {
t.Errorf("LDAP = %+v", cfg.Server.LDAP)
}
if !cfg.Audit.Enabled || cfg.Audit.DBPath != "/audit.db" {
t.Errorf("Audit = %+v", cfg.Audit)
}
if cfg.Datasource.EPICS.AutoSyncFilter != "area=SR" || !cfg.Datasource.EPICS.AutoSyncFromArchiver {
t.Errorf("EPICS sync = %+v", cfg.Datasource.EPICS)
}
if len(cfg.Datasource.PVA.AddrList) != 2 {
t.Errorf("PVA AddrList = %+v", cfg.Datasource.PVA.AddrList)
}
if cfg.UI.DefaultZoom != 1.5 {
t.Errorf("UI.DefaultZoom = %v, want 1.5", cfg.UI.DefaultZoom)
}
}
func TestEnvInvalidNumbersIgnored(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "not-a-number")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "xyz")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
// Invalid floats are ignored, leaving the defaults intact.
if cfg.Server.MaxUpdateRateHz != config.Default().Server.MaxUpdateRateHz {
t.Errorf("invalid MaxUpdateRateHz should be ignored, got %v", cfg.Server.MaxUpdateRateHz)
}
if cfg.UI.DefaultZoom != config.Default().UI.DefaultZoom {
t.Errorf("invalid DefaultZoom should be ignored, got %v", cfg.UI.DefaultZoom)
}
}
func TestEnvTrimsWhitespace(t *testing.T) {
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Listen != ":8888" {
t.Errorf("Listen = %q, expected whitespace trimmed", cfg.Server.Listen)
}
}
+56
View File
@@ -0,0 +1,56 @@
package confmgr
// WriteFunc writes a resolved value to a target signal. The API layer supplies
// a closure backed by the broker/datasource so confmgr stays decoupled from the
// transport.
type WriteFunc func(ds, signal string, value any) error
// ApplyEntry records the outcome of writing one parameter.
type ApplyEntry struct {
Key string `json:"key"`
DS string `json:"ds"`
Signal string `json:"signal"`
Value any `json:"value,omitempty"`
OK bool `json:"ok"`
Skipped bool `json:"skipped,omitempty"`
Error string `json:"error,omitempty"`
}
// ApplyResult summarises an apply run.
type ApplyResult struct {
InstanceID string `json:"instanceId"`
SetID string `json:"setId"`
Entries []ApplyEntry `json:"entries"`
Applied int `json:"applied"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
// Apply writes every resolvable parameter value of an instance to its target
// signal via write. Optional parameters with no value and no default are
// skipped. Individual write failures are recorded per-entry rather than
// aborting the whole apply, so a partial apply is reported faithfully.
func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult {
res := ApplyResult{InstanceID: inst.ID, SetID: set.ID, Entries: make([]ApplyEntry, 0, len(set.Parameters))}
for _, p := range set.Parameters {
e := ApplyEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
v, ok := inst.Resolve(p)
if !ok {
e.Skipped = true
res.Skipped++
res.Entries = append(res.Entries, e)
continue
}
v = p.normalize(v)
e.Value = v
if err := write(p.DS, p.Signal, v); err != nil {
e.Error = err.Error()
res.Failed++
} else {
e.OK = true
res.Applied++
}
res.Entries = append(res.Entries, e)
}
return res
}
+108
View File
@@ -0,0 +1,108 @@
package confmgr
import (
"errors"
"testing"
)
func TestApplyWritesValues(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0},
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
{Key: "opt", DS: "epics", Signal: "PSU:OPT", Type: TypeFloat}, // no value, no default → skipped
},
}
inst := ConfigInstance{ID: "i1", SetID: "set1", Values: map[string]any{"v": 24.0, "en": true}}
type write struct {
ds, sig string
val any
}
var writes []write
res := Apply(set, inst, func(ds, signal string, value any) error {
writes = append(writes, write{ds, signal, value})
return nil
})
if res.Applied != 2 || res.Skipped != 1 || res.Failed != 0 {
t.Fatalf("summary: applied=%d skipped=%d failed=%d", res.Applied, res.Skipped, res.Failed)
}
if len(writes) != 2 {
t.Fatalf("want 2 writes, got %d", len(writes))
}
if writes[0].sig != "PSU:V" || writes[0].val != 24.0 {
t.Errorf("first write: %+v", writes[0])
}
}
func TestApplyUsesDefaultWhenNoValue(t *testing.T) {
set := ConfigSet{
ID: "set1", Name: "s",
Parameters: []Parameter{{Key: "v", DS: "d", Signal: "S", Type: TypeFloat, Default: 7.0}},
}
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
var got any
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
if res.Applied != 1 || got != 7.0 {
t.Errorf("want default 7.0 applied, got %v (applied=%d)", got, res.Applied)
}
}
func TestApplyArrayNormalizes(t *testing.T) {
set := ConfigSet{ID: "s", Name: "s", Parameters: []Parameter{
{Key: "wf", DS: "d", Signal: "WF", Type: TypeFloatArray},
}}
// JSON decoding yields []any of float64 — apply must hand the datasource a []float64.
inst := ConfigInstance{ID: "i", SetID: "s", Values: map[string]any{"wf": []any{1.0, 2.0, 3.0}}}
var got any
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
if res.Applied != 1 {
t.Fatalf("applied=%d", res.Applied)
}
arr, ok := got.([]float64)
if !ok || len(arr) != 3 || arr[0] != 1 || arr[2] != 3 {
t.Fatalf("want []float64{1,2,3}, got %T %v", got, got)
}
}
func TestArrayValidation(t *testing.T) {
lo := 0.0
p := Parameter{Key: "wf", DS: "d", Signal: "S", Type: TypeFloatArray, Min: &lo}
if err := p.checkValue([]any{1.0, 2.0}); err != nil {
t.Fatalf("valid array rejected: %v", err)
}
if err := p.checkValue([]any{1.0, -5.0}); err == nil {
t.Fatal("expected out-of-range element to be rejected")
}
if err := p.checkValue("notarray"); err == nil {
t.Fatal("expected non-array value to be rejected")
}
}
func TestApplyRecordsFailures(t *testing.T) {
set := ConfigSet{
ID: "set1", Name: "s",
Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat, Default: 2.0},
},
}
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
res := Apply(set, inst, func(_, signal string, _ any) error {
if signal == "B" {
return errors.New("write rejected")
}
return nil
})
if res.Applied != 1 || res.Failed != 1 {
t.Fatalf("want applied=1 failed=1, got applied=%d failed=%d", res.Applied, res.Failed)
}
for _, e := range res.Entries {
if e.Key == "b" && (e.OK || e.Error == "") {
t.Errorf("entry b should record failure: %+v", e)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package confmgr
import (
"reflect"
"testing"
)
// TestCoerceSnapshot covers the alternate and error branches of coerceSnapshot
// that the happy-path Snapshot tests do not reach.
func TestCoerceSnapshot(t *testing.T) {
enum := Parameter{Type: TypeEnum, EnumValues: []string{"off", "low", "high"}}
cases := []struct {
name string
p Parameter
raw any
want any
wantErr bool
}{
{"float err", Parameter{Type: TypeFloat}, struct{}{}, nil, true},
{"int round", Parameter{Type: TypeInt}, 2.6, int64(3), false},
{"int err", Parameter{Type: TypeInt}, struct{}{}, nil, true},
{"bool from string", Parameter{Type: TypeBool}, "true", true, false},
{"bool bad string", Parameter{Type: TypeBool}, "maybe", nil, true},
{"bool from numeric", Parameter{Type: TypeBool}, 0.0, false, false},
{"bool unconvertible", Parameter{Type: TypeBool}, struct{}{}, nil, true},
{"string passthrough", Parameter{Type: TypeString}, "x", "x", false},
{"string from numeric", Parameter{Type: TypeString}, 42.0, "42", false},
{"enum string in range", enum, "low", "low", false},
{"enum string out of range", enum, "nope", nil, true},
{"enum index", enum, int64(2), "high", false},
{"enum index out of range", enum, 9.0, nil, true},
{"enum non-numeric", enum, struct{}{}, nil, true},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, []float64{1, 2}, false},
{"array err", Parameter{Type: TypeFloatArray}, 1.0, nil, true},
{"default passthrough", Parameter{Type: ParamType("weird")}, "asis", "asis", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.p.coerceSnapshot(tc.raw)
if tc.wantErr {
if err == nil {
t.Errorf("coerceSnapshot(%v): want error", tc.raw)
}
return
}
if err != nil {
t.Fatalf("coerceSnapshot(%v): %v", tc.raw, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("coerceSnapshot(%v) = %v (%T), want %v (%T)", tc.raw, got, got, tc.want, tc.want)
}
})
}
}
// TestSnapshotCoerceFailureRecorded ensures a coercion failure (vs read failure)
// is counted in res.Failed and kept out of Values.
func TestSnapshotCoerceFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "s",
Name: "s",
Parameters: []Parameter{
{Key: "n", DS: "d", Signal: "N", Type: TypeInt},
},
}
res := Snapshot(set, func(_, _ string) (any, error) {
return "not-a-number", nil // reads fine, fails coercion
})
if res.Captured != 0 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["n"]; ok {
t.Error("coercion-failed parameter must not appear in values")
}
}
+207
View File
@@ -0,0 +1,207 @@
package confmgr
import (
"fmt"
"reflect"
"strings"
"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
cueerrors "cuelang.org/go/cue/errors"
)
// ConfigRule is a CUE-based validation/transformation rule bound to a config
// set. Its Source is CUE describing constraints and/or derivations over the
// instance's parameter values. Regular CUE fields whose key matches a set
// parameter are unified with the instance value; constraints that fail produce
// violations, and concrete fields that differ from the instance value are
// reported (and persisted) as transformations. Hidden fields (_x) and
// definitions (#X) are available for helpers and are excluded from both
// concreteness checks and transformation output. Rules are versioned git-style,
// exactly like sets and instances.
type ConfigRule struct {
ID string `json:"id"`
Name string `json:"name"`
SetID string `json:"setId"`
Description string `json:"description,omitempty"`
// Enabled gates whether the rule runs when instances of the bound set are
// saved/applied. A nil pointer (legacy rules created before the flag) is
// treated as enabled, so existing rules keep their behaviour.
Enabled *bool `json:"enabled,omitempty"`
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Source string `json:"source"`
}
// IsEnabled reports whether the rule participates in instance evaluation. A nil
// Enabled flag (legacy rules) is treated as enabled.
func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
// RuleViolation is a single constraint failure from evaluating a rule.
type RuleViolation struct {
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
Path string `json:"path,omitempty"` // dotted parameter path, when known
Message string `json:"message"`
}
// RuleResult is the outcome of evaluating one or more rules against a set of
// instance values. OK is true only when there is no compile error and no
// violation. Transformed holds the parameter values the rule(s) derived or
// overrode (keys are parameter keys; values are the new concrete values).
type RuleResult struct {
OK bool `json:"ok"`
Violations []RuleViolation `json:"violations,omitempty"`
Transformed map[string]any `json:"transformed,omitempty"`
CompileError string `json:"compileError,omitempty"`
}
// RuleError wraps a failing RuleResult so it can flow through the store's
// error-returning API while preserving the structured violations.
type RuleError struct {
Result RuleResult
}
func (e *RuleError) Error() string {
if e.Result.CompileError != "" {
return "rule compile error: " + e.Result.CompileError
}
msgs := make([]string, 0, len(e.Result.Violations))
for _, v := range e.Result.Violations {
if v.Path != "" {
msgs = append(msgs, v.Path+": "+v.Message)
} else {
msgs = append(msgs, v.Message)
}
}
if len(msgs) == 0 {
return "rule validation failed"
}
return "rule validation failed: " + strings.Join(msgs, "; ")
}
// Validate compiles the rule's CUE source and checks basic metadata. It does
// not require concrete values — only that the source is syntactically and
// structurally well-formed CUE.
func (r ConfigRule) Validate() error {
if strings.TrimSpace(r.Name) == "" {
return fmt.Errorf("rule name must not be empty")
}
if strings.TrimSpace(r.SetID) == "" {
return fmt.Errorf("rule must reference a config set")
}
ctx := cuecontext.New()
v := ctx.CompileString(r.Source)
if err := v.Err(); err != nil {
return fmt.Errorf("invalid CUE: %s", firstError(err))
}
return nil
}
// EvaluateRule unifies a single CUE source with the given instance values and
// reports violations plus any transformed values. A compile error yields a
// non-OK result with CompileError populated (and no violations), so callers can
// distinguish "the rule is broken" from "the values are invalid".
func EvaluateRule(source string, values map[string]any) RuleResult {
ctx := cuecontext.New()
schema := ctx.CompileString(source)
if err := schema.Err(); err != nil {
return RuleResult{OK: false, CompileError: firstError(err)}
}
data := ctx.Encode(values)
if err := data.Err(); err != nil {
return RuleResult{OK: false, CompileError: "cannot encode values: " + firstError(err)}
}
unified := schema.Unify(data)
res := RuleResult{OK: true}
if err := unified.Validate(cue.Concrete(true), cue.All()); err != nil {
res.OK = false
for _, e := range cueerrors.Errors(err) {
res.Violations = append(res.Violations, RuleViolation{
Path: strings.Join(e.Path(), "."),
Message: cleanMessage(e),
})
}
if len(res.Violations) == 0 {
res.Violations = append(res.Violations, RuleViolation{Message: firstError(err)})
}
return res
}
// Extract transformations: regular concrete fields whose value differs from
// the supplied input. Definitions and hidden fields are skipped by Fields().
iter, err := unified.Fields()
if err != nil {
return res
}
for iter.Next() {
key := iter.Selector().Unquoted()
if key == "" {
key = strings.Trim(iter.Selector().String(), `"`)
}
var v any
if err := iter.Value().Decode(&v); err != nil {
continue
}
if !reflect.DeepEqual(v, values[key]) {
if res.Transformed == nil {
res.Transformed = map[string]any{}
}
res.Transformed[key] = v
}
}
return res
}
// evaluateRules runs several rules against the same values and aggregates the
// outcome. Violations are tagged with the rule ID. Transformations are applied
// cumulatively in order; a later rule sees earlier rules' outputs.
func evaluateRules(rules []ConfigRule, values map[string]any) RuleResult {
agg := RuleResult{OK: true}
cur := make(map[string]any, len(values))
for k, v := range values {
cur[k] = v
}
for _, r := range rules {
one := EvaluateRule(r.Source, cur)
if one.CompileError != "" {
agg.OK = false
agg.Violations = append(agg.Violations, RuleViolation{Rule: r.ID, Message: "compile error: " + one.CompileError})
continue
}
if !one.OK {
agg.OK = false
for _, v := range one.Violations {
v.Rule = r.ID
agg.Violations = append(agg.Violations, v)
}
continue
}
for k, v := range one.Transformed {
if agg.Transformed == nil {
agg.Transformed = map[string]any{}
}
agg.Transformed[k] = v
cur[k] = v
}
}
return agg
}
// firstError renders the first CUE error as a single line.
func firstError(err error) string {
errs := cueerrors.Errors(err)
if len(errs) == 0 {
return err.Error()
}
return cleanMessage(errs[0])
}
// cleanMessage formats a CUE error to a compact single-line string without the
// noisy file:line position prefix that cuecontext synthesises for anonymous
// sources.
func cleanMessage(e cueerrors.Error) string {
format, args := e.Msg()
return fmt.Sprintf(format, args...)
}
+80
View File
@@ -0,0 +1,80 @@
package confmgr
import "testing"
func TestEvaluateRule_Valid(t *testing.T) {
src := `
current_limit: >=0 & <=max
max: 100
`
res := EvaluateRule(src, map[string]any{"current_limit": 50.0})
if !res.OK {
t.Fatalf("expected OK, got violations: %+v compile=%q", res.Violations, res.CompileError)
}
if res.CompileError != "" {
t.Fatalf("unexpected compile error: %s", res.CompileError)
}
}
func TestEvaluateRule_Violation(t *testing.T) {
src := `current_limit: >=0 & <=100`
res := EvaluateRule(src, map[string]any{"current_limit": 150.0})
if res.OK {
t.Fatalf("expected violation, got OK")
}
if len(res.Violations) == 0 {
t.Fatalf("expected at least one violation")
}
}
func TestEvaluateRule_Transform(t *testing.T) {
// power is derived from the supplied current & voltage.
src := `
current: number
voltage: number
power: current * voltage
`
res := EvaluateRule(src, map[string]any{"current": 2.0, "voltage": 3.0})
if !res.OK {
t.Fatalf("expected OK, got %+v", res.Violations)
}
got, ok := res.Transformed["power"]
if !ok {
t.Fatalf("expected transformed power, got %+v", res.Transformed)
}
if f, _ := toFloat(got); f != 6 {
t.Fatalf("expected power=6, got %v", got)
}
}
func TestEvaluateRule_DefaultFillsMissing(t *testing.T) {
src := `mode: *"auto" | "manual"`
res := EvaluateRule(src, map[string]any{})
if !res.OK {
t.Fatalf("expected OK, got %+v", res.Violations)
}
if res.Transformed["mode"] != "auto" {
t.Fatalf("expected mode default auto, got %v", res.Transformed["mode"])
}
}
func TestEvaluateRule_CompileError(t *testing.T) {
res := EvaluateRule(`this is : : not cue`, map[string]any{})
if res.OK || res.CompileError == "" {
t.Fatalf("expected compile error, got %+v", res)
}
}
func TestConfigRule_Validate(t *testing.T) {
r := ConfigRule{Name: "r", SetID: "s", Source: `x: >=0`}
if err := r.Validate(); err != nil {
t.Fatalf("expected valid rule, got %v", err)
}
bad := ConfigRule{Name: "r", SetID: "s", Source: `x: : :`}
if err := bad.Validate(); err == nil {
t.Fatalf("expected compile error for bad source")
}
if err := (ConfigRule{Source: `x: 1`}).Validate(); err == nil {
t.Fatalf("expected error for missing name/setID")
}
}
+140
View File
@@ -0,0 +1,140 @@
package confmgr
import (
"fmt"
"sort"
)
// ChangeStatus classifies a single diff entry.
type ChangeStatus string
const (
StatusAdded ChangeStatus = "added"
StatusRemoved ChangeStatus = "removed"
StatusChanged ChangeStatus = "changed"
StatusUnchanged ChangeStatus = "unchanged"
)
// SetDiffEntry is one parameter-level difference between two config sets.
type SetDiffEntry struct {
Key string `json:"key"`
Status ChangeStatus `json:"status"`
Left *Parameter `json:"left,omitempty"`
Right *Parameter `json:"right,omitempty"`
}
// InstanceDiffEntry is one value-level difference between two config instances.
type InstanceDiffEntry struct {
Key string `json:"key"`
Status ChangeStatus `json:"status"`
Left any `json:"left,omitempty"`
Right any `json:"right,omitempty"`
}
// DiffSets compares two config sets parameter-by-parameter (keyed by Key),
// returning entries sorted by key. Both sides are included so the frontend can
// render either unified or side-by-side.
func DiffSets(left, right ConfigSet) []SetDiffEntry {
keys := map[string]bool{}
li := map[string]Parameter{}
ri := map[string]Parameter{}
for _, p := range left.Parameters {
li[p.Key] = p
keys[p.Key] = true
}
for _, p := range right.Parameters {
ri[p.Key] = p
keys[p.Key] = true
}
out := make([]SetDiffEntry, 0, len(keys))
for k := range keys {
l, lok := li[k]
r, rok := ri[k]
e := SetDiffEntry{Key: k}
switch {
case lok && !rok:
e.Status = StatusRemoved
lp := l
e.Left = &lp
case !lok && rok:
e.Status = StatusAdded
rp := r
e.Right = &rp
default:
lp, rp := l, r
e.Left, e.Right = &lp, &rp
if paramEqual(l, r) {
e.Status = StatusUnchanged
} else {
e.Status = StatusChanged
}
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// DiffInstances compares two config instances value-by-value (keyed by
// parameter key), returning entries sorted by key.
func DiffInstances(left, right ConfigInstance) []InstanceDiffEntry {
keys := map[string]bool{}
for k := range left.Values {
keys[k] = true
}
for k := range right.Values {
keys[k] = true
}
out := make([]InstanceDiffEntry, 0, len(keys))
for k := range keys {
lv, lok := left.Values[k]
rv, rok := right.Values[k]
e := InstanceDiffEntry{Key: k, Left: lv, Right: rv}
switch {
case lok && !rok:
e.Status = StatusRemoved
case !lok && rok:
e.Status = StatusAdded
case valueEqual(lv, rv):
e.Status = StatusUnchanged
default:
e.Status = StatusChanged
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
func paramEqual(a, b Parameter) bool {
if a.Label != b.Label || a.Group != b.Group || a.Subgroup != b.Subgroup ||
a.DS != b.DS || a.Signal != b.Signal || a.Type != b.Type ||
a.Mandatory != b.Mandatory || a.Unit != b.Unit || a.Description != b.Description {
return false
}
if !valueEqual(a.Default, b.Default) || !floatPtrEqual(a.Min, b.Min) || !floatPtrEqual(a.Max, b.Max) {
return false
}
if len(a.EnumValues) != len(b.EnumValues) {
return false
}
for i := range a.EnumValues {
if a.EnumValues[i] != b.EnumValues[i] {
return false
}
}
return true
}
func floatPtrEqual(a, b *float64) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// valueEqual compares two JSON-decoded scalar values for diff purposes by
// rendering them to a canonical string.
func valueEqual(a, b any) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
+55
View File
@@ -0,0 +1,55 @@
package confmgr
import "testing"
func TestDiffSets(t *testing.T) {
left := ConfigSet{Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat},
}}
right := ConfigSet{Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 2.0}, // changed default
{Key: "c", DS: "d", Signal: "C", Type: TypeFloat}, // added
}}
diff := DiffSets(left, right)
got := map[string]ChangeStatus{}
for _, e := range diff {
got[e.Key] = e.Status
}
if got["a"] != StatusChanged {
t.Errorf("a: want changed, got %s", got["a"])
}
if got["b"] != StatusRemoved {
t.Errorf("b: want removed, got %s", got["b"])
}
if got["c"] != StatusAdded {
t.Errorf("c: want added, got %s", got["c"])
}
}
func TestDiffSetsUnchanged(t *testing.T) {
p := Parameter{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}
diff := DiffSets(ConfigSet{Parameters: []Parameter{p}}, ConfigSet{Parameters: []Parameter{p}})
if len(diff) != 1 || diff[0].Status != StatusUnchanged {
t.Errorf("want single unchanged entry, got %+v", diff)
}
}
func TestDiffInstances(t *testing.T) {
left := ConfigInstance{Values: map[string]any{"a": 1.0, "b": "x"}}
right := ConfigInstance{Values: map[string]any{"a": 2.0, "c": true}}
diff := DiffInstances(left, right)
got := map[string]ChangeStatus{}
for _, e := range diff {
got[e.Key] = e.Status
}
if got["a"] != StatusChanged {
t.Errorf("a: want changed, got %s", got["a"])
}
if got["b"] != StatusRemoved {
t.Errorf("b: want removed, got %s", got["b"])
}
if got["c"] != StatusAdded {
t.Errorf("c: want added, got %s", got["c"])
}
}
+277
View File
@@ -0,0 +1,277 @@
// Package confmgr implements the configuration manager: versioned, git-style
// configuration Sets (schemas of parameters bound to target signals) and
// configuration Instances (concrete values for a set), persisted as versioned
// JSON files. Applying an instance writes each value to its target signal.
package confmgr
import (
"fmt"
"math"
"slices"
"strconv"
"strings"
)
// ParamType enumerates the value kinds a parameter may hold.
type ParamType string
const (
TypeFloat ParamType = "float64"
TypeInt ParamType = "int64"
TypeBool ParamType = "bool"
TypeString ParamType = "string"
TypeEnum ParamType = "enum"
TypeFloatArray ParamType = "float64[]" // waveform; value is a list of numbers
)
func (t ParamType) valid() bool {
switch t {
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray:
return true
}
return false
}
// Parameter is one entry in a configuration set's schema. It names a target
// signal (DS + Signal), a value type, an optional default, and validation
// metadata. Parameters may be grouped for presentation via Group/Subgroup.
type Parameter struct {
Key string `json:"key"` // unique within the set
Label string `json:"label,omitempty"` // human-friendly name
Group string `json:"group,omitempty"` // top-level grouping
Subgroup string `json:"subgroup,omitempty"`
DS string `json:"ds"` // target data source
Signal string `json:"signal"` // target signal name
Type ParamType `json:"type"` // value kind
Default any `json:"default,omitempty"`
Mandatory bool `json:"mandatory,omitempty"`
Min *float64 `json:"min,omitempty"` // numeric lower bound
Max *float64 `json:"max,omitempty"` // numeric upper bound
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
}
// ConfigSet is the schema half of the two-tier model: an ordered list of
// parameters bound to target signals. It is versioned git-style.
type ConfigSet struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
Parameters []Parameter `json:"parameters"`
}
// ConfigInstance is the value half: concrete values for a set's parameters,
// keyed by parameter key. SetID pins the schema it belongs to; SetVersion pins
// the schema revision (0 means "track current"). It is versioned git-style.
type ConfigInstance struct {
ID string `json:"id"`
Name string `json:"name"`
SetID string `json:"setId"`
SetVersion int `json:"setVersion,omitempty"` // 0 = current set version
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
Values map[string]any `json:"values"`
}
// Validate checks structural invariants of a config set: non-empty name,
// unique non-empty parameter keys, valid types, a target signal per parameter,
// and well-formed enum/default metadata.
func (s ConfigSet) Validate() error {
if strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("config set name must not be empty")
}
seen := make(map[string]bool, len(s.Parameters))
for i, p := range s.Parameters {
if strings.TrimSpace(p.Key) == "" {
return fmt.Errorf("parameter %d: key must not be empty", i)
}
if seen[p.Key] {
return fmt.Errorf("duplicate parameter key %q", p.Key)
}
seen[p.Key] = true
if !p.Type.valid() {
return fmt.Errorf("parameter %q: invalid type %q", p.Key, p.Type)
}
if strings.TrimSpace(p.DS) == "" || strings.TrimSpace(p.Signal) == "" {
return fmt.Errorf("parameter %q: target ds and signal are required", p.Key)
}
if p.Type == TypeEnum && len(p.EnumValues) == 0 {
return fmt.Errorf("parameter %q: enum type requires enumValues", p.Key)
}
if p.Min != nil && p.Max != nil && *p.Min > *p.Max {
return fmt.Errorf("parameter %q: min %v greater than max %v", p.Key, *p.Min, *p.Max)
}
if p.Default != nil {
if err := p.checkValue(p.Default); err != nil {
return fmt.Errorf("parameter %q default: %w", p.Key, err)
}
}
}
return nil
}
// param returns the parameter with the given key, or false.
func (s ConfigSet) param(key string) (Parameter, bool) {
for _, p := range s.Parameters {
if p.Key == key {
return p, true
}
}
return Parameter{}, false
}
// ValidateAgainst checks an instance against its set: every value references a
// known parameter and is type/range valid; every mandatory parameter without a
// default has a value.
func (inst ConfigInstance) ValidateAgainst(set ConfigSet) error {
for key, v := range inst.Values {
p, ok := set.param(key)
if !ok {
return fmt.Errorf("value for unknown parameter %q", key)
}
if err := p.checkValue(v); err != nil {
return fmt.Errorf("parameter %q: %w", key, err)
}
}
for _, p := range set.Parameters {
if !p.Mandatory {
continue
}
if _, ok := inst.Values[p.Key]; ok {
continue
}
if p.Default == nil {
return fmt.Errorf("mandatory parameter %q has no value", p.Key)
}
}
return nil
}
// Resolve returns the effective value for a parameter: the instance value when
// present, otherwise the parameter default. The second result is false when no
// value is available (optional parameter, no default).
func (inst ConfigInstance) Resolve(p Parameter) (any, bool) {
if v, ok := inst.Values[p.Key]; ok {
return v, true
}
if p.Default != nil {
return p.Default, true
}
return nil, false
}
// checkValue verifies a value matches the parameter's type and constraints.
func (p Parameter) checkValue(v any) error {
switch p.Type {
case TypeFloat, TypeInt:
f, err := toFloat(v)
if err != nil {
return err
}
if p.Type == TypeInt && f != math.Trunc(f) {
return fmt.Errorf("value %v is not an integer", f)
}
if p.Min != nil && f < *p.Min {
return fmt.Errorf("value %v below minimum %v", f, *p.Min)
}
if p.Max != nil && f > *p.Max {
return fmt.Errorf("value %v above maximum %v", f, *p.Max)
}
case TypeBool:
if _, ok := v.(bool); !ok {
return fmt.Errorf("value %v is not a bool", v)
}
case TypeString:
if _, ok := v.(string); !ok {
return fmt.Errorf("value %v is not a string", v)
}
case TypeEnum:
s, ok := v.(string)
if !ok {
return fmt.Errorf("enum value %v is not a string", v)
}
if !slices.Contains(p.EnumValues, s) {
return fmt.Errorf("value %q is not an allowed enum value", s)
}
case TypeFloatArray:
arr, err := toFloatArray(v)
if err != nil {
return err
}
for i, f := range arr {
if p.Min != nil && f < *p.Min {
return fmt.Errorf("element %d value %v below minimum %v", i, f, *p.Min)
}
if p.Max != nil && f > *p.Max {
return fmt.Errorf("element %d value %v above maximum %v", i, f, *p.Max)
}
}
}
return nil
}
// normalize coerces a JSON-decoded value into the canonical Go type the
// datasource write path expects. Array parameters become []float64 (JSON yields
// []any); scalar values are returned unchanged.
func (p Parameter) normalize(v any) any {
if p.Type == TypeFloatArray {
if arr, err := toFloatArray(v); err == nil {
return arr
}
}
return v
}
// toFloatArray coerces a JSON-decoded array value to []float64. JSON
// unmarshalling yields []any of float64, but a native []float64 is also
// accepted.
func toFloatArray(v any) ([]float64, error) {
switch a := v.(type) {
case []float64:
return a, nil
case []any:
out := make([]float64, len(a))
for i, e := range a {
f, err := toFloat(e)
if err != nil {
return nil, fmt.Errorf("element %d: %w", i, err)
}
out[i] = f
}
return out, nil
default:
return nil, fmt.Errorf("value %v is not an array", v)
}
}
// toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling
// yields float64 for numbers, but values may also arrive as int or string.
func toFloat(v any) (float64, error) {
switch n := v.(type) {
case float64:
return n, nil
case float32:
return float64(n), nil
case int:
return float64(n), nil
case int64:
return float64(n), nil
case string:
f, err := strconv.ParseFloat(n, 64)
if err != nil {
return 0, fmt.Errorf("value %q is not numeric", n)
}
return f, nil
default:
return 0, fmt.Errorf("value %v is not numeric", v)
}
}
+134
View File
@@ -0,0 +1,134 @@
package confmgr
import (
"testing"
)
// TestParamTypeValid covers the valid/invalid branches of ParamType.valid.
func TestParamTypeValid(t *testing.T) {
for _, ok := range []ParamType{TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray} {
if !ok.valid() {
t.Errorf("%q should be valid", ok)
}
}
if ParamType("bogus").valid() {
t.Error("bogus type should be invalid")
}
}
// TestCheckValue exercises every type branch of Parameter.checkValue, including
// the type-mismatch and range-violation error paths.
func TestCheckValue(t *testing.T) {
cases := []struct {
name string
p Parameter
v any
wantErr bool
}{
{"float ok", Parameter{Type: TypeFloat}, 1.5, false},
{"float from string", Parameter{Type: TypeFloat}, "2.5", false},
{"float not numeric", Parameter{Type: TypeFloat}, true, true},
{"float below min", Parameter{Type: TypeFloat, Min: fptr(0)}, -1.0, true},
{"float above max", Parameter{Type: TypeFloat, Max: fptr(10)}, 11.0, true},
{"int ok", Parameter{Type: TypeInt}, 4.0, false},
{"int non-integer", Parameter{Type: TypeInt}, 4.5, true},
{"bool ok", Parameter{Type: TypeBool}, true, false},
{"bool wrong type", Parameter{Type: TypeBool}, 1.0, true},
{"string ok", Parameter{Type: TypeString}, "hi", false},
{"string wrong type", Parameter{Type: TypeString}, 1.0, true},
{"enum ok", Parameter{Type: TypeEnum, EnumValues: []string{"a", "b"}}, "b", false},
{"enum not string", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, 1.0, true},
{"enum not allowed", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, "z", true},
{"array ok", Parameter{Type: TypeFloatArray}, []any{1.0, 2.0}, false},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, false},
{"array not array", Parameter{Type: TypeFloatArray}, 1.0, true},
{"array elem below min", Parameter{Type: TypeFloatArray, Min: fptr(0)}, []any{-1.0}, true},
{"array elem above max", Parameter{Type: TypeFloatArray, Max: fptr(5)}, []any{9.0}, true},
{"array bad elem", Parameter{Type: TypeFloatArray}, []any{"nope"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.p.checkValue(tc.v)
if tc.wantErr && err == nil {
t.Errorf("checkValue(%v): want error", tc.v)
}
if !tc.wantErr && err != nil {
t.Errorf("checkValue(%v): unexpected error %v", tc.v, err)
}
})
}
}
// TestValidateInvalidType covers the invalid-type branch of ConfigSet.Validate.
func TestValidateInvalidType(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: ParamType("weird")},
}}
if err := set.Validate(); err == nil {
t.Error("invalid parameter type: want error")
}
}
// TestValidateEnumRequiresValues covers the enum-without-values branch.
func TestValidateEnumRequiresValues(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeEnum},
}}
if err := set.Validate(); err == nil {
t.Error("enum without values: want error")
}
}
// TestValidateMinGreaterThanMax covers the min>max branch.
func TestValidateMinGreaterThanMax(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Min: fptr(10), Max: fptr(1)},
}}
if err := set.Validate(); err == nil {
t.Error("min>max: want error")
}
}
// TestValidateAgainstMandatoryNoDefault covers the mandatory-without-default
// branch of ValidateAgainst.
func TestValidateAgainstMandatoryNoDefault(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "req", DS: "d", Signal: "s", Type: TypeFloat, Mandatory: true},
}}
inst := ConfigInstance{SetID: "x", Values: map[string]any{}}
if err := inst.ValidateAgainst(set); err == nil {
t.Error("missing mandatory value: want error")
}
// Providing the value clears the error.
inst.Values["req"] = 1.0
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("with value: unexpected error %v", err)
}
}
// TestResolveAndNormalize covers Resolve fallbacks and array normalization.
func TestResolveAndNormalize(t *testing.T) {
p := Parameter{Key: "v", Type: TypeFloat, Default: 7.0}
inst := ConfigInstance{Values: map[string]any{}}
if got, ok := inst.Resolve(p); !ok || got != 7.0 {
t.Errorf("Resolve default: got %v,%v want 7,true", got, ok)
}
inst.Values["v"] = 3.0
if got, ok := inst.Resolve(p); !ok || got != 3.0 {
t.Errorf("Resolve value: got %v,%v want 3,true", got, ok)
}
// Optional param without default → no value.
if _, ok := inst.Resolve(Parameter{Key: "none", Type: TypeFloat}); ok {
t.Error("Resolve no-default: want ok=false")
}
arr := Parameter{Type: TypeFloatArray}
out := arr.normalize([]any{1.0, 2.0, 3.0})
if got, ok := out.([]float64); !ok || len(got) != 3 {
t.Errorf("normalize array: got %T %v", out, out)
}
// Non-array passthrough.
if got := arr.normalize("scalar"); got != "scalar" {
t.Errorf("normalize passthrough: got %v", got)
}
}
+129
View File
@@ -0,0 +1,129 @@
package confmgr
import (
"fmt"
"math"
"slices"
"strconv"
)
// ReadFunc reads the current raw value of a target signal. The API/engine layer
// supplies a closure backed by the broker (ReadNow) so confmgr stays decoupled
// from the transport. The returned value mirrors datasource.Value.Data
// (float64 | []float64 | string | int64 | bool; enums arrive as an int64 index).
type ReadFunc func(ds, signal string) (any, error)
// SnapshotEntry records the outcome of reading one parameter's target signal.
type SnapshotEntry struct {
Key string `json:"key"`
DS string `json:"ds"`
Signal string `json:"signal"`
Value any `json:"value,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// SnapshotResult summarises a snapshot run. Values holds the captured,
// type-coerced values ready to populate a new ConfigInstance.
type SnapshotResult struct {
SetID string `json:"setId"`
Entries []SnapshotEntry `json:"entries"`
Captured int `json:"captured"`
Failed int `json:"failed"`
Values map[string]any `json:"-"`
}
// Snapshot reads the current value of every parameter's target signal via read
// and builds a value map for a new instance. Read failures and values that
// cannot be coerced to the parameter's type are recorded per-entry rather than
// aborting the whole snapshot, so a partial capture is reported faithfully.
func Snapshot(set ConfigSet, read ReadFunc) SnapshotResult {
res := SnapshotResult{
SetID: set.ID,
Entries: make([]SnapshotEntry, 0, len(set.Parameters)),
Values: make(map[string]any, len(set.Parameters)),
}
for _, p := range set.Parameters {
e := SnapshotEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
raw, err := read(p.DS, p.Signal)
if err != nil {
e.Error = err.Error()
res.Failed++
res.Entries = append(res.Entries, e)
continue
}
v, err := p.coerceSnapshot(raw)
if err != nil {
e.Error = err.Error()
res.Failed++
res.Entries = append(res.Entries, e)
continue
}
e.Value = v
e.OK = true
res.Values[p.Key] = v
res.Captured++
res.Entries = append(res.Entries, e)
}
return res
}
// coerceSnapshot converts a raw datasource value into the canonical Go value the
// parameter's type expects, so the result passes checkValue and serialises
// cleanly. Enum signals arrive as an int64 index and are mapped to their string.
func (p Parameter) coerceSnapshot(raw any) (any, error) {
switch p.Type {
case TypeFloat:
return toFloat(raw)
case TypeInt:
f, err := toFloat(raw)
if err != nil {
return nil, err
}
return int64(math.Round(f)), nil
case TypeBool:
switch b := raw.(type) {
case bool:
return b, nil
case string:
pb, err := strconv.ParseBool(b)
if err != nil {
return nil, fmt.Errorf("value %q is not a bool", b)
}
return pb, nil
default:
f, err := toFloat(raw)
if err != nil {
return nil, fmt.Errorf("value %v is not a bool", raw)
}
return f != 0, nil
}
case TypeString:
if s, ok := raw.(string); ok {
return s, nil
}
return fmt.Sprintf("%v", raw), nil
case TypeEnum:
// Enum signals deliver an int64 index into the metadata strings; map it
// to this parameter's enum value. A string already in range is kept.
if s, ok := raw.(string); ok {
if slices.Contains(p.EnumValues, s) {
return s, nil
}
return nil, fmt.Errorf("value %q is not an allowed enum value", s)
}
f, err := toFloat(raw)
if err != nil {
return nil, fmt.Errorf("enum value %v is not an index", raw)
}
idx := int(math.Round(f))
if idx < 0 || idx >= len(p.EnumValues) {
return nil, fmt.Errorf("enum index %d out of range", idx)
}
return p.EnumValues[idx], nil
case TypeFloatArray:
return toFloatArray(raw)
default:
return raw, nil
}
}
+83
View File
@@ -0,0 +1,83 @@
package confmgr
import (
"errors"
"testing"
)
func TestSnapshotCapturesAndCoerces(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat},
{Key: "n", DS: "epics", Signal: "PSU:N", Type: TypeInt},
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
{Key: "mode", DS: "epics", Signal: "PSU:MODE", Type: TypeEnum, EnumValues: []string{"off", "low", "high"}},
{Key: "wf", DS: "epics", Signal: "PSU:WF", Type: TypeFloatArray},
{Key: "lbl", DS: "epics", Signal: "PSU:LBL", Type: TypeString},
},
}
live := map[string]any{
"PSU:V": 24.5,
"PSU:N": 3.0, // float reading → coerced to int64
"PSU:EN": int64(1), // numeric → bool true
"PSU:MODE": int64(2), // enum index → "high"
"PSU:WF": []float64{1, 2, 3},
"PSU:LBL": "ready",
}
res := Snapshot(set, func(_, signal string) (any, error) {
v, ok := live[signal]
if !ok {
return nil, errors.New("not read")
}
return v, nil
})
if res.Captured != 6 || res.Failed != 0 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if res.Values["v"] != 24.5 {
t.Errorf("v = %v, want 24.5", res.Values["v"])
}
if res.Values["n"] != int64(3) {
t.Errorf("n = %v (%T), want int64(3)", res.Values["n"], res.Values["n"])
}
if res.Values["en"] != true {
t.Errorf("en = %v, want true", res.Values["en"])
}
if res.Values["mode"] != "high" {
t.Errorf("mode = %v, want high", res.Values["mode"])
}
if res.Values["lbl"] != "ready" {
t.Errorf("lbl = %v, want ready", res.Values["lbl"])
}
// The captured values must validate against the set.
inst := ConfigInstance{SetID: set.ID, Values: res.Values}
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("snapshot values fail validation: %v", err)
}
}
func TestSnapshotReadFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "a", DS: "epics", Signal: "A", Type: TypeFloat},
{Key: "b", DS: "epics", Signal: "B", Type: TypeFloat},
},
}
res := Snapshot(set, func(_, signal string) (any, error) {
if signal == "B" {
return nil, errors.New("offline")
}
return 1.0, nil
})
if res.Captured != 1 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["b"]; ok {
t.Errorf("failed parameter must not appear in values")
}
}
+700
View File
@@ -0,0 +1,700 @@
package confmgr
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
// ErrNotFound is returned when the requested set or instance does not exist.
var ErrNotFound = errors.New("config object not found")
// Kind selects which collection a store operation targets.
type Kind int
const (
KindSet Kind = iota
KindInstance
KindRule
)
func (k Kind) sub() string {
switch k {
case KindInstance:
return "instances"
case KindRule:
return "rules"
default:
return "sets"
}
}
// Meta is the lightweight listing representation.
type Meta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
// SetID is only populated for instances (the schema they belong to); it is
// empty for sets. Lets clients group/filter instances by set without a GET
// per instance.
SetID string `json:"setId,omitempty"`
// Enabled is only populated for rules: nil for sets/instances and for legacy
// rules without the flag. Lets the rule list show enabled/disabled status
// without a GET per rule.
Enabled *bool `json:"enabled,omitempty"`
// Owner/Scope/Groups carry the visibility metadata so list handlers can filter
// by the caller without a GET per object. Empty scope = global (legacy-safe).
Owner string `json:"owner,omitempty"`
Scope string `json:"scope,omitempty"`
Groups []string `json:"groups,omitempty"`
}
// VersionMeta describes a single persisted revision.
type VersionMeta struct {
Version int `json:"version"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"savedAt"`
}
// Store persists configuration sets and instances as versioned JSON files
// under {storageDir}/configs/{sets,instances}/. Each object lives in
// {id}.json with prior revisions preserved as {id}.v{N}.json backups, exactly
// mirroring the panel storage versioning scheme.
type Store struct {
rootDir string
dirs map[Kind]string
}
// New opens (and creates, if needed) the configs storage directories.
func New(storageDir string) (*Store, error) {
s := &Store{rootDir: storageDir, dirs: map[Kind]string{}}
for _, k := range []Kind{KindSet, KindInstance, KindRule} {
dir := filepath.Join(storageDir, "configs", k.sub())
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create %s dir: %w", k.sub(), err)
}
s.dirs[k] = dir
}
return s, nil
}
// header parses the metadata fields shared by sets and instances.
type header struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
Tag string `json:"tag"`
SetID string `json:"setId"`
Enabled *bool `json:"enabled"`
Owner string `json:"owner"`
Scope string `json:"scope"`
Groups []string `json:"groups"`
}
func validateID(id string) error {
if id == "" {
return fmt.Errorf("config ID must not be empty")
}
for _, r := range id {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
return fmt.Errorf("invalid character %q in config ID", r)
}
}
return nil
}
func (s *Store) filePath(k Kind, id string) string {
return filepath.Join(s.dirs[k], id+".json")
}
func (s *Store) backupPath(k Kind, id string, version int) string {
return filepath.Join(s.dirs[k], fmt.Sprintf("%s.v%d.json", id, version))
}
// isVersioned reports whether name matches <id>.v<number>.json.
func isVersioned(name string) bool {
parts := strings.Split(name, ".")
if len(parts) != 3 || parts[2] != "json" {
return false
}
if !strings.HasPrefix(parts[1], "v") {
return false
}
_, err := strconv.Atoi(parts[1][1:])
return err == nil
}
func readHeader(data []byte) (header, error) {
var h header
if err := json.Unmarshal(data, &h); err != nil {
return header{}, err
}
return h, nil
}
// List returns metadata for every stored object of the given kind.
func (s *Store) List(k Kind) ([]Meta, error) {
entries, err := os.ReadDir(s.dirs[k])
if err != nil {
return nil, err
}
out := []Meta{}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".json") || isVersioned(name) {
continue
}
id := strings.TrimSuffix(name, ".json")
data, err := os.ReadFile(s.filePath(k, id))
if err != nil {
continue
}
h, err := readHeader(data)
if err != nil {
continue
}
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled, Owner: h.Owner, Scope: h.Scope, Groups: h.Groups})
}
return out, nil
}
// get returns the raw JSON bytes for the current revision.
func (s *Store) get(k Kind, id string) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
}
data, err := os.ReadFile(s.filePath(k, id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// create writes a new object, deriving a unique ID from name when obj has none,
// stamping version=1 and the given tag. It returns the raw stored bytes.
func (s *Store) create(k Kind, obj map[string]any, tag string) (string, []byte, error) {
id, _ := obj["id"].(string)
if id == "" {
id = slugify(name(obj))
}
if err := validateID(id); err != nil {
return "", nil, err
}
if _, err := os.Stat(s.filePath(k, id)); err == nil {
id = id + "-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
obj["id"] = id
obj["version"] = 1
if tag != "" {
obj["tag"] = tag
}
data, err := marshal(obj)
if err != nil {
return "", nil, err
}
return id, data, os.WriteFile(s.filePath(k, id), data, 0o644)
}
// update replaces the current revision, preserving the prior one as a backup
// and incrementing the version. It returns the raw stored bytes.
func (s *Store) update(k Kind, id string, obj map[string]any, tag string) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
oldData, err := os.ReadFile(s.filePath(k, id))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return nil, err
}
oldHdr, err := readHeader(oldData)
if err != nil {
return nil, fmt.Errorf("parse existing JSON: %w", err)
}
if oldHdr.Version < 1 {
oldHdr.Version = 1
}
if err := os.WriteFile(s.backupPath(k, id, oldHdr.Version), oldData, 0o644); err != nil {
return nil, fmt.Errorf("create backup: %w", err)
}
obj["id"] = id
obj["version"] = oldHdr.Version + 1
if tag != "" {
obj["tag"] = tag
}
data, err := marshal(obj)
if err != nil {
return nil, err
}
return data, os.WriteFile(s.filePath(k, id), data, 0o644)
}
// Versions returns metadata for every persisted revision, newest-first.
func (s *Store) Versions(k Kind, id string) ([]VersionMeta, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
curInfo, err := os.Stat(s.filePath(k, id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
} else if err != nil {
return nil, err
}
curData, err := os.ReadFile(s.filePath(k, id))
if err != nil {
return nil, err
}
curHdr, err := readHeader(curData)
if err != nil {
return nil, err
}
out := []VersionMeta{{
Version: curHdr.Version,
Name: curHdr.Name,
Tag: curHdr.Tag,
Current: true,
SavedAt: curInfo.ModTime(),
}}
entries, err := os.ReadDir(s.dirs[k])
if err != nil {
return nil, err
}
prefix := id + ".v"
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, prefix) || !isVersioned(name) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
data, err := os.ReadFile(filepath.Join(s.dirs[k], name))
if err != nil {
continue
}
h, err := readHeader(data)
if err != nil {
continue
}
out = append(out, VersionMeta{
Version: h.Version,
Name: h.Name,
Tag: h.Tag,
SavedAt: info.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// getVersion returns the raw JSON bytes for a specific revision.
func (s *Store) getVersion(k Kind, id string, version int) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
curData, err := os.ReadFile(s.filePath(k, id))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return nil, err
}
if h, err := readHeader(curData); err == nil && h.Version == version {
return curData, nil
}
data, err := os.ReadFile(s.backupPath(k, id, version))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// Promote re-saves a past revision as a new revision on top of history.
func (s *Store) Promote(k Kind, id string, version int) error {
data, err := s.getVersion(k, id, version)
if err != nil {
return err
}
obj, err := unmarshal(data)
if err != nil {
return err
}
_, err = s.update(k, id, obj, fmt.Sprintf("restored from v%d", version))
return err
}
// Fork creates a brand-new object from a revision, with a fresh ID and version 1.
func (s *Store) Fork(k Kind, id string, version int) (string, error) {
data, err := s.getVersion(k, id, version)
if err != nil {
return "", err
}
obj, err := unmarshal(data)
if err != nil {
return "", err
}
newID := id + "-fork-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
obj["id"] = newID
obj["version"] = 1
delete(obj, "tag")
out, err := marshal(obj)
if err != nil {
return "", err
}
return newID, os.WriteFile(s.filePath(k, newID), out, 0o644)
}
// Delete moves an object and all its backups to a timestamped trash folder so
// it can be recovered. Nothing is destroyed.
func (s *Store) Delete(k Kind, id string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
if _, err := os.Stat(s.filePath(k, id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
trashDir := filepath.Join(s.rootDir, "trash", "configs", k.sub(), fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return fmt.Errorf("create trash dir: %w", err)
}
entries, err := os.ReadDir(s.dirs[k])
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if name == id+".json" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
if err := os.Rename(filepath.Join(s.dirs[k], name), filepath.Join(trashDir, name)); err != nil {
return fmt.Errorf("move %s to trash: %w", name, err)
}
}
}
return nil
}
// ── typed wrappers ──────────────────────────────────────────────────────────
// GetSet returns the current revision of a config set.
func (s *Store) GetSet(id string) (ConfigSet, error) {
data, err := s.get(KindSet, id)
if err != nil {
return ConfigSet{}, err
}
var set ConfigSet
return set, json.Unmarshal(data, &set)
}
// GetSetVersion returns a specific revision of a config set.
func (s *Store) GetSetVersion(id string, version int) (ConfigSet, error) {
data, err := s.getVersion(KindSet, id, version)
if err != nil {
return ConfigSet{}, err
}
var set ConfigSet
return set, json.Unmarshal(data, &set)
}
// CreateSet validates and stores a new config set, returning it with its
// assigned ID and version.
func (s *Store) CreateSet(set ConfigSet, tag string) (ConfigSet, error) {
if err := set.Validate(); err != nil {
return ConfigSet{}, err
}
obj, err := toMap(set)
if err != nil {
return ConfigSet{}, err
}
_, data, err := s.create(KindSet, obj, tag)
if err != nil {
return ConfigSet{}, err
}
var out ConfigSet
return out, json.Unmarshal(data, &out)
}
// UpdateSet validates and stores a new revision of an existing config set.
func (s *Store) UpdateSet(id string, set ConfigSet, tag string) (ConfigSet, error) {
if err := set.Validate(); err != nil {
return ConfigSet{}, err
}
obj, err := toMap(set)
if err != nil {
return ConfigSet{}, err
}
data, err := s.update(KindSet, id, obj, tag)
if err != nil {
return ConfigSet{}, err
}
var out ConfigSet
return out, json.Unmarshal(data, &out)
}
// GetInstance returns the current revision of a config instance.
func (s *Store) GetInstance(id string) (ConfigInstance, error) {
data, err := s.get(KindInstance, id)
if err != nil {
return ConfigInstance{}, err
}
var inst ConfigInstance
return inst, json.Unmarshal(data, &inst)
}
// GetInstanceVersion returns a specific revision of a config instance.
func (s *Store) GetInstanceVersion(id string, version int) (ConfigInstance, error) {
data, err := s.getVersion(KindInstance, id, version)
if err != nil {
return ConfigInstance{}, err
}
var inst ConfigInstance
return inst, json.Unmarshal(data, &inst)
}
// setForInstance loads the schema an instance is bound to, honouring a pinned
// SetVersion when set.
func (s *Store) setForInstance(inst ConfigInstance) (ConfigSet, error) {
if inst.SetVersion > 0 {
return s.GetSetVersion(inst.SetID, inst.SetVersion)
}
return s.GetSet(inst.SetID)
}
// CreateInstance validates an instance against its set and stores it.
func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance, error) {
set, err := s.setForInstance(inst)
if err != nil {
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
}
if err := inst.ValidateAgainst(set); err != nil {
return ConfigInstance{}, err
}
if err := s.applyRules(&inst, set); err != nil {
return ConfigInstance{}, err
}
obj, err := toMap(inst)
if err != nil {
return ConfigInstance{}, err
}
_, data, err := s.create(KindInstance, obj, tag)
if err != nil {
return ConfigInstance{}, err
}
var out ConfigInstance
return out, json.Unmarshal(data, &out)
}
// UpdateInstance validates and stores a new revision of an existing instance.
func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (ConfigInstance, error) {
set, err := s.setForInstance(inst)
if err != nil {
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
}
if err := inst.ValidateAgainst(set); err != nil {
return ConfigInstance{}, err
}
if err := s.applyRules(&inst, set); err != nil {
return ConfigInstance{}, err
}
obj, err := toMap(inst)
if err != nil {
return ConfigInstance{}, err
}
data, err := s.update(KindInstance, id, obj, tag)
if err != nil {
return ConfigInstance{}, err
}
var out ConfigInstance
return out, json.Unmarshal(data, &out)
}
// SetForInstance is the exported loader used by apply/diff callers.
func (s *Store) SetForInstance(inst ConfigInstance) (ConfigSet, error) {
return s.setForInstance(inst)
}
// ── rules ────────────────────────────────────────────────────────────────────
// GetRule returns the current revision of a CUE validation/transformation rule.
func (s *Store) GetRule(id string) (ConfigRule, error) {
data, err := s.get(KindRule, id)
if err != nil {
return ConfigRule{}, err
}
var rule ConfigRule
return rule, json.Unmarshal(data, &rule)
}
// GetRuleVersion returns a specific revision of a rule.
func (s *Store) GetRuleVersion(id string, version int) (ConfigRule, error) {
data, err := s.getVersion(KindRule, id, version)
if err != nil {
return ConfigRule{}, err
}
var rule ConfigRule
return rule, json.Unmarshal(data, &rule)
}
// CreateRule validates the rule's CUE source and stores it.
func (s *Store) CreateRule(rule ConfigRule, tag string) (ConfigRule, error) {
if err := rule.Validate(); err != nil {
return ConfigRule{}, err
}
obj, err := toMap(rule)
if err != nil {
return ConfigRule{}, err
}
_, data, err := s.create(KindRule, obj, tag)
if err != nil {
return ConfigRule{}, err
}
var out ConfigRule
return out, json.Unmarshal(data, &out)
}
// UpdateRule validates and stores a new revision of an existing rule.
func (s *Store) UpdateRule(id string, rule ConfigRule, tag string) (ConfigRule, error) {
if err := rule.Validate(); err != nil {
return ConfigRule{}, err
}
obj, err := toMap(rule)
if err != nil {
return ConfigRule{}, err
}
data, err := s.update(KindRule, id, obj, tag)
if err != nil {
return ConfigRule{}, err
}
var out ConfigRule
return out, json.Unmarshal(data, &out)
}
// rulesForSet loads every current-revision rule bound to the given set.
func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
metas, err := s.List(KindRule)
if err != nil {
return nil, err
}
var out []ConfigRule
for _, m := range metas {
if m.SetID != setID {
continue
}
rule, err := s.GetRule(m.ID)
if err != nil {
continue
}
if !rule.IsEnabled() {
continue
}
out = append(out, rule)
}
return out, nil
}
// applyRules runs every rule bound to inst's set against its values. On
// success it merges rule-derived values back into inst (parameter keys only) so
// the stored instance reflects the canonical, transformed configuration. A
// violation is returned as *RuleError so the caller can surface the structured
// details.
func (s *Store) applyRules(inst *ConfigInstance, set ConfigSet) error {
rules, err := s.rulesForSet(inst.SetID)
if err != nil {
return err
}
if len(rules) == 0 {
return nil
}
res := evaluateRules(rules, inst.Values)
if !res.OK {
return &RuleError{Result: res}
}
for k, v := range res.Transformed {
if _, ok := set.param(k); ok {
if inst.Values == nil {
inst.Values = map[string]any{}
}
inst.Values[k] = v
}
}
return nil
}
// ValidateInstanceRules evaluates the stored rules for an instance without
// persisting anything. It is the read-only counterpart used by the validate
// endpoint.
func (s *Store) ValidateInstanceRules(inst ConfigInstance) (RuleResult, error) {
rules, err := s.rulesForSet(inst.SetID)
if err != nil {
return RuleResult{}, err
}
if len(rules) == 0 {
return RuleResult{OK: true}, nil
}
return evaluateRules(rules, inst.Values), nil
}
// ── JSON helpers ────────────────────────────────────────────────────────────
func name(obj map[string]any) string {
n, _ := obj["name"].(string)
return n
}
func marshal(obj map[string]any) ([]byte, error) {
return json.MarshalIndent(obj, "", " ")
}
func unmarshal(data []byte) (map[string]any, error) {
var obj map[string]any
if err := json.Unmarshal(data, &obj); err != nil {
return nil, err
}
return obj, nil
}
// toMap round-trips a typed value through JSON into a generic map so the store
// can stamp id/version/tag without knowing the concrete type.
func toMap(v any) (map[string]any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
return unmarshal(data)
}
// 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 "config"
}
return result
}
+118
View File
@@ -0,0 +1,118 @@
package confmgr
import (
"testing"
)
// TestValidateIDRejectsBadChars covers the invalid-character branch of
// validateID, surfaced through the typed getters as ErrNotFound.
func TestValidateIDRejectsBadChars(t *testing.T) {
s, _ := New(t.TempDir())
for _, bad := range []string{"", "bad/slash", "has space", "dot.dot"} {
if _, err := s.GetSet(bad); err == nil {
t.Errorf("GetSet(%q): want error", bad)
}
}
}
// TestNotFoundPaths covers the ErrNotFound branches across the revision API.
func TestNotFoundPaths(t *testing.T) {
s, _ := New(t.TempDir())
if _, err := s.GetSet("missing"); err != ErrNotFound {
t.Errorf("GetSet missing: want ErrNotFound, got %v", err)
}
if _, err := s.GetSetVersion("missing", 1); err != ErrNotFound {
t.Errorf("GetSetVersion missing: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(KindSet, "missing"); err != ErrNotFound {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
if err := s.Promote(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Promote missing: want ErrNotFound, got %v", err)
}
if _, err := s.Fork(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
if _, err := s.UpdateSet("missing", sampleSet(), ""); err != ErrNotFound {
t.Errorf("UpdateSet missing: want ErrNotFound, got %v", err)
}
// A valid id that exists but a version that was never written.
created, _ := s.CreateSet(sampleSet(), "")
if _, err := s.GetSetVersion(created.ID, 99); err != ErrNotFound {
t.Errorf("GetSetVersion bogus version: want ErrNotFound, got %v", err)
}
}
// TestInstancePinnedSetVersion covers the SetVersion>0 branch of setForInstance:
// the instance is validated against a specific (pinned) revision of its set.
func TestInstancePinnedSetVersion(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
// Bump the set to v2 so a distinct earlier revision exists.
if _, err := s.UpdateSet(set.ID, sampleSet(), "v2"); err != nil {
t.Fatal(err)
}
inst := ConfigInstance{
Name: "pinned",
SetID: set.ID,
SetVersion: 1, // pin to the original schema revision
Values: map[string]any{"voltage": 24.0},
}
out, err := s.CreateInstance(inst, "")
if err != nil {
t.Fatalf("CreateInstance pinned: %v", err)
}
if out.SetVersion != 1 {
t.Errorf("SetVersion: want 1, got %d", out.SetVersion)
}
// Updating the pinned instance also exercises the pinned UpdateInstance path.
out.Values["voltage"] = 30.0
v2, err := s.UpdateInstance(out.ID, out, "bump")
if err != nil {
t.Fatalf("UpdateInstance pinned: %v", err)
}
if v2.Version != 2 {
t.Errorf("instance version: want 2, got %d", v2.Version)
}
}
// TestUpdateInstanceMissingSet covers the load-set error branch of
// UpdateInstance (set referenced by the instance does not exist).
func TestUpdateInstanceMissingSet(t *testing.T) {
s, _ := New(t.TempDir())
inst := ConfigInstance{Name: "x", SetID: "nope", Values: map[string]any{}}
if _, err := s.UpdateInstance("anything", inst, ""); err == nil {
t.Error("UpdateInstance with missing set: want error")
}
}
// TestListSkipsVersionedFiles checks List returns only current revisions and
// reflects metadata after updates.
func TestListSkipsVersionedFiles(t *testing.T) {
s, _ := New(t.TempDir())
a, _ := s.CreateSet(sampleSet(), "")
b, _ := s.CreateSet(sampleSet(), "")
// Create backups for a so the dir holds versioned files too.
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
metas, err := s.List(KindSet)
if err != nil {
t.Fatal(err)
}
if len(metas) != 2 {
t.Fatalf("List: want 2 current sets, got %d", len(metas))
}
ids := map[string]bool{}
for _, m := range metas {
ids[m.ID] = true
}
if !ids[a.ID] || !ids[b.ID] {
t.Errorf("List missing expected ids: %v", ids)
}
}
+290
View File
@@ -0,0 +1,290 @@
package confmgr
import (
"testing"
)
func fptr(f float64) *float64 { return &f }
func sampleSet() ConfigSet {
return ConfigSet{
Name: "PSU",
Description: "power supply config",
Parameters: []Parameter{
{Key: "voltage", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0, Mandatory: true, Min: fptr(0), Max: fptr(48)},
{Key: "enabled", DS: "epics", Signal: "PSU:EN", Type: TypeBool, Default: false},
},
}
}
func TestCreateAndGetSet(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
out, err := s.CreateSet(sampleSet(), "initial")
if err != nil {
t.Fatal(err)
}
if out.ID == "" {
t.Fatal("expected generated ID")
}
if out.Version != 1 {
t.Errorf("version: want 1, got %d", out.Version)
}
got, err := s.GetSet(out.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "PSU" || len(got.Parameters) != 2 {
t.Errorf("round-trip mismatch: %+v", got)
}
}
func TestRuleBlocksInvalidInstance(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
if _, err := s.CreateRule(ConfigRule{
Name: "voltage cap",
SetID: set.ID,
Source: "voltage: <=24",
}, ""); err != nil {
t.Fatal(err)
}
// In-range value passes.
if _, err := s.CreateInstance(ConfigInstance{
Name: "ok", SetID: set.ID, Values: map[string]any{"voltage": 20.0},
}, ""); err != nil {
t.Fatalf("expected in-range instance to save, got %v", err)
}
// Out-of-range value is rejected by the rule.
_, err := s.CreateInstance(ConfigInstance{
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
}, "")
if err == nil {
t.Fatalf("expected rule violation to block save")
}
var re *RuleError
if !asRuleError(err, &re) {
t.Fatalf("expected *RuleError, got %T: %v", err, err)
}
}
func TestRuleTransformPersists(t *testing.T) {
s, _ := New(t.TempDir())
set := sampleSet()
max := 48.0
set.Parameters = append(set.Parameters, Parameter{Key: "vmax", DS: "epics", Signal: "PSU:VMAX", Type: TypeFloat, Min: &max})
created, _ := s.CreateSet(set, "")
// Rule derives vmax = voltage * 2 (within range for voltage=20 -> 40).
if _, err := s.CreateRule(ConfigRule{
Name: "vmax derive", SetID: created.ID, Source: "voltage: number\nvmax: voltage * 2",
}, ""); err != nil {
t.Fatal(err)
}
inst, err := s.CreateInstance(ConfigInstance{
Name: "x", SetID: created.ID, Values: map[string]any{"voltage": 20.0},
}, "")
if err != nil {
t.Fatal(err)
}
if f, _ := toFloat(inst.Values["vmax"]); f != 40 {
t.Fatalf("expected derived vmax=40 to persist, got %v", inst.Values["vmax"])
}
}
func TestDisabledRuleSkipped(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
disabled := false
if _, err := s.CreateRule(ConfigRule{
Name: "voltage cap",
SetID: set.ID,
Enabled: &disabled,
Source: "voltage: <=24",
}, ""); err != nil {
t.Fatal(err)
}
// A value that would violate the rule still saves because the rule is off.
if _, err := s.CreateInstance(ConfigInstance{
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
}, ""); err != nil {
t.Fatalf("expected disabled rule to be skipped, got %v", err)
}
}
func asRuleError(err error, target **RuleError) bool {
for err != nil {
if re, ok := err.(*RuleError); ok {
*target = re
return true
}
type unwrapper interface{ Unwrap() error }
u, ok := err.(unwrapper)
if !ok {
return false
}
err = u.Unwrap()
}
return false
}
func TestSetVersioning(t *testing.T) {
s, _ := New(t.TempDir())
created, _ := s.CreateSet(sampleSet(), "")
id := created.ID
// Update -> v2.
upd := sampleSet()
upd.Description = "edited"
v2, err := s.UpdateSet(id, upd, "edit")
if err != nil {
t.Fatal(err)
}
if v2.Version != 2 {
t.Fatalf("version after update: want 2, got %d", v2.Version)
}
versions, err := s.Versions(KindSet, id)
if err != nil {
t.Fatal(err)
}
if len(versions) != 2 {
t.Fatalf("want 2 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 2 {
t.Errorf("newest should be current v2: %+v", versions[0])
}
// Old version still retrievable.
old, err := s.GetSetVersion(id, 1)
if err != nil {
t.Fatal(err)
}
if old.Description != "power supply config" {
t.Errorf("v1 description changed: %q", old.Description)
}
// Promote v1 -> becomes v3 with original content.
if err := s.Promote(KindSet, id, 1); err != nil {
t.Fatal(err)
}
cur, _ := s.GetSet(id)
if cur.Version != 3 || cur.Description != "power supply config" {
t.Errorf("after promote: want v3 original desc, got v%d %q", cur.Version, cur.Description)
}
}
func TestForkSet(t *testing.T) {
s, _ := New(t.TempDir())
created, _ := s.CreateSet(sampleSet(), "")
_, _ = s.UpdateSet(created.ID, sampleSet(), "")
newID, err := s.Fork(KindSet, created.ID, 1)
if err != nil {
t.Fatal(err)
}
forked, err := s.GetSet(newID)
if err != nil {
t.Fatal(err)
}
if forked.ID == created.ID {
t.Error("fork should have a new ID")
}
if forked.Version != 1 {
t.Errorf("fork version: want 1, got %d", forked.Version)
}
if forked.Tag != "" {
t.Errorf("fork tag should be cleared, got %q", forked.Tag)
}
}
func TestDeleteSetSoftDeletes(t *testing.T) {
s, _ := New(t.TempDir())
created, _ := s.CreateSet(sampleSet(), "")
if err := s.Delete(KindSet, created.ID); err != nil {
t.Fatal(err)
}
if _, err := s.GetSet(created.ID); err == nil {
t.Error("expected set to be gone after delete")
}
// Deleting again is a not-found.
if err := s.Delete(KindSet, created.ID); err != ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestSetValidation(t *testing.T) {
s, _ := New(t.TempDir())
bad := ConfigSet{Name: "", Parameters: nil}
if _, err := s.CreateSet(bad, ""); err == nil {
t.Error("expected empty-name validation error")
}
dup := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat},
{Key: "a", DS: "d", Signal: "s2", Type: TypeFloat},
}}
if _, err := s.CreateSet(dup, ""); err == nil {
t.Error("expected duplicate-key validation error")
}
defaultOOR := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Default: 100.0, Max: fptr(10)},
}}
if _, err := s.CreateSet(defaultOOR, ""); err == nil {
t.Error("expected out-of-range default validation error")
}
}
func TestInstanceLifecycle(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
inst := ConfigInstance{
Name: "nominal",
SetID: set.ID,
Values: map[string]any{"voltage": 24.0, "enabled": true},
}
out, err := s.CreateInstance(inst, "")
if err != nil {
t.Fatal(err)
}
if out.Version != 1 {
t.Errorf("instance version: want 1, got %d", out.Version)
}
// Update value -> v2.
out.Values["voltage"] = 36.0
v2, err := s.UpdateInstance(out.ID, out, "bump")
if err != nil {
t.Fatal(err)
}
if v2.Version != 2 {
t.Errorf("instance version after update: want 2, got %d", v2.Version)
}
}
func TestInstanceValidation(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
// voltage is mandatory with a default, so omitting it is OK; but an
// out-of-range value must fail.
bad := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"voltage": 999.0}}
if _, err := s.CreateInstance(bad, ""); err == nil {
t.Error("expected out-of-range value error")
}
// Unknown parameter key must fail.
unknown := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"nope": 1.0}}
if _, err := s.CreateInstance(unknown, ""); err == nil {
t.Error("expected unknown-parameter error")
}
// Unknown set must fail.
missing := ConfigInstance{Name: "x", SetID: "does-not-exist", Values: map[string]any{}}
if _, err := s.CreateInstance(missing, ""); err == nil {
t.Error("expected missing-set error")
}
}
+235
View File
@@ -0,0 +1,235 @@
package controllogic
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/datasource"
)
// writableSource is a minimal in-memory DataSource that records the last value
// written to each signal, so config-apply/read nodes can be tested end-to-end.
type writableSource struct {
mu sync.Mutex
written map[string]any
}
func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} }
func (s *writableSource) Name() string { return "tgt" }
func (s *writableSource) Connect(context.Context) error { return nil }
func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
// Subscribe delivers the signal's last-written value once (if any), so a
// one-shot ReadNow (used by config snapshot) resolves immediately.
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
v, ok := s.written[sig]
s.mu.Unlock()
if ok {
select {
case ch <- datasource.Value{Data: v, Timestamp: time.Now()}:
default:
}
}
return func() {}, nil
}
func (s *writableSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
s.written[signal] = value
return nil
}
func (s *writableSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *writableSource) get(signal string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[signal]
return v, ok
}
// setupConfigEngine wires a broker (with a writable "tgt" source), a confmgr
// store seeded with a set + instance, and an Engine bound to both.
func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()
src := newWritableSource()
brk := broker.New(ctx, log)
brk.Register(src)
cfg, err := confmgr.New(t.TempDir())
if err != nil {
t.Fatal("confmgr.New:", err)
}
set, err := cfg.CreateSet(confmgr.ConfigSet{
Name: "tuning",
Parameters: []confmgr.Parameter{
{Key: "gain", DS: "tgt", Signal: "GAIN", Type: confmgr.TypeFloat, Default: 1.0},
{Key: "offset", DS: "tgt", Signal: "OFFSET", Type: confmgr.TypeFloat, Default: 0.0},
},
}, "")
if err != nil {
t.Fatal("CreateSet:", err)
}
inst, err := cfg.CreateInstance(confmgr.ConfigInstance{
Name: "warm",
SetID: set.ID,
Values: map[string]any{"gain": 2.5, "offset": 10.0},
}, "")
if err != nil {
t.Fatal("CreateInstance:", err)
}
e := NewEngine(ctx, brk, nil, cfg, audit.Nop(), log)
return e, src, inst.ID
}
func TestApplyConfigWritesEveryParam(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.applyConfig(cg, instID)
if got, ok := src.get("GAIN"); !ok || toNum(got) != 2.5 {
t.Errorf("GAIN = %v (ok=%v), want 2.5", got, ok)
}
if got, ok := src.get("OFFSET"); !ok || toNum(got) != 10.0 {
t.Errorf("OFFSET = %v (ok=%v), want 10.0", got, ok)
}
}
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
e, src, _ := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.applyConfig(cg, "does-not-exist")
if len(src.written) != 0 {
t.Errorf("expected no writes, got %v", src.written)
}
}
func TestReadConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 2.5 {
t.Errorf("readConfigParam(gain) = %v (ok=%v), want 2.5", v, ok)
}
// Missing param key.
if _, ok := e.readConfigParam(instID, "nope"); ok {
t.Errorf("readConfigParam(nope) returned ok=true, want false")
}
// Missing instance.
if _, ok := e.readConfigParam("does-not-exist", "gain"); ok {
t.Errorf("readConfigParam(missing instance) returned ok=true, want false")
}
}
func TestWriteConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.writeConfigParam(cg, instID, "gain", 7.5)
// A new revision should now resolve to the written value.
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 7.5 {
t.Errorf("after write, gain = %v (ok=%v), want 7.5", v, ok)
}
inst, err := e.cfg.GetInstance(instID)
if err != nil {
t.Fatal("GetInstance:", err)
}
if inst.Version < 2 {
t.Errorf("instance version = %d, want >= 2 (a new revision)", inst.Version)
}
}
func TestCreateConfigInstance(t *testing.T) {
e, _, srcID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
src, err := e.cfg.GetInstance(srcID)
if err != nil {
t.Fatal("GetInstance:", err)
}
e.createConfigInstance(cg, src.SetID, "cold", srcID)
insts, err := e.cfg.List(confmgr.KindInstance)
if err != nil {
t.Fatal("List:", err)
}
var found *confmgr.ConfigInstance
for i := range insts {
if insts[i].Name == "cold" {
full, err := e.cfg.GetInstance(insts[i].ID)
if err != nil {
t.Fatal("GetInstance:", err)
}
found = &full
}
}
if found == nil {
t.Fatal("created instance 'cold' not found")
}
// Values copied from the source instance.
if got := toNum(found.Values["gain"]); got != 2.5 {
t.Errorf("copied gain = %v, want 2.5", got)
}
}
func TestSnapshotConfig(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
// Seed current live values for the set's target signals.
_ = src.Write(context.Background(), "GAIN", 3.5)
_ = src.Write(context.Background(), "OFFSET", -2.0)
seed, err := e.cfg.GetInstance(instID)
if err != nil {
t.Fatal("GetInstance:", err)
}
e.snapshotConfig(cg, seed.SetID, "snap1")
insts, err := e.cfg.List(confmgr.KindInstance)
if err != nil {
t.Fatal("List:", err)
}
var found *confmgr.ConfigInstance
for i := range insts {
if insts[i].Name == "snap1" {
full, err := e.cfg.GetInstance(insts[i].ID)
if err != nil {
t.Fatal("GetInstance:", err)
}
found = &full
}
}
if found == nil {
t.Fatal("snapshot instance 'snap1' not found")
}
if got := toNum(found.Values["gain"]); got != 3.5 {
t.Errorf("snapshot gain = %v, want 3.5 (current live value)", got)
}
if got := toNum(found.Values["offset"]); got != -2.0 {
t.Errorf("snapshot offset = %v, want -2.0 (current live value)", got)
}
}
+161
View File
@@ -0,0 +1,161 @@
// Minimal 5-field cron parser for control-logic cron triggers.
//
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
// - any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
//
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
// matches when EITHER matches, following Vixie cron semantics.
package controllogic
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule is a parsed 5-field cron specification.
type Schedule struct {
minute uint64 // bitmask 0..59
hour uint64 // bitmask 0..23
dom uint64 // bitmask 1..31
month uint64 // bitmask 1..12
dow uint64 // bitmask 0..6
domStar bool // day-of-month field was "*"
dowStar bool // day-of-week field was "*"
}
// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
func ParseSchedule(spec string) (*Schedule, error) {
fields := strings.Fields(spec)
if len(fields) != 5 {
return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
}
s := &Schedule{}
var err error
if s.minute, err = parseField(fields[0], 0, 59); err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
if s.hour, err = parseField(fields[1], 0, 23); err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
if s.dom, err = parseField(fields[2], 1, 31); err != nil {
return nil, fmt.Errorf("day-of-month: %w", err)
}
if s.month, err = parseField(fields[3], 1, 12); err != nil {
return nil, fmt.Errorf("month: %w", err)
}
if s.dow, err = parseDOW(fields[4]); err != nil {
return nil, fmt.Errorf("day-of-week: %w", err)
}
s.domStar = strings.TrimSpace(fields[2]) == "*"
s.dowStar = strings.TrimSpace(fields[4]) == "*"
return s, nil
}
// Match reports whether t (truncated to the minute) satisfies the schedule.
func (s *Schedule) Match(t time.Time) bool {
if s.minute&bit(t.Minute()) == 0 {
return false
}
if s.hour&bit(t.Hour()) == 0 {
return false
}
if s.month&bit(int(t.Month())) == 0 {
return false
}
domMatch := s.dom&bit(t.Day()) != 0
dowMatch := s.dow&bit(int(t.Weekday())) != 0
// Vixie semantics: when both day fields are restricted, match either.
switch {
case s.domStar && s.dowStar:
return true
case s.domStar:
return dowMatch
case s.dowStar:
return domMatch
default:
return domMatch || dowMatch
}
}
func bit(n int) uint64 { return uint64(1) << uint(n) }
func parseDOW(field string) (uint64, error) {
// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
mask, err := parseField(field, 0, 7)
if err != nil {
return 0, err
}
if mask&bit(7) != 0 {
mask = (mask &^ bit(7)) | bit(0)
}
return mask, nil
}
func parseField(field string, lo, hi int) (uint64, error) {
var mask uint64
for _, part := range strings.Split(field, ",") {
m, err := parsePart(strings.TrimSpace(part), lo, hi)
if err != nil {
return 0, err
}
mask |= m
}
if mask == 0 {
return 0, fmt.Errorf("empty field %q", field)
}
return mask, nil
}
func parsePart(part string, lo, hi int) (uint64, error) {
if part == "" {
return 0, fmt.Errorf("empty term")
}
step := 1
rangePart := part
if idx := strings.IndexByte(part, '/'); idx >= 0 {
rangePart = part[:idx]
st, err := strconv.Atoi(part[idx+1:])
if err != nil || st < 1 {
return 0, fmt.Errorf("bad step %q", part[idx+1:])
}
step = st
}
start, end := lo, hi
if rangePart != "*" {
if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
a, err1 := strconv.Atoi(rangePart[:idx])
b, err2 := strconv.Atoi(rangePart[idx+1:])
if err1 != nil || err2 != nil {
return 0, fmt.Errorf("bad range %q", rangePart)
}
start, end = a, b
} else {
v, err := strconv.Atoi(rangePart)
if err != nil {
return 0, fmt.Errorf("bad value %q", rangePart)
}
start, end = v, v
}
}
if start < lo || end > hi || start > end {
return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
}
var mask uint64
for v := start; v <= end; v += step {
mask |= bit(v)
}
return mask, nil
}
+92
View File
@@ -0,0 +1,92 @@
package controllogic
import (
"testing"
"time"
)
func mustSched(t *testing.T, spec string) *Schedule {
t.Helper()
s, err := ParseSchedule(spec)
if err != nil {
t.Fatalf("ParseSchedule(%q): %v", spec, err)
}
return s
}
func TestCronEveryMinute(t *testing.T) {
s := mustSched(t, "* * * * *")
if !s.Match(time.Date(2026, 6, 18, 12, 34, 0, 0, time.UTC)) {
t.Error("* * * * * should match any time")
}
}
func TestCronSpecificTime(t *testing.T) {
s := mustSched(t, "30 9 * * *")
if !s.Match(time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)) {
t.Error("should match 09:30")
}
if s.Match(time.Date(2026, 6, 18, 9, 31, 0, 0, time.UTC)) {
t.Error("should not match 09:31")
}
}
func TestCronStep(t *testing.T) {
s := mustSched(t, "*/15 * * * *")
for _, m := range []int{0, 15, 30, 45} {
if !s.Match(time.Date(2026, 6, 18, 1, m, 0, 0, time.UTC)) {
t.Errorf("*/15 should match minute %d", m)
}
}
if s.Match(time.Date(2026, 6, 18, 1, 7, 0, 0, time.UTC)) {
t.Error("*/15 should not match minute 7")
}
}
func TestCronRangeAndList(t *testing.T) {
s := mustSched(t, "0 9-17 * * 1,2,3,4,5")
// 2026-06-18 is a Thursday (weekday 4).
if !s.Match(time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC)) {
t.Error("should match Thursday 10:00")
}
if s.Match(time.Date(2026, 6, 18, 18, 0, 0, 0, time.UTC)) {
t.Error("should not match 18:00 (out of 9-17)")
}
// 2026-06-20 is a Saturday (weekday 6).
if s.Match(time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)) {
t.Error("should not match Saturday")
}
}
func TestCronDOWSunday7(t *testing.T) {
s := mustSched(t, "0 0 * * 7")
// 2026-06-21 is a Sunday.
if !s.Match(time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)) {
t.Error("7 should mean Sunday")
}
}
func TestCronDomOrDow(t *testing.T) {
// When both day fields restricted, match either (Vixie semantics).
s := mustSched(t, "0 0 1 * 5")
// 2026-06-01 is a Monday — matches via day-of-month=1.
if !s.Match(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) {
t.Error("should match day-of-month 1")
}
// 2026-06-19 is a Friday (weekday 5) — matches via day-of-week.
if !s.Match(time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)) {
t.Error("should match Friday")
}
// 2026-06-18 Thursday, not the 1st — no match.
if s.Match(time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)) {
t.Error("should not match Thursday the 18th")
}
}
func TestCronInvalid(t *testing.T) {
for _, bad := range []string{"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "a * * * *", "*/0 * * * *"} {
if _, err := ParseSchedule(bad); err == nil {
t.Errorf("ParseSchedule(%q) should error", bad)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package controllogic
import (
"context"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
)
// DebugEvent reports a single node execution for the live debug view. Value is
// only meaningful when HasValue is true (e.g. an action.write's written value or
// a flow.if branch as 0/1); otherwise the event just marks the node as active.
type DebugEvent struct {
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value any `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"` // unix millis
}
// DebugObserver receives node-execution events from running (and simulated)
// graphs. The server implements it; Observe must not block (the hub fans out
// drop-on-full so a slow editor never stalls the engine).
type DebugObserver interface {
Observe(DebugEvent)
}
// debugObsBox wraps a DebugObserver so atomic.Value always sees one type.
type debugObsBox struct{ o DebugObserver }
// SetDebugObserver installs the sink for node-execution events. Safe to call
// once at startup; read lock-free by running flows.
func (e *Engine) SetDebugObserver(o DebugObserver) {
e.debugObs.Store(debugObsBox{o: o})
}
// SetDebugWatch replaces the set of graph ids with at least one live debug
// subscriber. emitDebug short-circuits for graphs absent from this set, so the
// common (nobody watching) case costs a single atomic load. The hub owns the
// map and must not mutate it after publishing (it is read without a lock).
func (e *Engine) SetDebugWatch(ids map[string]bool) {
e.debugWatch.Store(ids)
}
// registerFire records a compiled graph's manual-fire channel under its route id
// (live graph id or simulate sandbox id).
func (e *Engine) registerFire(id string, ch chan string) {
e.fireMu.Lock()
e.fireChs[id] = ch
e.fireMu.Unlock()
}
// unregisterFire removes id's fire channel, but only if it still points at ch —
// so a newer generation that reused the same id (live reload) is not clobbered
// by the old generation's teardown.
func (e *Engine) unregisterFire(id string, ch chan string) {
e.fireMu.Lock()
if e.fireChs[id] == ch {
delete(e.fireChs, id)
}
e.fireMu.Unlock()
}
// FireTrigger asks the graph behind a debug route (graphID) to run triggerID's
// flow now, as if the trigger had fired. Non-blocking and best-effort: returns
// false if the route is gone or its fire buffer is full. The receiving graph
// validates that triggerID is actually one of its trigger nodes.
func (e *Engine) FireTrigger(graphID, triggerID string) bool {
e.fireMu.Lock()
ch := e.fireChs[graphID]
e.fireMu.Unlock()
if ch == nil || triggerID == "" {
return false
}
select {
case ch <- triggerID:
return true
default:
return false
}
}
// emitDebug reports a node execution to the observer when the graph is watched
// (or the graph is a simulate sandbox, which is always its own subscriber).
func (cg *compiledGraph) emitDebug(nodeID string, value Value, hasValue bool) {
if !cg.alwaysDebug {
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
if !w[cg.id] {
return
}
}
box, _ := cg.engine.debugObs.Load().(debugObsBox)
if box.o == nil {
return
}
box.o.Observe(DebugEvent{
GraphID: cg.id,
NodeID: nodeID,
Value: value,
HasValue: hasValue,
TS: time.Now().UnixMilli(),
})
}
// StartSimulate runs g in a throwaway sandbox generation: real side effects are
// suppressed (data-source writes, config mutations, dialogs) but local vars,
// triggers, timers and the flow itself execute normally, emitting debug events
// the editor can visualise. It is independent of Reload's live generation. The
// returned stop func cancels the sandbox and waits for its goroutines to drain;
// it is safe to call more than once.
func (e *Engine) StartSimulate(g Graph) func() {
cg := compile(g)
cg.engine = e
cg.dryRun = true
cg.alwaysDebug = true
ctx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
cg.genCtx = ctx
cg.wg = wg
// Sandbox-local live cache so simulate reads don't disturb the live engine.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range cg.refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic simulate: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
e.registerFire(cg.id, cg.fireCh)
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
for _, u := range unsubs {
u()
}
e.unregisterFire(cg.id, cg.fireCh)
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case u := <-updates:
val := toNum(u.Value.Data)
key := refKey(u.Ref.DS, u.Ref.Name)
e.liveMu.Lock()
e.live[key] = val
e.liveMu.Unlock()
cg.onSignal(key, val)
case <-ctx.Done():
return
}
}
}()
cg.startTriggers()
var once sync.Once
return func() {
once.Do(func() {
cancel()
wg.Wait()
})
}
}
+196
View File
@@ -0,0 +1,196 @@
package controllogic
import (
"context"
"sync"
"testing"
"time"
)
// fakeObserver records every DebugEvent it receives, in order.
type fakeObserver struct {
mu sync.Mutex
events []DebugEvent
}
func (f *fakeObserver) Observe(ev DebugEvent) {
f.mu.Lock()
f.events = append(f.events, ev)
f.mu.Unlock()
}
func (f *fakeObserver) snapshot() []DebugEvent {
f.mu.Lock()
defer f.mu.Unlock()
return append([]DebugEvent(nil), f.events...)
}
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.events) - 1; i >= 0; i-- {
if f.events[i].NodeID == nodeID {
return f.events[i], true
}
}
return DebugEvent{}, false
}
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
// to "tgt:OUT". Used by both the observe and simulate tests.
func thresholdWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
// observer saw the trigger fire and the write node's value — and that the real
// data-source write still happened (live mode, not dry-run).
func TestDebugObserverCapturesNodeValues(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{"g1": true})
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
events := obs.snapshot()
if len(events) == 0 {
t.Fatal("observer captured no events")
}
// Trigger then write must both be reported.
if _, ok := obs.last("t"); !ok {
t.Error("no event for trigger node 't'")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "g1" {
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
}
// Live mode: the real write went through.
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
}
}
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
// FireTrigger forces a run that would otherwise never happen.
func manualWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
// never fires on its own, routed through the simulate sandbox.
func TestFireTriggerForcesRun(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(manualWriteGraph("sim"))
defer stop()
// Nothing should have run yet — the threshold trigger has no signal.
time.Sleep(50 * time.Millisecond)
if _, ok := obs.last("w"); ok {
t.Fatal("write node ran before any manual fire")
}
if !e.FireTrigger("sim", "t") {
t.Fatal("FireTrigger returned false for an active simulate route")
}
// Poll for the write node event (the flow runs on its own goroutine).
var wEv DebugEvent
for i := 0; i < 100; i++ {
if ev, ok := obs.last("w"); ok {
wEv = ev
break
}
time.Sleep(10 * time.Millisecond)
}
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
// An unknown route id must be a no-op (best-effort, returns false).
if e.FireTrigger("does-not-exist", "t") {
t.Error("FireTrigger returned true for an unknown route")
}
}
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
// from the watch set, so unwatched live graphs cost nothing.
func TestDebugWatchGatesEmission(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{}) // nobody watching
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
if got := obs.snapshot(); len(got) != 0 {
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
}
}
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
func TestSimulateSuppressesWrites(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
// Deliberately leave the watch set empty: simulate must emit regardless.
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(thresholdWriteGraph("sim"))
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
stop()
if _, ok := src.get("OUT"); ok {
t.Errorf("simulate performed a real write to OUT, want none")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("simulate produced no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "sim" {
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
package controllogic
import (
"math"
"testing"
"github.com/uopi/uopi/internal/confmgr"
)
func TestFormatAny(t *testing.T) {
cases := []struct {
in any
want string
}{
{3.5, "3.5"},
{float64(42), "42"},
{"hello", "hello"},
{true, "true"},
{int64(7), "7"},
}
for _, c := range cases {
if got := formatAny(c.in); got != c.want {
t.Errorf("formatAny(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTestThreshold(t *testing.T) {
cases := []struct {
val float64
op string
cmp float64
want bool
}{
{1, "<", 2, true},
{3, "<", 2, false},
{2, ">=", 2, true},
{1, ">=", 2, false},
{2, "<=", 2, true},
{3, "<=", 2, false},
{2, "==", 2, true},
{2, "!=", 3, true},
{5, ">", 2, true}, // default branch
{1, "", 0, true}, // empty op → ">"
{math.NaN(), "<", 1, false}, // NaN never satisfies
}
for _, c := range cases {
if got := testThreshold(c.val, c.op, c.cmp); got != c.want {
t.Errorf("testThreshold(%v,%q,%v) = %v, want %v", c.val, c.op, c.cmp, got, c.want)
}
}
}
func TestParseFloat(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"3.14", 3.14},
{" 10 ", 10},
{"", 0},
{"not-a-number", 0},
}
for _, c := range cases {
if got := parseFloat(c.in); got != c.want {
t.Errorf("parseFloat(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestCoerceParamValue(t *testing.T) {
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeInt}, 3.9); v != int64(3) {
t.Errorf("int coerce = %v, want 3", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 0); v != false {
t.Errorf("bool 0 = %v, want false", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 1); v != true {
t.Errorf("bool 1 = %v, want true", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeString}, 2.5); v != "2.5" {
t.Errorf("string coerce = %v, want \"2.5\"", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeFloat}, 1.25); v != 1.25 {
t.Errorf("float coerce = %v, want 1.25", v)
}
}
func TestSign(t *testing.T) {
if signOf(5) != 1 || signOf(-5) != -1 || signOf(0) != 0 {
t.Errorf("sign mismatch: %d %d %d", signOf(5), signOf(-5), signOf(0))
}
}
+180
View File
@@ -0,0 +1,180 @@
package controllogic
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// pushSource is a DataSource whose Subscribe captures the broker's delivery
// channel per signal, letting a test push successive values to drive level/edge
// triggers. Writes are recorded so action.write effects can be asserted.
type pushSource struct {
mu sync.Mutex
chans map[string]chan<- datasource.Value
written map[string]any
}
func newPushSource() *pushSource {
return &pushSource{chans: map[string]chan<- datasource.Value{}, written: map[string]any{}}
}
func (s *pushSource) Name() string { return "tgt" }
func (s *pushSource) Connect(context.Context) error { return nil }
func (s *pushSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *pushSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
func (s *pushSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
s.chans[sig] = ch
s.mu.Unlock()
return func() {}, nil
}
func (s *pushSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
s.written[signal] = value
s.mu.Unlock()
return nil
}
func (s *pushSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *pushSource) chanFor(sig string) (chan<- datasource.Value, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ch, ok := s.chans[sig]
return ch, ok
}
func (s *pushSource) get(sig string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[sig]
return v, ok
}
// TestEngineReloadThresholdTrigger drives a real Reload generation: a
// trigger.threshold on tgt:IN (>5) wired to an action.write of 42 to tgt:OUT.
// Pushing IN below then above the threshold must fire exactly the rising edge.
func TestEngineReloadThresholdTrigger(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "watchdog",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.threshold", Params: map[string]string{
"signal": "tgt:IN", "op": ">", "value": "5",
}},
{ID: "a1", Kind: "action.write", Params: map[string]string{
"target": "tgt:OUT", "expr": "42",
}},
},
Wires: []Wire{{From: "t1", To: "a1"}},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
// t.Context() is cancelled at test cleanup, tearing the generation down.
// Wait until the engine has subscribed to tgt:IN.
var ch chan<- datasource.Value
waitFor(t, time.Second, func() bool {
c, ok := src.chanFor("IN")
if ok {
ch = c
}
return ok
})
// Below threshold: no fire (prev state seeds to false).
ch <- datasource.Value{Data: 0.0, Timestamp: time.Now()}
// Rising edge above threshold: fires the action.
ch <- datasource.Value{Data: 10.0, Timestamp: time.Now()}
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT")
return ok && toNum(v) == 42
})
if v, ok := src.get("OUT"); !ok || toNum(v) != 42 {
t.Fatalf("OUT = %v (ok=%v), want 42 after rising-edge trigger", v, ok)
}
}
// TestEngineReloadTimerIfWrite covers the timer trigger, startTriggers, and a
// flow.if then-branch driving an action.write.
func TestEngineReloadTimerIfWrite(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "ticker",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.timer", Params: map[string]string{"interval": "50"}},
{ID: "if1", Kind: "flow.if", Params: map[string]string{"cond": "2 > 1"}},
{ID: "a1", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT2", "expr": "7"}},
},
Wires: []Wire{
{From: "t1", To: "if1"},
{From: "if1", FromPort: "then", To: "a1"},
},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT2")
return ok && toNum(v) == 7
})
}
// waitFor polls cond until it returns true or the deadline elapses.
func waitFor(t *testing.T, d time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
if !cond() {
t.Fatalf("condition not met within %s", d)
}
}
+242
View File
@@ -0,0 +1,242 @@
package controllogic
import (
"context"
"reflect"
"sync"
"testing"
)
// runFlowOnce compiles g, wires a minimal engine/context onto the compiled graph
// (enough for the run/follow path and the lock-free emitDebug guard), then drives
// the flow from triggerID synchronously and returns the compiled graph so callers
// can inspect locals.
func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph {
t.Helper()
cg := compile(g)
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
switch ds {
case "local":
return cg.getLocal(name)
default:
return 0.0
}
}
ctx := &runCtx{fired: triggerID, resolve: R}
cg.follow(triggerID, "out", ctx)
return cg
}
func TestArrayPushNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}},
},
Wires: []Wire{{From: "t", To: "p"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) {
t.Fatalf("after push buf = %#v", got)
}
}
func TestArrayClearNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}},
},
Wires: []Wire{{From: "t", To: "c"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) {
t.Fatalf("after clear buf = %#v", got)
}
}
func TestArraySetNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[0,0,0]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "buf", "index": "1", "expr": "9"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 9.0, 0.0}) {
t.Fatalf("after set buf = %#v", got)
}
}
func TestArrayRemoveNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[10,20,30]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "r", Kind: "action.array.remove", Params: map[string]string{"array": "buf", "index": "-1"}},
},
Wires: []Wire{{From: "t", To: "r"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{10.0, 20.0}) {
t.Fatalf("after remove buf = %#v", got)
}
}
func TestArrayPopNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "p", Kind: "action.array.pop", Params: map[string]string{"array": "buf"}},
},
Wires: []Wire{{From: "t", To: "p"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0}) {
t.Fatalf("after pop buf = %#v", got)
}
}
// TestArraySetNodeNested verifies set with a comma-separated path mutates a nested
// sub-array, and (crucially) that setPath does not mutate the previously stored slice
// in place — the stored value must be replaced by a fresh tree.
func TestArraySetNodeNested(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[1,2],[3,4]]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,1", "expr": "9"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := compile(g)
before := cg.getLocal("grid")
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
cg.follow("t", "out", &runCtx{fired: "t", resolve: R})
want := []Value{[]Value{1.0, 9.0}, []Value{3.0, 4.0}}
if got := cg.getLocal("grid"); !reflect.DeepEqual(got, want) {
t.Fatalf("after nested set grid = %#v", got)
}
// The pre-mutation snapshot must be untouched (no shared-backing in-place write).
if !reflect.DeepEqual(before, []Value{[]Value{1.0, 2.0}, []Value{3.0, 4.0}}) {
t.Fatalf("setPath mutated the prior stored value in place: %#v", before)
}
}
// TestArraySetNodeConcurrentNoRace drives many concurrent set+read flows against the
// same nested-array local; with -race it guards the setPath copy-on-descend fix.
func TestArraySetNodeConcurrentNoRace(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[0,0],[0,0]]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,0", "expr": "1"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := compile(g)
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(2)
go func() { defer wg.Done(); cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) }()
go func() {
defer wg.Done()
// Read the inner element concurrently; with the in-place setPath bug the
// writer mutates this same sub-array backing, producing a data race.
if arr, ok := cg.getLocal("grid").([]Value); ok && len(arr) > 0 {
if sub, ok := arr[0].([]Value); ok && len(sub) > 0 {
_ = sub[0]
}
}
}()
}
wg.Wait()
}
func TestCompileInitsLocalsFromDecls(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{
{Name: "count", Type: "number", Initial: "7"},
{Name: "flag", Type: "bool", Initial: "true"},
{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4},
},
}
cg := compile(g)
if got := cg.getLocal("count"); got != Value(7.0) {
t.Fatalf("count = %#v", got)
}
if got := cg.getLocal("flag"); got != Value(1.0) {
t.Fatalf("flag = %#v", got)
}
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
t.Fatalf("buf = %#v", got)
}
}
func TestSetLocalAppliesSizing(t *testing.T) {
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
{Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2},
}}
cg := compile(g)
cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0})
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) {
t.Fatalf("sized buf = %#v", got)
}
}
func TestResolverReturnsLocalValue(t *testing.T) {
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
{Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"},
}}
cg := compile(g)
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
if v := EvalExpr("buf[1]", R); v != 20 {
t.Fatalf("buf[1] = %v", v)
}
if v := EvalExpr("sum(buf)", R); v != 30 {
t.Fatalf("sum(buf) = %v", v)
}
}
func TestEmitDebugAcceptsArray(t *testing.T) {
g := Graph{ID: "g", Name: "n"}
cg := compile(g)
cg.engine = &Engine{} // no observer installed; emitDebug must not panic
cg.emitDebug("x", []Value{1.0, 2.0}, true)
cg.emitDebug("x", 3.0, true)
}
+833
View File
@@ -0,0 +1,833 @@
// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts.
//
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
// parentheses, array literals ([a, b, c]), postfix indexing (arr[i]), and a set
// of math + array functions. Two kinds of variable reference are resolved live:
//
// {ds:name} a data-source signal value (brace content split on FIRST ':').
// bareIdent a graph-local state variable (data source "local").
//
// Values are either a scalar (float64; booleans 1/0) or an array ([]Value). The
// evaluator never uses reflection or eval; it walks a parsed AST against a
// caller-supplied Resolver.
package controllogic
import (
"fmt"
"math"
"strconv"
"strings"
"sync"
)
// Resolver returns the current value of a signal/local reference.
type Resolver func(ds, name string) Value
// RefLite identifies one signal/local reference read by an expression.
type RefLite struct {
DS string
Name string
}
// ── AST ──────────────────────────────────────────────────────────────────────
type exprNode interface{ eval(R Resolver) Value }
type numNode struct{ v float64 }
type sigNode struct{ ds, name string }
type varNode struct{ name string }
type arrNode struct{ items []exprNode }
type indexNode struct{ a, i exprNode }
type unNode struct {
op string
a exprNode
}
type binNode struct {
op string
a, b exprNode
}
type ternNode struct{ c, a, b exprNode }
type callNode struct {
fn string
args []exprNode
}
func mustNum(v Value) float64 {
f, err := asNum(v)
if err != nil {
panic(err)
}
return f
}
func mustArr(v Value) []Value {
a, err := asArr(v)
if err != nil {
panic(err)
}
return a
}
func (n numNode) eval(R Resolver) Value { return n.v }
func (n sigNode) eval(R Resolver) Value { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) Value { return R("local", n.name) }
func (n arrNode) eval(R Resolver) Value {
out := make([]Value, len(n.items))
for i, it := range n.items {
out[i] = it.eval(R)
}
return out
}
func (n indexNode) eval(R Resolver) Value {
arr := mustArr(n.a.eval(R))
k, err := idxResolve(mustNum(n.i.eval(R)), len(arr))
if err != nil {
panic(err)
}
return arr[k]
}
func (n unNode) eval(R Resolver) Value {
if n.op == "-" {
return -mustNum(n.a.eval(R))
}
if mustNum(n.a.eval(R)) == 0 {
return 1.0
}
return 0.0
}
func (n ternNode) eval(R Resolver) Value {
if mustNum(n.c.eval(R)) != 0 {
return n.a.eval(R)
}
return n.b.eval(R)
}
func (n callNode) eval(R Resolver) Value {
args := make([]Value, len(n.args))
for i, a := range n.args {
args[i] = a.eval(R)
}
// min/max: scalar-variadic OR single-array form.
if n.fn == "min" || n.fn == "max" {
if len(args) == 1 {
if arr, ok := args[0].([]Value); ok {
return reduceMinMax(n.fn, arr)
}
}
nums := make([]Value, len(args))
copy(nums, args)
return reduceMinMax(n.fn, nums)
}
if af, ok := arrFuncs[n.fn]; ok {
return af(args)
}
if sf, ok := scalarFuncs[n.fn]; ok {
nums := make([]float64, len(args))
for i, a := range args {
nums[i] = mustNum(a)
}
return sf(nums)
}
panic(fmt.Errorf("unknown function %q", n.fn))
}
func (n binNode) eval(R Resolver) Value {
a, b := mustNum(n.a.eval(R)), mustNum(n.b.eval(R))
switch n.op {
case "+":
return a + b
case "-":
return a - b
case "*":
return a * b
case "/":
return a / b
case "%":
return math.Mod(a, b)
case "<":
return boolf(a < b)
case "<=":
return boolf(a <= b)
case ">":
return boolf(a > b)
case ">=":
return boolf(a >= b)
case "==":
return boolf(a == b)
case "!=":
return boolf(a != b)
case "&&":
return boolf(a != 0 && b != 0)
case "||":
return boolf(a != 0 || b != 0)
}
panic(fmt.Errorf("unknown operator %q", n.op))
}
func boolf(b bool) float64 {
if b {
return 1
}
return 0
}
func reduceMinMax(fn string, arr []Value) Value {
if len(arr) == 0 {
if fn == "min" {
return math.Inf(1)
}
return math.Inf(-1)
}
m := mustNum(arr[0])
for _, x := range arr[1:] {
v := mustNum(x)
if fn == "min" {
m = math.Min(m, v)
} else {
m = math.Max(m, v)
}
}
return m
}
// ── Functions ────────────────────────────────────────────────────────────────
var scalarFuncs = map[string]func([]float64) float64{
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
"round": func(a []float64) float64 { return math.Round(a[0]) },
"sign": func(a []float64) float64 { return float64(signOf(a[0])) },
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
"log": func(a []float64) float64 { return math.Log(a[0]) },
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
"sin": func(a []float64) float64 { return math.Sin(a[0]) },
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
}
var arrFuncs = map[string]func([]Value) Value{
"len": func(a []Value) Value { return float64(len(mustArr(a[0]))) },
"sum": func(a []Value) Value {
s := 0.0
for _, x := range mustArr(a[0]) {
s += mustNum(x)
}
return s
},
"mean": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return 0.0
}
s := 0.0
for _, x := range r {
s += mustNum(x)
}
return s / float64(len(r))
},
"slice": func(a []Value) Value {
r := mustArr(a[0])
s := 0
e := len(r)
if len(a) > 1 {
s = clampIdx(int(mustNum(a[1])), len(r))
}
if len(a) > 2 {
e = clampIdx(int(mustNum(a[2])), len(r))
}
if s > e {
s = e
}
out := make([]Value, 0, e-s)
out = append(out, r[s:e]...)
return out
},
"concat": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), mustArr(a[1])...) },
"reverse": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...); reverse(r); return r },
"sort": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
sortNum(r)
return r
},
"scale": func(a []Value) Value {
r := mustArr(a[0])
k := mustNum(a[1])
out := make([]Value, len(r))
for i, x := range r {
out[i] = mustNum(x) * k
}
return out
},
"add": func(a []Value) Value {
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x + y })
},
"sub": func(a []Value) Value {
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x - y })
},
"push": func(a []Value) Value {
return append(append([]Value{}, mustArr(a[0])...), a[1])
},
"set": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k, err := idxResolve(mustNum(a[1]), len(r))
if err != nil {
panic(err)
}
r[k] = a[2]
return r
},
"insert": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k := int(mustNum(a[1]))
if k < 0 {
k = 0
}
if k > len(r) {
k = len(r)
}
r = append(r, nil)
copy(r[k+1:], r[k:])
r[k] = a[2]
return r
},
"remove": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k, err := idxResolve(mustNum(a[1]), len(r))
if err != nil {
panic(err)
}
return append(r[:k], r[k+1:]...)
},
"pop": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return []Value{}
}
return append([]Value{}, r[:len(r)-1]...)
},
"shift": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return []Value{}
}
return append([]Value{}, r[1:]...)
},
"indexOf": func(a []Value) Value {
r := mustArr(a[0])
for i, x := range r {
if valEq(x, a[1]) {
return float64(i)
}
}
return -1.0
},
"contains": func(a []Value) Value {
r := mustArr(a[0])
for _, x := range r {
if valEq(x, a[1]) {
return 1.0
}
}
return 0.0
},
"fill": func(a []Value) Value {
n := int(mustNum(a[0]))
if n < 0 {
n = 0
}
out := make([]Value, n)
for i := range out {
out[i] = a[1]
}
return out
},
}
func signOf(x float64) int {
switch {
case x > 0:
return 1
case x < 0:
return -1
default:
return 0
}
}
func clampIdx(i, length int) int {
if i < 0 {
i = length + i
}
if i < 0 {
i = 0
}
if i > length {
i = length
}
return i
}
func reverse(r []Value) {
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
}
func sortNum(r []Value) {
for i := 1; i < len(r); i++ {
for j := i; j > 0 && mustNum(r[j-1]) > mustNum(r[j]); j-- {
r[j-1], r[j] = r[j], r[j-1]
}
}
}
func zipNum(x, y []Value, f func(a, b float64) float64) []Value {
n := len(x)
if len(y) < n {
n = len(y)
}
out := make([]Value, 0, n)
for i := 0; i < n; i++ {
out = append(out, f(mustNum(x[i]), mustNum(y[i])))
}
return out
}
func valEq(a, b Value) bool {
af, aok := a.(float64)
bf, bok := b.(float64)
return aok && bok && af == bf
}
// ── Tokenizer ────────────────────────────────────────────────────────────────
type tok struct {
k string
v string
}
func tokenize(src string) ([]tok, error) {
var toks []tok
two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true}
r := []rune(src)
i := 0
for i < len(r) {
c := r[i]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
i++
continue
case c == '{':
end := -1
for j := i + 1; j < len(r); j++ {
if r[j] == '}' {
end = j
break
}
}
if end < 0 {
return nil, fmt.Errorf("unterminated { in expression")
}
toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])})
i = end + 1
continue
}
if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) {
j := i + 1
for j < len(r) && (isDigit(r[j]) || r[j] == '.') {
j++
}
toks = append(toks, tok{k: "num", v: string(r[i:j])})
i = j
continue
}
if isIdentStart(c) {
j := i + 1
for j < len(r) && isIdentPart(r[j]) {
j++
}
toks = append(toks, tok{k: "ident", v: string(r[i:j])})
i = j
continue
}
if i+1 < len(r) {
pair := string(r[i : i+2])
if two[pair] {
toks = append(toks, tok{k: pair})
i += 2
continue
}
}
if strings.ContainsRune("+-*/%<>!()?:,[]", c) {
toks = append(toks, tok{k: string(c)})
i++
continue
}
return nil, fmt.Errorf("unexpected character %q in expression", string(c))
}
return toks, nil
}
func isDigit(c rune) bool { return c >= '0' && c <= '9' }
func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }
func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) }
// ── Parser (recursive descent) ────────────────────────────────────────────────
type parser struct {
toks []tok
p int
}
func (ps *parser) peek() (tok, bool) {
if ps.p < len(ps.toks) {
return ps.toks[ps.p], true
}
return tok{}, false
}
func (ps *parser) eat(k string) (tok, error) {
if ps.p >= len(ps.toks) {
return tok{}, fmt.Errorf("unexpected end of expression")
}
t := ps.toks[ps.p]
if k != "" && t.k != k {
return tok{}, fmt.Errorf("expected %q in expression", k)
}
ps.p++
return t, nil
}
func parse(src string) (exprNode, error) {
toks, err := tokenize(src)
if err != nil {
return nil, err
}
ps := &parser{toks: toks}
root, err := ps.ternary()
if err != nil {
return nil, err
}
if ps.p < len(ps.toks) {
return nil, fmt.Errorf("trailing tokens in expression")
}
return root, nil
}
func (ps *parser) atom() (exprNode, error) {
t, ok := ps.peek()
if !ok {
return nil, fmt.Errorf("unexpected end of expression")
}
switch t.k {
case "num":
ps.eat("")
v, err := strconv.ParseFloat(t.v, 64)
if err != nil {
return nil, fmt.Errorf("bad number %q", t.v)
}
return numNode{v: v}, nil
case "[":
ps.eat("[")
var items []exprNode
if nx, ok := ps.peek(); ok && nx.k != "]" {
a, err := ps.ternary()
if err != nil {
return nil, err
}
items = append(items, a)
for {
nx2, ok := ps.peek()
if !ok || nx2.k != "," {
break
}
ps.eat(",")
a, err := ps.ternary()
if err != nil {
return nil, err
}
items = append(items, a)
}
}
if _, err := ps.eat("]"); err != nil {
return nil, err
}
return arrNode{items: items}, nil
case "sig":
ps.eat("")
idx := strings.IndexByte(t.v, ':')
if idx < 0 {
return sigNode{ds: t.v, name: ""}, nil
}
return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil
case "ident":
ps.eat("")
id := t.v
if id == "true" {
return numNode{v: 1}, nil
}
if id == "false" {
return numNode{v: 0}, nil
}
if nx, ok := ps.peek(); ok && nx.k == "(" {
ps.eat("(")
var args []exprNode
if nx2, ok := ps.peek(); ok && nx2.k != ")" {
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
for {
nx3, ok := ps.peek()
if !ok || nx3.k != "," {
break
}
ps.eat(",")
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
}
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
if !knownFunc(id) {
return nil, fmt.Errorf("unknown function %q", id)
}
return callNode{fn: id, args: args}, nil
}
return varNode{name: id}, nil
case "(":
ps.eat("(")
e, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
return e, nil
}
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
}
func knownFunc(id string) bool {
if id == "min" || id == "max" {
return true
}
if _, ok := arrFuncs[id]; ok {
return true
}
_, ok := scalarFuncs[id]
return ok
}
func (ps *parser) primary() (exprNode, error) {
n, err := ps.atom()
if err != nil {
return nil, err
}
for {
nx, ok := ps.peek()
if !ok || nx.k != "[" {
return n, nil
}
ps.eat("[")
i, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat("]"); err != nil {
return nil, err
}
n = indexNode{a: n, i: i}
}
}
func (ps *parser) unary() (exprNode, error) {
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
ps.eat("")
a, err := ps.unary()
if err != nil {
return nil, err
}
return unNode{op: t.k, a: a}, nil
}
return ps.primary()
}
func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) {
a, err := next()
if err != nil {
return nil, err
}
for {
t, ok := ps.peek()
if !ok || !contains(ops, t.k) {
return a, nil
}
op, _ := ps.eat("")
b, err := next()
if err != nil {
return nil, err
}
a = binNode{op: op.k, a: a, b: b}
}
}
func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") }
func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") }
func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") }
func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") }
func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") }
func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") }
func (ps *parser) ternary() (exprNode, error) {
c, err := ps.or()
if err != nil {
return nil, err
}
if t, ok := ps.peek(); ok && t.k == "?" {
ps.eat("?")
a, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(":"); err != nil {
return nil, err
}
b, err := ps.ternary()
if err != nil {
return nil, err
}
return ternNode{c: c, a: a, b: b}, nil
}
return c, nil
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ── Cache + public API ─────────────────────────────────────────────────────────
type cacheEntry struct {
node exprNode
err error
}
var (
cacheMu sync.Mutex
cache = map[string]cacheEntry{}
)
func parseCached(src string) (exprNode, error) {
cacheMu.Lock()
e, ok := cache[src]
cacheMu.Unlock()
if ok {
return e.node, e.err
}
n, err := parse(src)
cacheMu.Lock()
cache[src] = cacheEntry{node: n, err: err}
cacheMu.Unlock()
return n, err
}
// EvalValue evaluates an expression, returning the full Value (number or array).
// Returns NaN on parse/eval failure.
func EvalValue(src string, resolve Resolver) Value {
n, err := parseCached(src)
if err != nil {
return math.NaN()
}
return safeEval(n, resolve)
}
func safeEval(n exprNode, resolve Resolver) (out Value) {
defer func() {
if recover() != nil {
out = math.NaN()
}
}()
return n.eval(resolve)
}
// EvalExpr evaluates an expression to a scalar; returns NaN on parse/eval
// failure OR when the result is an array.
func EvalExpr(src string, resolve Resolver) float64 {
v := EvalValue(src, resolve)
if f, ok := v.(float64); ok {
return f
}
return math.NaN()
}
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN scalar.
func EvalBool(src string, resolve Resolver) bool {
v := EvalExpr(src, resolve)
return !math.IsNaN(v) && v != 0
}
// CollectRefs returns every signal/local reference an expression reads.
func CollectRefs(src string) []RefLite {
root, err := parseCached(src)
if err != nil {
return nil
}
var out []RefLite
seen := map[string]bool{}
add := func(ds, name string) {
k := ds + "\x00" + name
if !seen[k] {
seen[k] = true
out = append(out, RefLite{DS: ds, Name: name})
}
}
var walk func(n exprNode)
walk = func(n exprNode) {
switch t := n.(type) {
case sigNode:
add(t.ds, t.name)
case varNode:
add("local", t.name)
case arrNode:
for _, it := range t.items {
walk(it)
}
case indexNode:
walk(t.a)
walk(t.i)
case unNode:
walk(t.a)
case binNode:
walk(t.a)
walk(t.b)
case ternNode:
walk(t.c)
walk(t.a)
walk(t.b)
case callNode:
for _, a := range t.args {
walk(a)
}
}
}
walk(root)
return out
}
// CheckExpr validates an expression; returns an error message or "" if it parses.
func CheckExpr(src string) string {
if strings.TrimSpace(src) == "" {
return ""
}
if _, err := parse(src); err != nil {
return err.Error()
}
return ""
}
+156
View File
@@ -0,0 +1,156 @@
package controllogic
import (
"math"
"reflect"
"testing"
)
func TestEvalExpr(t *testing.T) {
resolve := func(ds, name string) Value {
switch {
case ds == "stub" && name == "x":
return 10
case ds == "local" && name == "y":
return 3
case ds == "sys" && name == "dt":
return 0.5
}
return math.NaN()
}
cases := []struct {
expr string
want float64
}{
{"1 + 2 * 3", 7},
{"(1 + 2) * 3", 9},
{"10 % 3", 1},
{"{stub:x} + y", 13},
{"{stub:x} > 5 ? 1 : 0", 1},
{"min(4, 2, 8)", 2},
{"max(4, 2, 8)", 8},
{"abs(-5)", 5},
{"sqrt(16)", 4},
{"2 < 3 && 3 < 4", 1},
{"!0", 1},
{"-{stub:x}", -10},
{"{sys:dt} * 2", 1},
{"true + false", 1},
}
for _, c := range cases {
got := EvalExpr(c.expr, resolve)
if math.Abs(got-c.want) > 1e-9 {
t.Errorf("EvalExpr(%q) = %v, want %v", c.expr, got, c.want)
}
}
}
func TestEvalExprErrors(t *testing.T) {
r := func(ds, name string) Value { return 0 }
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
if v := EvalExpr(bad, r); !math.IsNaN(v) {
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
}
}
}
func TestCollectRefs(t *testing.T) {
refs := CollectRefs("{stub:x} + y + {epics:MY:PV} * z")
got := map[string]bool{}
for _, r := range refs {
got[r.DS+":"+r.Name] = true
}
for _, want := range []string{"stub:x", "local:y", "epics:MY:PV", "local:z"} {
if !got[want] {
t.Errorf("CollectRefs missing %q (got %v)", want, got)
}
}
}
func TestCheckExpr(t *testing.T) {
if msg := CheckExpr("1 + 2"); msg != "" {
t.Errorf("CheckExpr valid returned %q", msg)
}
if msg := CheckExpr("1 +"); msg == "" {
t.Errorf("CheckExpr invalid returned empty")
}
if msg := CheckExpr(""); msg != "" {
t.Errorf("CheckExpr empty returned %q", msg)
}
}
func TestEpicsRefSplitFirstColon(t *testing.T) {
refs := CollectRefs("{epics:SR:BPM:01:X}")
if len(refs) != 1 || refs[0].DS != "epics" || refs[0].Name != "SR:BPM:01:X" {
t.Errorf("got %+v, want epics / SR:BPM:01:X", refs)
}
}
// ── New tests for value-polymorphic evaluator ─────────────────────────────────
func numResolver(vals map[string]Value) Resolver {
return func(ds, name string) Value {
if v, ok := vals[ds+":"+name]; ok {
return v
}
return math.NaN()
}
}
func TestEvalValueScalar(t *testing.T) {
R := numResolver(nil)
if got := EvalExpr("2 + 3 * 4", R); got != 14 {
t.Fatalf("scalar = %v", got)
}
if !EvalBool("1 < 2 && 3 >= 3", R) {
t.Fatal("bool expr should be true")
}
}
func TestEvalValueArrayLiteralAndIndex(t *testing.T) {
R := numResolver(nil)
got := EvalValue("[1, 2, 3]", R)
if !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
t.Fatalf("array literal = %#v", got)
}
if v := EvalExpr("[10,20,30][-1]", R); v != 30 {
t.Fatalf("index -1 = %v", v)
}
}
func TestEvalArrayFuncs(t *testing.T) {
R := numResolver(map[string]Value{"local:buf": []Value{3.0, 1.0, 2.0}})
if v := EvalExpr("len(buf)", R); v != 3 {
t.Fatalf("len = %v", v)
}
if v := EvalExpr("sum(buf)", R); v != 6 {
t.Fatalf("sum = %v", v)
}
if v := EvalExpr("max(buf)", R); v != 3 {
t.Fatalf("max(array) = %v", v)
}
if v := EvalExpr("max(1, 9, 4)", R); v != 9 {
t.Fatalf("max(scalars) = %v", v)
}
got := EvalValue("push(buf, 7)", R)
if !reflect.DeepEqual(got, []Value{3.0, 1.0, 2.0, 7.0}) {
t.Fatalf("push = %#v", got)
}
}
func TestEvalExprArrayYieldsNaN(t *testing.T) {
if v := EvalExpr("[1,2]", numResolver(nil)); !math.IsNaN(v) {
t.Fatalf("array via EvalExpr should be NaN, got %v", v)
}
}
func TestCollectRefsArray(t *testing.T) {
refs := CollectRefs("[{ds:a}, b[0]] ")
keys := map[string]bool{}
for _, r := range refs {
keys[r.DS+":"+r.Name] = true
}
if !keys["ds:a"] || !keys["local:b"] {
t.Fatalf("refs = %#v", refs)
}
}
+118
View File
@@ -0,0 +1,118 @@
package controllogic
import (
"math"
"sync"
lua "github.com/yuin/gopher-lua"
)
// luaRuntime holds a sandboxed Lua VM for one action.lua node. The VM is created
// lazily and reused; access is serialised so concurrent flow activations of the
// same node don't share VM state unsafely. Host functions exposed to scripts:
//
// get("ds:name") → number read a signal / sys / local value (NaN if absent)
// set("ds:name", v) write a value (bare name → graph-local var)
// log(msg) append to the server log
//
// The os, io, package, debug and code-loading globals are removed, mirroring the
// dsp LuaNode sandbox.
type luaRuntime struct {
script string
mu sync.Mutex
L *lua.LState
// Bound to the current activation while run() holds mu.
curResolve Resolver
curSet func(target string, val float64)
curLog func(msg string)
}
func newLuaRuntime(script string) *luaRuntime {
return &luaRuntime{script: script}
}
func (lr *luaRuntime) ensure() error {
if lr.L != nil {
return nil
}
L := lua.NewState(lua.Options{SkipOpenLibs: true})
for _, pair := range []struct {
name string
fn lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage},
{lua.BaseLibName, lua.OpenBase},
{lua.MathLibName, lua.OpenMath},
{lua.StringLibName, lua.OpenString},
{lua.TabLibName, lua.OpenTable},
} {
if err := L.CallByParam(lua.P{Fn: L.NewFunction(pair.fn), NRet: 0, Protect: true}, lua.LString(pair.name)); err != nil {
L.Close()
return err
}
}
L.SetGlobal("load", lua.LNil)
L.SetGlobal("loadfile", lua.LNil)
L.SetGlobal("dofile", lua.LNil)
L.SetGlobal("require", lua.LNil)
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
ds, name, ok := parseRef(target)
v := math.NaN()
if ok && lr.curResolve != nil {
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
v = f
}
}
s.Push(lua.LNumber(v))
return 1
}))
L.SetGlobal("set", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
val := float64(s.CheckNumber(2))
if lr.curSet != nil {
lr.curSet(target, val)
}
return 0
}))
L.SetGlobal("log", L.NewFunction(func(s *lua.LState) int {
if lr.curLog != nil {
lr.curLog(s.CheckString(1))
}
return 0
}))
lr.L = L
return nil
}
// run executes the script once with the given host hooks bound.
func (lr *luaRuntime) run(resolve Resolver, set func(string, float64), logf func(string)) {
lr.mu.Lock()
defer lr.mu.Unlock()
if err := lr.ensure(); err != nil {
logf("lua init error: " + err.Error())
return
}
lr.curResolve = resolve
lr.curSet = set
lr.curLog = logf
defer func() {
lr.curResolve = nil
lr.curSet = nil
lr.curLog = nil
if r := recover(); r != nil {
logf("lua panic")
}
}()
lr.L.SetTop(0)
if err := lr.L.DoString(lr.script); err != nil {
logf("lua error: " + err.Error())
}
}
+86
View File
@@ -0,0 +1,86 @@
// Package controllogic implements a server-side flow-graph engine. It mirrors
// the client-side panel logic engine (web/src/lib/logic.ts) but runs
// continuously on the server under the root context, independent of any panel.
//
// A control-logic graph is a Node-RED-style flow of trigger, gate, control-flow
// and action nodes connected by wires. Triggers are flow entry points; when one
// activates the engine follows the outgoing wires executing downstream nodes.
//
// Compared with the panel engine, control logic has no button trigger (buttons
// are panel-UI driven) and gains two headless triggers — cron (5-field schedule)
// and alarm (signal out of an allowed range) — plus a Lua script action node
// with get/set/log host functions.
//
// Graphs are persisted as JSON in {storageDir}/controllogic.json and CRUD'd via
// the REST API; the engine rebuilds itself (Reload) whenever they change.
package controllogic
// Node kinds. Triggers begin flows; the rest execute downstream.
//
// trigger.threshold — fires on the rising edge of cmp(signal, value).
// trigger.change — fires whenever a watched signal's value changes.
// trigger.timer — fires every `interval` ms.
// trigger.cron — fires when the wall clock matches a 5-field cron `spec`.
// trigger.alarm — fires on the rising edge of signal leaving [min,max].
// gate.and — passes only when all incoming triggers are satisfied.
// flow.if — evaluates `cond`, continues on the 'then'/'else' port.
// flow.loop — repeats the 'body' port (count / while, capped) then 'done'.
// action.write — evaluates `expr`, writes the result to `target`.
// action.delay — waits `ms` before continuing.
// action.log — logs an expression value to the server log.
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
// action.dialog — pushes an info/error/input dialog to connected clients
// filtered by user/group; input responses write `target`.
// Node is a single node in a control-logic graph. Params are stored as strings
// (matching the panel logic model) and parsed per-kind by the engine.
type Node struct {
ID string `json:"id"`
Kind string `json:"kind"`
X float64 `json:"x"`
Y float64 `json:"y"`
Params map[string]string `json:"params,omitempty"`
}
// Wire connects an output port of one node to the input of another.
// FromPort defaults to "out" when empty.
type Wire struct {
From string `json:"from"`
FromPort string `json:"fromPort,omitempty"`
To string `json:"to"`
}
// NodeGroup is a cosmetic, editor-side grouping of nodes. The engine ignores it;
// it is stored and round-tripped so the editor keeps its visual organisation.
type NodeGroup struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Members []string `json:"members"`
Collapsed bool `json:"collapsed,omitempty"`
}
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
// StateVars declares graph-local variables (scalar or array). Live values are
// instantiated in memory per generation from these declarations; only the
// declarations persist. Mirrors the panel-logic statevars feature.
StateVars []StateVar `json:"statevars,omitempty"`
}
func (n Node) param(key string) string {
if n.Params == nil {
return ""
}
return n.Params[key]
}
+37
View File
@@ -0,0 +1,37 @@
package controllogic
import "strings"
// Dialog is a user-facing notification or input request emitted by an
// action.dialog node. It is delivered to connected clients whose identity
// matches Users/Groups (both empty = everyone). For an "input" dialog the
// client's response is written back to Target (a "ds:name" reference, e.g.
// "srv:approved") so control logic can read it on a later activation.
type Dialog struct {
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title"`
Message string `json:"message"`
Target string `json:"target,omitempty"`
Users []string `json:"users,omitempty"`
Groups []string `json:"groups,omitempty"`
}
// Notifier delivers control-logic dialogs to connected clients. The server
// implements it; the engine calls Notify when an action.dialog node runs.
// Notify must not block (the hub fans out without waiting on slow clients).
type Notifier interface {
Notify(Dialog)
}
// splitCSV parses a comma-separated user/group filter into trimmed,
// non-empty tokens. An empty string yields a nil slice (no filter).
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
+171
View File
@@ -0,0 +1,171 @@
package controllogic
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
const definitionsFile = "controllogic.json"
// ErrNotFound is returned when a graph id does not exist.
var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
//
// Git-style versioning: the live graph lives in controllogic.json (the current
// revision), while every superseded revision is preserved as a backup file
// {id}.vN.json under versionsDir. Promote/Fork build on these backups.
type Store struct {
mu sync.RWMutex
path string
trashDir string
versionsDir string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
versionsDir: filepath.Join(storageDir, "controllogic_versions"),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var graphs []Graph
if err := json.Unmarshal(data, &graphs); err != nil {
return fmt.Errorf("parse %s: %w", s.path, err)
}
for _, g := range graphs {
s.items[g.ID] = g
}
return nil
}
// saveLocked writes the current set atomically. Caller must hold s.mu.
func (s *Store) saveLocked() error {
graphs := make([]Graph, 0, len(s.items))
for _, g := range s.items {
graphs = append(graphs, g)
}
data, err := json.MarshalIndent(graphs, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// List returns all graphs.
func (s *Store) List() []Graph {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Graph, 0, len(s.items))
for _, g := range s.items {
out = append(out, g)
}
return out
}
// Get returns a single graph by id.
func (s *Store) Get(id string) (Graph, error) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.items[id]
if !ok {
return Graph{}, ErrNotFound
}
return g, nil
}
// Save inserts or replaces a graph and persists the store. When replacing an
// existing graph the superseded revision is backed up as {id}.vN.json and the
// new graph's Version is bumped; a brand-new graph starts at version 1.
func (s *Store) Save(g Graph) error {
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.items[g.ID]; ok {
oldV := old.Version
if oldV < 1 {
oldV = 1
old.Version = 1
}
if err := s.backupLocked(old); err != nil {
return fmt.Errorf("back up control logic revision: %w", err)
}
g.Version = oldV + 1
} else if g.Version < 1 {
g.Version = 1
}
s.items[g.ID] = g
return s.saveLocked()
}
// backupLocked writes a single graph revision to versionsDir as {id}.vN.json.
// Caller must hold s.mu.
func (s *Store) backupLocked(g Graph) error {
if err := os.MkdirAll(s.versionsDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.versionPath(g.ID, g.Version), data, 0o644)
}
func (s *Store) versionPath(id string, version int) string {
return filepath.Join(s.versionsDir, fmt.Sprintf("%s.v%d.json", id, version))
}
// Delete removes a graph by id, first writing a copy into the trash folder so it
// can be recovered if needed. The trash backup is best-effort: a failure to write
// it does not prevent the delete (but is reported).
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.items[id]
if !ok {
return ErrNotFound
}
if err := s.trash(g); err != nil {
return fmt.Errorf("move control logic to trash: %w", err)
}
delete(s.items, id)
return s.saveLocked()
}
// trash writes a single graph as a timestamped JSON file under the trash folder.
func (s *Store) trash(g Graph) error {
if err := os.MkdirAll(s.trashDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli()))
return os.WriteFile(dst, data, 0o644)
}
+77
View File
@@ -0,0 +1,77 @@
package controllogic
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// TestStoreDeleteAndReload covers Delete (with trash backup), the ErrNotFound
// branches, List, and load() re-reading a persisted store from disk.
func TestStoreDeleteAndReload(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "g1", Name: "one", Enabled: true,
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
if err := s.Save(g); err != nil {
t.Fatalf("Save: %v", err)
}
if err := s.Save(Graph{ID: "g2", Name: "two"}); err != nil {
t.Fatalf("Save g2: %v", err)
}
if got := s.List(); len(got) != 2 {
t.Fatalf("List: want 2, got %d", len(got))
}
// Reload from disk: a fresh Store over the same dir must see both graphs.
s2, err := NewStore(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if got, err := s2.Get("g1"); err != nil || got.Name != "one" {
t.Errorf("reloaded g1 = %+v, %v", got, err)
}
// Delete writes a trash backup then removes the item.
if err := s.Delete("g1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get("g1"); err != ErrNotFound {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if err := s.Delete("g1"); err != ErrNotFound {
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
}
// A trash file should now exist for g1.
trashDir := filepath.Join(dir, "trash", "controllogic")
entries, err := os.ReadDir(trashDir)
if err != nil || len(entries) == 0 {
t.Errorf("trash dir = %v entries, err %v; want >=1", len(entries), err)
}
}
// TestSplitCSV covers the comma-filter parser, including trimming and the
// empty-input (nil) case.
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{"a", []string{"a"}},
{" a , b ,, c ", []string{"a", "b", "c"}},
}
for _, tc := range cases {
if got := splitCSV(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
package controllogic
import "testing"
func TestStoreRoundTripStateVars(t *testing.T) {
dir := t.TempDir()
st, err := NewStore(dir) // NewStore takes the storage DIRECTORY
if err != nil {
t.Fatal(err)
}
g := Graph{
ID: "g1",
Name: "with-vars",
StateVars: []StateVar{
{Name: "count", Type: "number", Initial: "0"},
{Name: "buf", Type: "array", Initial: "[1,2]", Elem: "number", Sizing: "capped", Capacity: 5},
},
}
if err := st.Save(g); err != nil { // Save returns only error
t.Fatal(err)
}
st2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
got, err := st2.Get("g1") // Get returns (Graph, error); ErrNotFound if absent
if err != nil {
t.Fatal(err)
}
if len(got.StateVars) != 2 || got.StateVars[1].Name != "buf" || got.StateVars[1].Capacity != 5 {
t.Fatalf("statevars not round-tripped: %#v", got.StateVars)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Value model for control-logic locals/expressions. A Value is either a scalar
// (float64; booleans are 1/0) or an array ([]Value). This is the Go port of
// web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from
// web/src/lib/expr.ts. Pure, dependency-free.
package controllogic
import (
"encoding/json"
"fmt"
"strings"
)
// Value is a scalar (float64) or an array ([]Value).
type Value = any
// ARRAY_MAX is the global hard cap on dynamic array length (drops oldest).
const ARRAY_MAX = 1_000_000
// StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar.
type StateVar struct {
Name string `json:"name"`
Type string `json:"type,omitempty"` // number|bool|string|array (default number)
Initial string `json:"initial"` // initial value, stored as a string
Unit string `json:"unit,omitempty"`
Low float64 `json:"low,omitempty"`
High float64 `json:"high,omitempty"`
Elem string `json:"elem,omitempty"` // array-only: number|bool|array
Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed
Capacity int `json:"capacity,omitempty"` // array-only
}
func asNum(v Value) (float64, error) {
f, ok := v.(float64)
if !ok {
return 0, fmt.Errorf("expected a number, got an array")
}
return f, nil
}
func asArr(v Value) ([]Value, error) {
a, ok := v.([]Value)
if !ok {
return nil, fmt.Errorf("expected an array, got a number")
}
return a, nil
}
// idxResolve resolves a possibly-negative index against length; range-checked.
func idxResolve(i float64, length int) (int, error) {
k := int(i) // truncates toward zero, matching Math.trunc
if k < 0 {
k = length + k
}
if k < 0 || k >= length {
return 0, fmt.Errorf("index %v out of range (len %d)", i, length)
}
return k, nil
}
// normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64,
// bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays).
func normalizeValue(v any) Value {
switch t := v.(type) {
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int64:
return float64(t)
case bool:
if t {
return 1.0
}
return 0.0
case []interface{}:
out := make([]Value, len(t))
for i, e := range t {
out[i] = normalizeValue(e)
}
return out
default:
return 0.0
}
}
func zeroFill(n int) []Value {
if n < 0 {
n = 0
}
out := make([]Value, n)
for i := range out {
out[i] = 0.0
}
return out
}
// parseInitialArray returns the starting contents of an array local. Mirrors
// arraypolicy.ts parseInitialArray.
func parseInitialArray(sv StateVar) []Value {
cap := sv.Capacity
raw := strings.TrimSpace(sv.Initial)
var parsed []Value
if raw != "" {
var j interface{}
if err := json.Unmarshal([]byte(raw), &j); err == nil {
if arr, ok := j.([]interface{}); ok {
parsed = normalizeValue(arr).([]Value)
}
}
}
if sv.Sizing == "fixed" {
if parsed == nil {
return zeroFill(cap)
}
out := make([]Value, 0, cap)
for i := 0; i < len(parsed) && i < cap; i++ {
out = append(out, parsed[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
}
if parsed == nil {
return []Value{}
}
return parsed
}
// applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts.
func applySizing(arr []Value, sv StateVar) []Value {
cap := sv.Capacity
switch sv.Sizing {
case "fixed":
out := make([]Value, 0, cap)
for i := 0; i < len(arr) && i < cap; i++ {
out = append(out, arr[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
case "capped":
if len(arr) > cap {
return arr[len(arr)-cap:]
}
return arr
default:
if len(arr) > ARRAY_MAX {
return arr[len(arr)-ARRAY_MAX:]
}
return arr
}
}
+67
View File
@@ -0,0 +1,67 @@
package controllogic
import (
"reflect"
"testing"
)
func TestAsNumAsArr(t *testing.T) {
if n, err := asNum(3.0); err != nil || n != 3 {
t.Fatalf("asNum(3)=%v,%v", n, err)
}
if _, err := asNum([]Value{1.0}); err == nil {
t.Fatal("asNum(array) should error")
}
if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 {
t.Fatalf("asArr=%v,%v", a, err)
}
if _, err := asArr(3.0); err == nil {
t.Fatal("asArr(number) should error")
}
}
func TestIdxResolve(t *testing.T) {
if k, err := idxResolve(-1, 3); err != nil || k != 2 {
t.Fatalf("idx(-1,3)=%v,%v", k, err)
}
if _, err := idxResolve(3, 3); err == nil {
t.Fatal("idx(3,3) should be out of range")
}
}
func TestNormalizeValue(t *testing.T) {
got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}})
want := []Value{1.0, 1.0, []Value{2.0}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalize=%#v want %#v", got, want)
}
if normalizeValue(5) != Value(5.0) {
t.Fatalf("normalize(int) = %#v", normalizeValue(5))
}
}
func TestParseInitialArray(t *testing.T) {
fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"})
if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) {
t.Fatalf("fixed init = %#v", fixed)
}
dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"})
if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) {
t.Fatalf("dynamic init = %#v", dyn)
}
empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""})
if len(empty) != 0 {
t.Fatalf("empty init = %#v", empty)
}
}
func TestApplySizing(t *testing.T) {
capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2})
if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) {
t.Fatalf("capped = %#v", capped)
}
fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3})
if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) {
t.Fatalf("fixed = %#v", fixed)
}
}
+142
View File
@@ -0,0 +1,142 @@
package controllogic
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
)
// VersionMeta describes a single persisted revision of a control-logic graph,
// mirroring storage.VersionMeta so the frontend can treat all versioned
// document types uniformly.
type VersionMeta struct {
Version int `json:"version"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"savedAt"`
}
// Versions returns metadata for every persisted revision of the graph, newest
// first. The live revision (held in controllogic.json) is flagged Current.
func (s *Store) Versions(id string) ([]VersionMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.items[id]
if !ok {
return nil, ErrNotFound
}
curV := cur.Version
if curV < 1 {
curV = 1
}
var savedAt time.Time
if info, err := os.Stat(s.path); err == nil {
savedAt = info.ModTime()
}
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
entries, err := os.ReadDir(s.versionsDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
prefix := id + ".v"
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".json") {
continue
}
vStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".json")
v, err := strconv.Atoi(vStr)
if err != nil {
continue
}
g, err := s.readVersion(id, v)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, VersionMeta{Version: v, Name: g.Name, Tag: g.Tag, SavedAt: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// GetVersion returns a specific revision of the graph. The current revision is
// served from the live store; older revisions come from their backup file.
func (s *Store) GetVersion(id string, version int) (Graph, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.items[id]
if !ok {
return Graph{}, ErrNotFound
}
curV := cur.Version
if curV < 1 {
curV = 1
}
if version == curV {
return cur, nil
}
return s.readVersion(id, version)
}
// readVersion loads a backup revision file. Caller must hold s.mu.
func (s *Store) readVersion(id string, version int) (Graph, error) {
data, err := os.ReadFile(s.versionPath(id, version))
if os.IsNotExist(err) {
return Graph{}, ErrNotFound
}
if err != nil {
return Graph{}, err
}
var g Graph
if err := json.Unmarshal(data, &g); err != nil {
return Graph{}, fmt.Errorf("parse revision: %w", err)
}
return g, nil
}
// Promote makes a past revision current by re-saving it on top of history. The
// existing current revision is preserved as a backup, so promotion is
// non-destructive. Returns the resulting (new current) graph.
func (s *Store) Promote(id string, version int) (Graph, error) {
g, err := s.GetVersion(id, version)
if err != nil {
return Graph{}, err
}
g.Tag = fmt.Sprintf("restored from v%d", version)
if err := s.Save(g); err != nil {
return Graph{}, err
}
return s.Get(id)
}
// Fork creates a brand-new graph from a specific revision, assigning a fresh id
// and resetting its version to 1. Returns the new graph.
func (s *Store) Fork(id string, version int) (Graph, error) {
g, err := s.GetVersion(id, version)
if err != nil {
return Graph{}, err
}
g.ID = fmt.Sprintf("%s-fork-%d", id, time.Now().UnixMilli())
g.Version = 1
g.Tag = ""
if g.Name != "" {
g.Name = g.Name + " (fork)"
}
if err := s.Save(g); err != nil {
return Graph{}, err
}
return g, nil
}
+70
View File
@@ -0,0 +1,70 @@
package controllogic
import "testing"
func TestControlLogicVersioning(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "cl-1", Name: "loop", Enabled: true,
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
if err := s.Save(g); err != nil {
t.Fatalf("Save v1: %v", err)
}
if got, _ := s.Get("cl-1"); got.Version != 1 {
t.Fatalf("after create: version=%d", got.Version)
}
// Two edits → v2, v3.
g.Name = "loop-2"
if err := s.Save(g); err != nil {
t.Fatalf("Save v2: %v", err)
}
g.Name = "loop-3"
if err := s.Save(g); err != nil {
t.Fatalf("Save v3: %v", err)
}
if got, _ := s.Get("cl-1"); got.Version != 3 || got.Name != "loop-3" {
t.Fatalf("current: version=%d name=%q", got.Version, got.Name)
}
versions, err := s.Versions("cl-1")
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 3 {
t.Fatalf("want 3 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 3 {
t.Errorf("newest should be current v3: %+v", versions[0])
}
v1, err := s.GetVersion("cl-1", 1)
if err != nil || v1.Name != "loop" {
t.Fatalf("GetVersion v1: name=%q err=%v", v1.Name, err)
}
// Promote v1 → v4.
promoted, err := s.Promote("cl-1", 1)
if err != nil {
t.Fatalf("Promote: %v", err)
}
if promoted.Version != 4 || promoted.Name != "loop" {
t.Errorf("promote: version=%d name=%q", promoted.Version, promoted.Name)
}
// Fork v3 → new id, version 1.
forked, err := s.Fork("cl-1", 3)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.Version != 1 || forked.ID == "cl-1" {
t.Errorf("fork: id=%q version=%d", forked.ID, forked.Version)
}
if got, err := s.Get(forked.ID); err != nil || got.Name != forked.Name {
t.Errorf("forked graph not stored: %v", err)
}
}
+57 -7
View File
@@ -1,5 +1,3 @@
//go:build epics
package epics
import (
@@ -9,6 +7,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/datasource"
@@ -37,11 +36,11 @@ type archiveResponse []struct {
}
type archivePoint struct {
Secs int64 `json:"secs"`
Nanos int64 `json:"nanos"`
Val any `json:"val"`
Severity int `json:"severity"`
Status int `json:"status"`
Secs int64 `json:"secs"`
Nanos int64 `json:"nanos"`
Val any `json:"val"`
Severity int `json:"severity"`
Status int `json:"status"`
}
// fetchArchiveHistory queries the EPICS Archive Appliance JSON API for
@@ -113,12 +112,63 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
Timestamp: ts,
Data: data,
Quality: q,
Severity: p.Severity,
Status: p.Status,
})
}
return out, nil
}
// searchArchivePVs queries the Archive Appliance management API for PV names
// matching the given pattern (glob style, e.g. "PV:*").
func searchArchivePVs(ctx context.Context, archiveURL, pattern string) ([]string, error) {
if archiveURL == "" {
return nil, nil
}
// Build the request URL.
// Format: GET {archiveURL}/mgmt/bpl/getAllPVs?pv={pattern}
reqURL, err := url.Parse(archiveURL)
if err != nil {
return nil, fmt.Errorf("epics archive: invalid archive URL %q: %w", archiveURL, err)
}
reqURL = reqURL.JoinPath("mgmt", "bpl", "getAllPVs")
q := reqURL.Query()
if pattern == "" {
pattern = "*"
}
// AA expects glob patterns. If no glob characters, wrap in stars.
if !strings.ContainsAny(pattern, "*?[]") {
pattern = "*" + pattern + "*"
}
q.Set("pv", pattern)
reqURL.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("epics archive: building request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("epics archive: HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("epics archive: unexpected status %d for search %q", resp.StatusCode, pattern)
}
var pvs []string
if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil {
return nil, fmt.Errorf("epics archive: decoding response: %w", err)
}
return pvs, nil
}
// coerceArchiveValue converts the raw JSON value (which json.Unmarshal decodes
// as float64, string, bool, []interface{}, or nil) into one of the types
// accepted by datasource.Value.Data.
+9 -2
View File
@@ -22,7 +22,7 @@ import (
//
//export goCAMonitorCallback
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) {
h := uintptr(handle)
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
data = float64(0)
}
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
v := datasource.Value{
Timestamp: ts,
Data: data,
Quality: q,
Severity: int(severity),
Status: int(status),
}
// Look up the subscription channel under the global handle table lock and
// perform a non-blocking send so we never stall the CA callback thread.
@@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
Timestamp: time.Now(),
Data: float64(0),
Quality: datasource.QualityBad,
Severity: 3, // INVALID
}
select {
case ch <- v:
+10 -3
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects.
*/
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
void *dbr, int severity,
void *dbr, int severity, int status,
double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
double timeSecs = 0.0;
int severity = 0;
int status = 0;
/*
* Determine the timestamp and alarm severity from the DBR type.
* Determine the timestamp and alarm severity/status from the DBR type.
* We request DBR_TIME_* types so the timestamp is embedded in the value
* buffer right after the alarm fields (struct dbr_time_double et al.).
*/
@@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_FLOAT: {
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_LONG: {
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_SHORT: {
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_STRING: {
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_ENUM: {
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
default:
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
}
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
(void *)args.dbr, severity, timeSecs);
(void *)args.dbr, severity, status, timeSecs);
}
/*
+67
View File
@@ -0,0 +1,67 @@
package epics
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
)
// cfChannel is the JSON structure returned by the Channel Finder Service.
type cfChannel struct {
Name string `json:"name"`
Owner string `json:"owner"`
Properties []cfProperty `json:"properties"`
Tags []cfTag `json:"tags"`
}
type cfProperty struct {
Name string `json:"name"`
Value string `json:"value"`
Owner string `json:"owner"`
}
type cfTag struct {
Name string `json:"name"`
Owner string `json:"owner"`
}
// queryChannelFinder fetches channels from CFS matching the given query string.
func queryChannelFinder(cfURL, query string) ([]cfChannel, error) {
if cfURL == "" {
return nil, nil
}
url := strings.TrimRight(cfURL, "/") + "/resources/channels"
if query != "" {
if strings.Contains(query, "=") || strings.Contains(query, "~") {
// Query is already a structured filter (e.g. area=L1)
url += "?" + query
} else {
// Query is just a PV name pattern
url += "?~name=*" + query + "*"
}
}
client := &http.Client{Timeout: 10 * time.Second}
slog.Info("CFS query", "url", url)
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("CFS request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("CFS returned status %d", resp.StatusCode)
}
var channels []cfChannel
if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil {
return nil, fmt.Errorf("CFS response parse error: %w", err)
}
slog.Info("CFS results", "count", len(channels))
return channels, nil
}
@@ -0,0 +1,82 @@
package epics_test
import (
"context"
"os"
"testing"
"time"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
"github.com/uopi/uopi/internal/datasource/epics"
)
func TestDiscovery_EnvironmentVariables(t *testing.T) {
// 1. Setup a fake CA server.
srv, err := testca.New([]testca.PVSpec{
{Name: "DISCO:PV", DBFType: proto.DBFDouble, Count: 1, Value: 123.45},
})
if err != nil {
t.Fatalf("testca.New: %v", err)
}
defer srv.Close()
// 2. Set the environment variable to point to this server.
os.Setenv("EPICS_CA_ADDR_LIST", srv.UDPAddr())
os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
defer os.Unsetenv("EPICS_CA_ADDR_LIST")
defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST")
// 3. Create datasource with NO explicit address (should pick up from env).
ds := epics.New("", "", "", "", false, nil)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := ds.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
// 4. Verify discovery works.
meta, err := ds.GetMetadata(ctx, "DISCO:PV")
if err != nil {
t.Fatalf("GetMetadata failed (discovery failed): %v", err)
}
if meta.Name != "DISCO:PV" {
t.Errorf("got %q, want DISCO:PV", meta.Name)
}
}
func TestDiscovery_ConfigOverride(t *testing.T) {
// 1. Setup TWO fake CA servers.
srv1, _ := testca.New([]testca.PVSpec{{Name: "PV:1", DBFType: proto.DBFDouble, Value: 1.0}})
defer srv1.Close()
srv2, _ := testca.New([]testca.PVSpec{{Name: "PV:2", DBFType: proto.DBFDouble, Value: 2.0}})
defer srv2.Close()
// 2. Point env to srv1.
os.Setenv("EPICS_CA_ADDR_LIST", srv1.UDPAddr())
os.Setenv("EPICS_CA_AUTO_ADDR_LIST", "NO")
defer os.Unsetenv("EPICS_CA_ADDR_LIST")
defer os.Unsetenv("EPICS_CA_AUTO_ADDR_LIST")
// 3. Create datasource pointing explicitly to srv2 (should OVERRIDE env).
ds := epics.New(srv2.UDPAddr(), "", "", "", false, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ds.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
// 4. PV from srv2 should be found.
if _, err := ds.GetMetadata(ctx, "PV:2"); err != nil {
t.Errorf("PV:2 from config address not found: %v", err)
}
// 5. PV from srv1 should NOT be found (since AutoAddrList is disabled by config override).
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel2()
if _, err := ds.GetMetadata(ctx2, "PV:1"); err == nil {
t.Error("PV:1 from env address was found, but config should have overridden it")
}
}
+104 -14
View File
@@ -26,6 +26,7 @@ import "C"
import (
"context"
"fmt"
"log/slog"
"os"
"sync"
"sync/atomic"
@@ -66,8 +67,11 @@ type caChannel struct {
// EPICS is the Channel Access data source.
type EPICS struct {
caAddrList string
archiveURL string
caAddrList string
archiveURL string
cfURL string
autoSyncFilter string
autoSyncFromArchiver bool
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
// because the C type (ca_client_context *) is opaque. Every goroutine
@@ -85,12 +89,17 @@ type EPICS struct {
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
// rely on the environment). archiveURL is the base URL of an EPICS Archive
// Appliance instance for history queries (may be empty).
func New(caAddrList, archiveURL string) datasource.DataSource {
// pvNames is accepted for API compatibility with the pure-Go build but ignored
// in the CGo build (the CGo implementation populates ListSignals via callbacks).
func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource {
return &EPICS{
caAddrList: caAddrList,
archiveURL: archiveURL,
channels: make(map[string]*caChannel),
metadata: make(map[string]datasource.Metadata),
caAddrList: caAddrList,
archiveURL: archiveURL,
cfURL: cfURL,
autoSyncFilter: autoSyncFilter,
autoSyncFromArchiver: autoSyncFromArchiver,
channels: make(map[string]*caChannel),
metadata: make(map[string]datasource.Metadata),
}
}
@@ -120,9 +129,62 @@ func (e *EPICS) Connect(_ context.Context) error {
}
// Save the context so goroutines on other OS threads can attach to it.
e.caCtx = C.caCurrentContext()
// Perform background sync from Channel Finder if configured.
if e.cfURL != "" && e.autoSyncFilter != "" {
go e.syncFromChannelFinder(context.Background())
}
// Perform background sync from Archive Appliance if configured.
if e.archiveURL != "" && e.autoSyncFromArchiver {
go e.syncFromArchiver(context.Background())
}
return nil
}
func (e *EPICS) syncFromChannelFinder(ctx context.Context) {
time.Sleep(500 * time.Millisecond)
slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter)
channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter)
if err != nil {
slog.Error("epics: Channel Finder sync failed", "err", err)
return
}
for _, ch := range channels {
// Convert CFS properties and tags
props := make(map[string]string)
for _, p := range ch.Properties {
props[p.Name] = p.Value
}
tags := make([]string, len(ch.Tags))
for i, t := range ch.Tags {
tags[i] = t.Name
}
e.mu.Lock()
if _, exists := e.metadata[ch.Name]; !exists {
e.metadata[ch.Name] = datasource.Metadata{
Name: ch.Name,
Properties: props,
Tags: tags,
}
}
e.mu.Unlock()
// Background pre-fetch of actual record info
go e.prefetchPV(ctx, ch.Name)
}
slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
}
func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
mctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
_, _ = e.GetMetadata(mctx, pv)
}
// attachCAContext attaches the saved CA context to the calling OS thread.
// Must be called at the top of every goroutine that uses CA functions,
// because Go goroutines can be scheduled onto any OS thread.
@@ -340,10 +402,10 @@ func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Meta
// fetchMetadata retrieves metadata from a connected chid using ca_get.
func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
// In EPICS, write access is governed by CA security (host/user rules), not
// by the field type. Default to writable=true; the IOC will reject puts
// that violate its security policy.
meta := datasource.Metadata{Name: name, Writable: true}
// ca_write_access returns 1 if CA security permits puts from this client.
// This reflects the IOC's actual security policy (host/user ACLs), so we
// use it directly rather than defaulting to true and relying on put failures.
meta := datasource.Metadata{Name: name, Writable: C.ca_write_access(chid) != 0}
fieldType := C.ca_field_type(chid)
count := C.ca_element_count(chid)
@@ -420,13 +482,12 @@ func (e *EPICS) fetchMetadata(chid C.chid, name string) datasource.Metadata {
}
// ListSignals returns metadata for all currently tracked channels.
// Channels with cached metadata return full info; others return a stub entry
// with the PV name so the signal tree can display them immediately after
// a manual add, before GetMetadata has been called.
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
e.mu.Lock()
defer e.mu.Unlock()
slog.Debug("epics: listing signals", "count", len(e.metadata))
// Start with all fully-fetched metadata entries.
out := make([]datasource.Metadata, 0, len(e.channels))
seen := make(map[string]bool, len(e.metadata))
@@ -446,6 +507,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
// Write puts a new value onto a CA channel.
// If the signal is not currently subscribed (e.g. a button in oneshot mode),
// a temporary CA channel is created, used for the put, then torn down.
//
// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in
// the CGo/libca build: libca uses a single process-wide CA context whose client
// name is fixed at startup. Writes therefore use the server identity here. Use
// the default pure-Go build for per-user write attribution.
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
e.attachCAContext()
@@ -581,3 +647,27 @@ func nativeTimeType(chid C.chid) C.chtype {
return C.DBR_TIME_DOUBLE
}
}
func (e *EPICS) syncFromArchiver(ctx context.Context) {
time.Sleep(1 * time.Second) // wait for network
slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL)
pvs, err := searchArchivePVs(ctx, e.archiveURL, "*")
if err != nil {
slog.Error("epics: Archiver sync failed", "err", err)
return
}
for _, name := range pvs {
if ctx.Err() != nil {
return
}
e.mu.Lock()
if _, exists := e.metadata[name]; !exists {
e.metadata[name] = datasource.Metadata{Name: name}
}
e.mu.Unlock()
// Background pre-fetch of record info
go e.prefetchPV(ctx, name)
}
slog.Info("epics: synced signals from Archiver", "count", len(pvs))
}
@@ -0,0 +1,350 @@
//go:build !epics
// Integration tests for the pure-Go EPICS datasource adapter.
// These tests use the in-process fake CA server from the goca testca package
// and therefore do not require a real EPICS installation.
package epics_test
import (
"context"
"math"
"testing"
"time"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/epics"
)
// newTestDS creates a datasource.DataSource backed by a fake CA server.
func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testca.Server) {
t.Helper()
srv, err := testca.New(pvs)
if err != nil {
t.Fatalf("testca.New: %v", err)
}
t.Cleanup(srv.Close)
ds := epics.New(srv.UDPAddr(), "", "", "", false, nil)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
if err := ds.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
return ds, srv
}
// -------------------------------------------------------------------------- //
// GetMetadata //
// -------------------------------------------------------------------------- //
func TestPureGo_GetMetadataDouble(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
meta, err := ds.GetMetadata(ctx, "DS:DOUBLE")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if meta.Name != "DS:DOUBLE" {
t.Errorf("Name = %q, want %q", meta.Name, "DS:DOUBLE")
}
if meta.Type != datasource.TypeFloat64 {
t.Errorf("Type = %v, want TypeFloat64", meta.Type)
}
if !meta.Writable {
t.Error("expected Writable=true")
}
}
func TestPureGo_GetMetadataString(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:STR", DBFType: proto.DBFString, Count: 1, Value: "hello"},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
meta, err := ds.GetMetadata(ctx, "DS:STR")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if meta.Type != datasource.TypeString {
t.Errorf("Type = %v, want TypeString", meta.Type)
}
}
func TestPureGo_GetMetadataEnum(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(0)},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
meta, err := ds.GetMetadata(ctx, "DS:ENUM")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if meta.Type != datasource.TypeEnum {
t.Errorf("Type = %v, want TypeEnum", meta.Type)
}
}
func TestPureGo_GetMetadataReadOnly(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:RO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0,
Access: proto.AccessRead},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
meta, err := ds.GetMetadata(ctx, "DS:RO")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if meta.Writable {
t.Error("expected Writable=false for read-only PV")
}
}
func TestPureGo_GetMetadataCached(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:CACHED", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
m1, err := ds.GetMetadata(ctx, "DS:CACHED")
if err != nil {
t.Fatalf("first GetMetadata: %v", err)
}
m2, err := ds.GetMetadata(ctx, "DS:CACHED")
if err != nil {
t.Fatalf("second GetMetadata: %v", err)
}
if m1.Name != m2.Name || m1.Type != m2.Type {
t.Error("second GetMetadata returned different result from cache")
}
}
// -------------------------------------------------------------------------- //
// Subscribe //
// -------------------------------------------------------------------------- //
func TestPureGo_SubscribeInitialValue(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:SUB", DBFType: proto.DBFDouble, Count: 1, Value: 7.5},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan datasource.Value, 16)
unsub, err := ds.Subscribe(ctx, "DS:SUB", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain MetaUpdate and value frames — first numeric value wins.
deadline := time.After(5 * time.Second)
for {
select {
case v := <-ch:
if v.MetaUpdate {
continue
}
f, ok := v.Data.(float64)
if !ok {
t.Fatalf("Data type = %T, want float64", v.Data)
}
if math.Abs(f-7.5) > 1e-6 {
t.Errorf("Data = %g, want 7.5", f)
}
return
case <-deadline:
t.Fatal("timeout waiting for initial value")
}
}
}
func TestPureGo_SubscribeUpdates(t *testing.T) {
ds, srv := newTestDS(t, []testca.PVSpec{
{Name: "DS:UPD", DBFType: proto.DBFDouble, Count: 1, Value: 1.0},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan datasource.Value, 16)
unsub, err := ds.Subscribe(ctx, "DS:UPD", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain initial frames.
deadline := time.After(3 * time.Second)
drainLoop:
for {
select {
case v := <-ch:
if !v.MetaUpdate {
break drainLoop
}
case <-deadline:
t.Fatal("timeout draining initial frames")
}
}
// Push new value via server.
srv.SetValue("DS:UPD", 99.9)
deadline = time.After(3 * time.Second)
for {
select {
case v := <-ch:
if v.MetaUpdate {
continue
}
f, ok := v.Data.(float64)
if !ok {
t.Fatalf("Data type = %T, want float64", v.Data)
}
if math.Abs(f-99.9) > 1e-6 {
t.Errorf("Data = %g, want 99.9", f)
}
return
case <-deadline:
t.Fatal("timeout waiting for updated value")
}
}
}
// -------------------------------------------------------------------------- //
// Write //
// -------------------------------------------------------------------------- //
func TestPureGo_WriteDouble(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:W", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch := make(chan datasource.Value, 16)
unsub, err := ds.Subscribe(ctx, "DS:W", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain initial.
deadline := time.After(3 * time.Second)
drainLoop:
for {
select {
case v := <-ch:
if !v.MetaUpdate {
break drainLoop
}
case <-deadline:
t.Fatal("timeout draining initial frames")
}
}
if err := ds.Write(ctx, "DS:W", float64(3.14)); err != nil {
t.Fatalf("Write: %v", err)
}
deadline = time.After(3 * time.Second)
for {
select {
case v := <-ch:
if v.MetaUpdate {
continue
}
f, ok := v.Data.(float64)
if !ok {
continue
}
if math.Abs(f-3.14) > 1e-6 {
t.Errorf("post-write Data = %g, want 3.14", f)
}
return
case <-deadline:
t.Fatal("timeout waiting for post-write monitor update")
}
}
}
func TestPureGo_WriteReadOnly(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:WRO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0,
Access: proto.AccessRead},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := ds.Write(ctx, "DS:WRO", float64(1.0))
if err == nil {
t.Fatal("expected error writing to read-only PV")
}
}
// -------------------------------------------------------------------------- //
// ListSignals //
// -------------------------------------------------------------------------- //
func TestPureGo_ListSignals(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:LS1", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
{Name: "DS:LS2", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// GetMetadata for each PV to populate the cache.
for _, name := range []string{"DS:LS1", "DS:LS2"} {
if _, err := ds.GetMetadata(ctx, name); err != nil {
t.Fatalf("GetMetadata(%q): %v", name, err)
}
}
sigs, err := ds.ListSignals(ctx)
if err != nil {
t.Fatalf("ListSignals: %v", err)
}
if len(sigs) < 2 {
t.Errorf("ListSignals returned %d signals, want ≥ 2", len(sigs))
}
}
// -------------------------------------------------------------------------- //
// History //
// -------------------------------------------------------------------------- //
func TestPureGo_HistoryUnavailable(t *testing.T) {
ds, _ := newTestDS(t, []testca.PVSpec{
{Name: "DS:HIST", DBFType: proto.DBFDouble, Count: 1, Value: 0.0},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := ds.History(ctx, "DS:HIST", time.Now().Add(-time.Hour), time.Now(), 100)
if err != datasource.ErrHistoryUnavailable {
t.Errorf("History with no archiveURL: want ErrHistoryUnavailable, got %v", err)
}
}
+468 -11
View File
@@ -1,17 +1,474 @@
//go:build !epics
// Package epics provides an EPICS Channel Access data source.
// When built without the "epics" build tag (i.e. without CGo and EPICS Base),
// this stub is compiled instead, so that the rest of the codebase can call
// epics.Available() to decide whether to register the data source.
// Package epics provides an EPICS Channel Access data source for uopi using a
// pure-Go CA implementation (no CGo, no EPICS Base installation required).
// When built WITH the "epics" build tag, the CGo-based implementation in
// epics.go is used instead; that version requires CGo and a working EPICS Base.
package epics
import "github.com/uopi/uopi/internal/datasource"
import (
"context"
"fmt"
"log/slog" //nolint:depguard
"strings"
"sync"
"time"
// Available reports whether the EPICS Channel Access data source is compiled in.
// It always returns false when built without the "epics" build tag.
func Available() bool { return false }
ca "github.com/uopi/goca"
"github.com/uopi/goca/proto"
"github.com/uopi/uopi/internal/datasource"
)
// New is a placeholder that returns nil when EPICS support is not compiled in.
// Callers must check Available() before calling New().
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
// Available always returns true for the pure-Go build.
func Available() bool { return true }
// -------------------------------------------------------------------------- //
// EPICS data source (pure-Go implementation) //
// -------------------------------------------------------------------------- //
// EPICS is the pure-Go Channel Access data source.
type EPICS struct {
caAddrList string
archiveURL string
cfURL string
autoSyncFilter string
autoSyncFromArchiver bool
pvNames []string // pre-fetched at connect time for ListSignals
client *ca.Client
mu sync.RWMutex
metadata map[string]datasource.Metadata
// Per-user CA clients for writes. EPICS Channel Access carries the client
// username at the circuit (TCP) level, so attributing a write to a specific
// end-user requires a dedicated client whose ClientName is that user. These
// are created lazily on first write by each user and evicted when idle.
ctx context.Context // datasource lifetime; governs per-user clients
baseCfg ca.Config // template config for per-user clients
selfName string // the server's own CA client name
userMu sync.Mutex
userClients map[string]*userClient
}
// userClient is a per-end-user CA client and its last-use time for idle eviction.
type userClient struct {
cli *ca.Client
lastUsed time.Time
}
// userClientIdleTTL is how long an idle per-user CA client is kept before being
// closed. Writes are bursty, so a few minutes avoids reconnecting on every put
// without holding circuits open indefinitely.
const userClientIdleTTL = 10 * time.Minute
// New creates a new EPICS Channel Access data source.
//
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses).
// Leave empty to use the environment variable.
// archiveURL is the base URL of an EPICS Archive Appliance instance; leave
// empty to disable history queries.
// pvNames is an optional list of PV names whose metadata will be pre-fetched
// at connect time so they appear in ListSignals immediately (useful for the
// edit-mode signal tree).
func New(caAddrList, archiveURL, cfURL, autoSyncFilter string, autoSyncFromArchiver bool, pvNames []string) datasource.DataSource {
return &EPICS{
caAddrList: caAddrList,
archiveURL: archiveURL,
cfURL: cfURL,
autoSyncFilter: autoSyncFilter,
autoSyncFromArchiver: autoSyncFromArchiver,
pvNames: pvNames,
metadata: make(map[string]datasource.Metadata),
}
}
// Name implements datasource.DataSource.
func (e *EPICS) Name() string { return "epics" }
// Connect creates the CA client and starts background I/O goroutines.
// ctx governs the lifetime of the client; cancel it to shut everything down.
func (e *EPICS) Connect(ctx context.Context) error {
cfg := ca.ConfigFromEnv()
if e.caAddrList != "" {
cfg.AddrList = strings.Fields(e.caAddrList)
cfg.AutoAddrList = false
}
slog.Info("epics: connecting CA client", "addrs", cfg.AddrList, "auto_addr", cfg.AutoAddrList)
cli, err := ca.NewClient(ctx, cfg)
if err != nil {
return fmt.Errorf("epics: %w", err)
}
e.client = cli
e.ctx = ctx
e.baseCfg = cfg
e.selfName = cfg.ClientName
e.userClients = make(map[string]*userClient)
go e.evictIdleUserClients(ctx)
slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName)
// Perform background sync from Channel Finder if configured.
if e.cfURL != "" && e.autoSyncFilter != "" {
go e.syncFromChannelFinder(ctx)
}
// Perform background sync from Archive Appliance if configured.
if e.archiveURL != "" && e.autoSyncFromArchiver {
go e.syncFromArchiver(ctx)
}
// Pre-fetch metadata for any configured PV names.
if len(e.pvNames) > 0 {
go func() {
for _, pv := range e.pvNames {
if ctx.Err() != nil {
return
}
e.prefetchPV(ctx, pv)
}
}()
}
return nil
}
func (e *EPICS) syncFromChannelFinder(ctx context.Context) {
time.Sleep(500 * time.Millisecond)
slog.Info("epics: syncing signals from Channel Finder", "url", e.cfURL, "filter", e.autoSyncFilter)
channels, err := queryChannelFinder(e.cfURL, e.autoSyncFilter)
if err != nil {
slog.Error("epics: Channel Finder sync failed", "err", err)
return
}
for _, ch := range channels {
if ctx.Err() != nil {
return
}
// Convert CFS properties and tags to datasource.Metadata
props := make(map[string]string)
for _, p := range ch.Properties {
props[p.Name] = p.Value
}
tags := make([]string, len(ch.Tags))
for i, t := range ch.Tags {
tags[i] = t.Name
}
// Initialize metadata with CFS info. Full metadata (type, units, etc.)
// will be populated on first access via DBR_CTRL GET.
e.mu.Lock()
if _, exists := e.metadata[ch.Name]; !exists {
e.metadata[ch.Name] = datasource.Metadata{
Name: ch.Name,
Properties: props,
Tags: tags,
}
}
e.mu.Unlock()
// Background pre-fetch of actual record info (type, etc.)
go e.prefetchPV(ctx, ch.Name)
}
slog.Info("epics: synced signals from Channel Finder", "count", len(channels))
}
func (e *EPICS) prefetchPV(ctx context.Context, pv string) {
tvCh := make(chan proto.TimeValue, 1)
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
defer subCancel()
cancel, err := e.client.Subscribe(subCtx, pv, tvCh)
if err != nil {
return
}
defer cancel()
// Channel is now connected; fetch CTRL metadata.
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
defer mcancel()
_, _ = e.GetMetadata(mctx, pv)
}
// -------------------------------------------------------------------------- //
// Subscribe //
// -------------------------------------------------------------------------- //
// Subscribe registers ch to receive live value updates for signal.
// It blocks until the CA channel is connected (or ctx expires).
// A background goroutine forwards proto.TimeValue updates to ch as
// datasource.Value. Call the returned CancelFunc to unsubscribe.
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
slog.Info("epics: subscribing", "pv", signal)
tvCh := make(chan proto.TimeValue, 16)
clientCancel, err := e.client.Subscribe(ctx, signal, tvCh)
if err != nil {
slog.Error("epics: subscribe failed", "pv", signal, "err", err)
return nil, fmt.Errorf("epics: subscribe %q: %w", signal, err)
}
slog.Info("epics: subscribed OK", "pv", signal)
// Fetch and cache metadata now that the channel is connected.
// Send MetaUpdate so the dispatcher re-broadcasts metadata to clients.
go func() {
// Small delay gives ACCESS_RIGHTS message time to arrive before GetCtrl.
time.Sleep(50 * time.Millisecond)
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
defer mcancel()
if _, err := e.GetMetadata(mctx, signal); err == nil {
select {
case ch <- datasource.Value{MetaUpdate: true}:
default:
}
}
}()
done := make(chan struct{})
// Forward goroutine: convert proto.TimeValue → datasource.Value.
go func() {
for {
select {
case tv := <-tvCh:
slog.Debug("epics: value received", "pv", signal, "double", tv.Double, "severity", tv.Severity)
val := e.timeValueToDS(signal, tv)
select {
case ch <- val:
default: // drop if consumer is slow
}
case <-done:
return
}
}
}()
cancel := func() {
clientCancel()
close(done)
}
return datasource.CancelFunc(cancel), nil
}
// timeValueToDS converts a proto.TimeValue to a datasource.Value.
func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Value {
var data any
if tv.Doubles != nil {
if len(tv.Doubles) == 1 {
data = tv.Doubles[0]
} else {
data = tv.Doubles
}
} else if tv.Str != "" {
data = tv.Str
} else {
data = tv.Double
}
quality := datasource.QualityGood
switch tv.Severity {
case proto.SeverityMinor:
quality = datasource.QualityUncertain
case proto.SeverityMajor, proto.SeverityInvalid:
quality = datasource.QualityBad
}
return datasource.Value{
Timestamp: tv.Timestamp,
Data: data,
Quality: quality,
Severity: int(tv.Severity),
Status: int(tv.Status),
}
}
// -------------------------------------------------------------------------- //
// GetMetadata //
// -------------------------------------------------------------------------- //
// GetMetadata returns metadata for signal, using a cached value when available.
// On the first call for a signal it performs a DBR_CTRL_* GET to retrieve
// units, display limits, enum strings, and access rights.
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
e.mu.RLock()
if m, ok := e.metadata[signal]; ok {
e.mu.RUnlock()
return m, nil
}
e.mu.RUnlock()
slog.Info("epics: fetching metadata", "pv", signal)
ci, err := e.client.GetCtrl(ctx, signal)
if err != nil {
slog.Error("epics: GetMetadata failed", "pv", signal, "err", err)
return datasource.Metadata{}, fmt.Errorf("epics: GetMetadata %q: %w", signal, err)
}
m := e.ctrlToMeta(signal, ci)
slog.Info("epics: metadata fetched", "pv", signal, "type", m.Type, "unit", m.Unit, "writable", m.Writable)
e.mu.Lock()
e.metadata[signal] = m
e.mu.Unlock()
return m, nil
}
// ctrlToMeta converts a ca.CtrlInfo to a datasource.Metadata.
func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata {
m := datasource.Metadata{
Name: name,
Writable: ci.Access&proto.AccessWrite != 0,
}
switch {
case ci.Double != nil:
m.Type = datasource.TypeFloat64
if ci.Count > 1 {
m.Type = datasource.TypeFloat64Array
}
m.Unit = ci.Double.Units
m.DisplayLow = ci.Double.LowerDispLimit
m.DisplayHigh = ci.Double.UpperDispLimit
m.DriveLow = ci.Double.LowerCtrlLimit
m.DriveHigh = ci.Double.UpperCtrlLimit
case ci.Long != nil:
m.Type = datasource.TypeInt64
m.Unit = ci.Long.Units
m.DisplayLow = float64(ci.Long.LowerDispLimit)
m.DisplayHigh = float64(ci.Long.UpperDispLimit)
m.DriveLow = float64(ci.Long.LowerCtrlLimit)
m.DriveHigh = float64(ci.Long.UpperCtrlLimit)
case ci.Enum != nil:
m.Type = datasource.TypeEnum
m.EnumStrings = ci.Enum.Strings
case ci.Str != nil:
m.Type = datasource.TypeString
}
return m
}
// -------------------------------------------------------------------------- //
// ListSignals //
// -------------------------------------------------------------------------- //
// ListSignals returns metadata for all signals with cached information.
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
e.mu.RLock()
defer e.mu.RUnlock()
slog.Debug("epics: listing signals", "count", len(e.metadata))
out := make([]datasource.Metadata, 0, len(e.metadata))
for _, m := range e.metadata {
out = append(out, m)
}
return out, nil
}
// -------------------------------------------------------------------------- //
// Write //
// -------------------------------------------------------------------------- //
// Write puts a new value onto the named CA channel.
// value must be one of: float64, float32, int64, int32, int, int16, string, bool.
//
// When the request context carries an end-user identity (see datasource.WithUser)
// that differs from the server's own CA client name, the put is performed on a
// dedicated CA client whose ClientName is that user, so the IOC's access-security
// rules see the actual end-user rather than the server process.
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
cli := e.client
if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName {
uc, err := e.clientForUser(user)
if err != nil {
return fmt.Errorf("epics: write %q as %q: %w", signal, user, err)
}
cli = uc
}
if err := cli.Put(ctx, signal, value); err != nil {
return fmt.Errorf("epics: write %q: %w", signal, err)
}
return nil
}
// clientForUser returns a CA client whose ClientName is user, creating and
// caching one on first use. The client is bound to the datasource lifetime ctx,
// not the per-request ctx, so it survives across writes until evicted when idle.
func (e *EPICS) clientForUser(user string) (*ca.Client, error) {
e.userMu.Lock()
defer e.userMu.Unlock()
if uc, ok := e.userClients[user]; ok {
uc.lastUsed = time.Now()
return uc.cli, nil
}
cfg := e.baseCfg
cfg.ClientName = user
cli, err := ca.NewClient(e.ctx, cfg)
if err != nil {
return nil, err
}
e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()}
slog.Info("epics: created per-user CA client", "user", user)
return cli, nil
}
// evictIdleUserClients closes per-user CA clients that have not been used within
// userClientIdleTTL. It runs until ctx is cancelled.
func (e *EPICS) evictIdleUserClients(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
e.userMu.Lock()
for user, uc := range e.userClients {
if now.Sub(uc.lastUsed) > userClientIdleTTL {
uc.cli.Close()
delete(e.userClients, user)
slog.Info("epics: evicted idle per-user CA client", "user", user)
}
}
e.userMu.Unlock()
}
}
}
// -------------------------------------------------------------------------- //
// History //
// -------------------------------------------------------------------------- //
// History delegates to the Archive Appliance client, or returns
// ErrHistoryUnavailable if no archive URL is configured.
func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
if e.archiveURL == "" {
return nil, datasource.ErrHistoryUnavailable
}
return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
}
func (e *EPICS) syncFromArchiver(ctx context.Context) {
time.Sleep(1 * time.Second) // wait for network
slog.Info("epics: syncing signals from Archiver", "url", e.archiveURL)
pvs, err := searchArchivePVs(ctx, e.archiveURL, "*")
if err != nil {
slog.Error("epics: Archiver sync failed", "err", err)
return
}
for _, name := range pvs {
if ctx.Err() != nil {
return
}
e.mu.Lock()
if _, exists := e.metadata[name]; !exists {
e.metadata[name] = datasource.Metadata{Name: name}
}
e.mu.Unlock()
// Background pre-fetch of record info
go e.prefetchPV(ctx, name)
}
slog.Info("epics: synced signals from Archiver", "count", len(pvs))
}
+4
View File
@@ -45,6 +45,8 @@ type Value struct {
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality
Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info
Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
}
@@ -60,6 +62,8 @@ type Metadata struct {
DriveHigh float64
EnumStrings []string // non-nil only for TypeEnum
Writable bool
Properties map[string]string // Channel Finder properties
Tags []string // Channel Finder tags
}
// CancelFunc cancels a subscription started by DataSource.Subscribe.
+216
View File
@@ -0,0 +1,216 @@
package modbus
import (
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
)
// Modbus function codes.
const (
fcReadCoils = 0x01
fcReadDiscrete = 0x02
fcReadHolding = 0x03
fcReadInput = 0x04
fcWriteSingleCoil = 0x05
fcWriteSingleReg = 0x06
fcWriteMultipleRegs = 0x10
)
// client is a minimal Modbus TCP master for a single device address. Requests
// are serialised by mu (Modbus TCP is request/response and the connection is
// shared by all of a device's polled registers). The connection is dialled
// lazily and dropped on any I/O error so the next request reconnects.
type client struct {
addr string
timeout time.Duration
mu sync.Mutex
conn net.Conn
txID uint16
}
func newClient(addr string, timeout time.Duration) *client {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &client{addr: addr, timeout: timeout}
}
func (c *client) close() {
c.mu.Lock()
c.closeLocked()
c.mu.Unlock()
}
func (c *client) closeLocked() {
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
// request sends a PDU to unitID and returns the response PDU (function code +
// data). It dials on demand and tears the connection down on error.
func (c *client) request(unitID byte, pdu []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
conn, err := net.DialTimeout("tcp", c.addr, c.timeout)
if err != nil {
return nil, fmt.Errorf("modbus: dial %s: %w", c.addr, err)
}
c.conn = conn
}
resp, err := c.transact(unitID, pdu)
if err != nil {
c.closeLocked()
return nil, err
}
return resp, nil
}
// transact performs one MBAP-framed exchange. Caller holds mu.
func (c *client) transact(unitID byte, pdu []byte) ([]byte, error) {
c.txID++
tx := c.txID
frame := make([]byte, 7+len(pdu))
binary.BigEndian.PutUint16(frame[0:], tx) // transaction id
binary.BigEndian.PutUint16(frame[2:], 0) // protocol id (0 = Modbus)
binary.BigEndian.PutUint16(frame[4:], uint16(1+len(pdu))) // length: unit id + PDU
frame[6] = unitID
copy(frame[7:], pdu)
_ = c.conn.SetDeadline(time.Now().Add(c.timeout))
if _, err := c.conn.Write(frame); err != nil {
return nil, fmt.Errorf("modbus: write: %w", err)
}
head := make([]byte, 7)
if _, err := io.ReadFull(c.conn, head); err != nil {
return nil, fmt.Errorf("modbus: read header: %w", err)
}
if binary.BigEndian.Uint16(head[0:]) != tx {
return nil, fmt.Errorf("modbus: transaction id mismatch")
}
length := binary.BigEndian.Uint16(head[4:])
if length < 2 { // unit id + at least a function code
return nil, fmt.Errorf("modbus: short frame length %d", length)
}
body := make([]byte, length-1) // header already consumed the unit id
if _, err := io.ReadFull(c.conn, body); err != nil {
return nil, fmt.Errorf("modbus: read body: %w", err)
}
fc := body[0]
if fc&0x80 != 0 { // exception response
var ex byte
if len(body) >= 2 {
ex = body[1]
}
return nil, fmt.Errorf("modbus: exception 0x%02x (%s)", ex, exceptionText(ex))
}
return body, nil
}
// readRegisters reads `quantity` 16-bit registers via fc (holding or input).
func (c *client) readRegisters(unitID, fc byte, addr, quantity uint16) ([]uint16, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short register response")
}
byteCount := int(resp[1])
if byteCount != int(quantity)*2 || len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: register byte count %d (want %d)", byteCount, quantity*2)
}
regs := make([]uint16, quantity)
for i := range regs {
regs[i] = binary.BigEndian.Uint16(resp[2+i*2:])
}
return regs, nil
}
// readBits reads `quantity` bits via fc (coils or discrete inputs).
func (c *client) readBits(unitID, fc byte, addr, quantity uint16) ([]bool, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short bit response")
}
byteCount := int(resp[1])
if len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: bit byte count %d exceeds frame", byteCount)
}
bits := make([]bool, quantity)
for i := range bits {
idx := 2 + i/8
if idx >= len(resp) {
break
}
bits[i] = resp[idx]&(1<<(uint(i)%8)) != 0
}
return bits, nil
}
func (c *client) writeSingleRegister(unitID byte, addr, value uint16) error {
pdu := []byte{fcWriteSingleReg, byte(addr >> 8), byte(addr), byte(value >> 8), byte(value)}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeMultipleRegisters(unitID byte, addr uint16, values []uint16) error {
pdu := make([]byte, 6+len(values)*2)
pdu[0] = fcWriteMultipleRegs
binary.BigEndian.PutUint16(pdu[1:], addr)
binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
pdu[5] = byte(len(values) * 2)
for i, v := range values {
binary.BigEndian.PutUint16(pdu[6+i*2:], v)
}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeSingleCoil(unitID byte, addr uint16, on bool) error {
var v uint16
if on {
v = 0xFF00
}
pdu := []byte{fcWriteSingleCoil, byte(addr >> 8), byte(addr), byte(v >> 8), byte(v)}
_, err := c.request(unitID, pdu)
return err
}
func exceptionText(code byte) string {
switch code {
case 0x01:
return "illegal function"
case 0x02:
return "illegal data address"
case 0x03:
return "illegal data value"
case 0x04:
return "server device failure"
case 0x05:
return "acknowledge"
case 0x06:
return "server device busy"
case 0x0B:
return "gateway target failed to respond"
default:
return "unknown"
}
}
+152
View File
@@ -0,0 +1,152 @@
package modbus
import (
"fmt"
"strings"
"github.com/uopi/uopi/internal/datasource"
)
// Config is the [datasource.modbus] section. Devices share no connection state;
// each is polled independently over its own TCP socket.
type Config struct {
Enabled bool `toml:"enabled"`
// PollIntervalMs is the default polling period for every register that does
// not override it. Zero → 1000 ms.
PollIntervalMs int `toml:"poll_interval_ms"`
Devices []Device `toml:"devices"`
}
// Device is one Modbus TCP slave. Address is "host:port" (default port 502 is
// appended if absent). UnitID is the Modbus unit/slave identifier (0255).
type Device struct {
Name string `toml:"name"`
Address string `toml:"address"`
UnitID uint8 `toml:"unit_id"`
TimeoutMs int `toml:"timeout_ms"`
Registers []Register `toml:"registers"`
}
// Register describes one logical signal mapped onto a Modbus address.
//
// - Kind selects the address space / function code:
// "holding" (FC03/06/10), "input" (FC04, read-only),
// "coil" (FC01/05, bool), "discrete" (FC02, read-only bool).
// - Encoding selects how holding/input words are decoded:
// "uint16", "int16", "uint32", "int32", "float32", "float64".
// Ignored for coil/discrete (always bool).
// - WordOrder is "big" (default, high word first) or "little" for the
// multi-word encodings.
// - Scale/Offset transform the raw numeric value: value*Scale + Offset.
// Scale 0 is treated as 1.
type Register struct {
Name string `toml:"name"`
Kind string `toml:"kind"`
Address uint16 `toml:"address"`
Encoding string `toml:"encoding"`
WordOrder string `toml:"word_order"`
Unit string `toml:"unit"`
Scale float64 `toml:"scale"`
Offset float64 `toml:"offset"`
Min float64 `toml:"min"`
Max float64 `toml:"max"`
Writable bool `toml:"writable"`
Description string `toml:"description"`
}
// register kinds.
const (
kindHolding = "holding"
kindInput = "input"
kindCoil = "coil"
kindDiscrete = "discrete"
)
// isBool reports whether the register addresses a single-bit space.
func (r Register) isBool() bool {
return r.kind() == kindCoil || r.kind() == kindDiscrete
}
func (r Register) kind() string {
if r.Kind == "" {
return kindHolding
}
return strings.ToLower(r.Kind)
}
func (r Register) encoding() string {
if r.Encoding == "" {
return "uint16"
}
return strings.ToLower(r.Encoding)
}
func (r Register) littleWordOrder() bool {
return strings.ToLower(r.WordOrder) == "little"
}
func (r Register) scale() float64 {
if r.Scale == 0 {
return 1
}
return r.Scale
}
// wordCount returns the number of 16-bit registers the encoding occupies.
func (r Register) wordCount() (int, error) {
switch r.encoding() {
case "uint16", "int16":
return 1, nil
case "uint32", "int32", "float32":
return 2, nil
case "float64":
return 4, nil
default:
return 0, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
}
// dataType maps the register to a datasource value type.
func (r Register) dataType() datasource.DataType {
if r.isBool() {
return datasource.TypeBool
}
// Integer encodings with no fractional scaling stay integers; anything
// scaled, offset, or float-encoded becomes a float64.
switch r.encoding() {
case "float32", "float64":
return datasource.TypeFloat64
default:
if r.scale() == 1 && r.Offset == 0 {
return datasource.TypeInt64
}
return datasource.TypeFloat64
}
}
// writable reports whether writes are permitted. Input registers and discrete
// inputs are read-only regardless of the Writable flag.
func (r Register) writable() bool {
switch r.kind() {
case kindInput, kindDiscrete:
return false
default:
return r.Writable
}
}
// metadata builds the datasource.Metadata for this register under signal name
// "device:register".
func (r Register) metadata(device string) datasource.Metadata {
return datasource.Metadata{
Name: device + ":" + r.Name,
Type: r.dataType(),
Unit: r.Unit,
Description: r.Description,
DisplayLow: r.Min,
DisplayHigh: r.Max,
DriveLow: r.Min,
DriveHigh: r.Max,
Writable: r.writable(),
}
}
+89
View File
@@ -0,0 +1,89 @@
package modbus
import (
"fmt"
"math"
)
// orderWords returns the registers in big-word-first order, reversing them when
// the register declares little word order. The slice is copied so the caller's
// data is left untouched.
func (r Register) orderWords(words []uint16) []uint16 {
if !r.littleWordOrder() {
return words
}
out := make([]uint16, len(words))
for i, w := range words {
out[len(words)-1-i] = w
}
return out
}
// decode converts the raw registers into the register's numeric value and
// applies scale/offset. The result type is int64 or float64 per dataType.
func (r Register) decode(words []uint16) (any, error) {
w := r.orderWords(words)
var raw float64
var rawInt int64
switch r.encoding() {
case "uint16":
rawInt = int64(w[0])
raw = float64(w[0])
case "int16":
rawInt = int64(int16(w[0]))
raw = float64(int16(w[0]))
case "uint32":
u := uint32(w[0])<<16 | uint32(w[1])
rawInt = int64(u)
raw = float64(u)
case "int32":
u := uint32(w[0])<<16 | uint32(w[1])
rawInt = int64(int32(u))
raw = float64(int32(u))
case "float32":
u := uint32(w[0])<<16 | uint32(w[1])
raw = float64(math.Float32frombits(u))
case "float64":
u := uint64(w[0])<<48 | uint64(w[1])<<32 | uint64(w[2])<<16 | uint64(w[3])
raw = math.Float64frombits(u)
default:
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
// Integer fast-path: no scaling/offset, integer encoding.
if r.scale() == 1 && r.Offset == 0 {
switch r.encoding() {
case "uint16", "int16", "uint32", "int32":
return rawInt, nil
}
}
return raw*r.scale() + r.Offset, nil
}
// encode converts a value destined for a Write back into raw registers,
// inverting scale/offset. Only the holding-register encodings are writable.
func (r Register) encode(value float64) ([]uint16, error) {
v := (value - r.Offset) / r.scale()
var words []uint16
switch r.encoding() {
case "uint16":
words = []uint16{uint16(int64(math.Round(v)))}
case "int16":
words = []uint16{uint16(int16(int64(math.Round(v))))}
case "uint32":
u := uint32(int64(math.Round(v)))
words = []uint16{uint16(u >> 16), uint16(u)}
case "int32":
u := uint32(int32(int64(math.Round(v))))
words = []uint16{uint16(u >> 16), uint16(u)}
case "float32":
u := math.Float32bits(float32(v))
words = []uint16{uint16(u >> 16), uint16(u)}
case "float64":
u := math.Float64bits(v)
words = []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
default:
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
return r.orderWords(words), nil
}
+115
View File
@@ -0,0 +1,115 @@
package modbus
import (
"math"
"testing"
"github.com/uopi/uopi/internal/datasource"
)
func TestDecodeEncodings(t *testing.T) {
cases := []struct {
name string
reg Register
words []uint16
want any
}{
{"uint16", Register{Encoding: "uint16"}, []uint16{42}, int64(42)},
{"int16 neg", Register{Encoding: "int16"}, []uint16{0xFFFF}, int64(-1)},
{"uint32", Register{Encoding: "uint32"}, []uint16{0x0001, 0x0000}, int64(65536)},
{"int32 neg", Register{Encoding: "int32"}, []uint16{0xFFFF, 0xFFFF}, int64(-1)},
{"scaled", Register{Encoding: "int16", Scale: 0.1}, []uint16{235}, 23.5},
{"float32", Register{Encoding: "float32"}, f32Words(3.5), 3.5},
{"float64", Register{Encoding: "float64"}, f64Words(2.25), 2.25},
}
for _, tc := range cases {
got, err := tc.reg.decode(tc.words)
if err != nil {
t.Errorf("%s: decode error %v", tc.name, err)
continue
}
switch w := tc.want.(type) {
case int64:
if got != w {
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
}
case float64:
gf, ok := got.(float64)
if !ok || math.Abs(gf-w) > 1e-6 {
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
}
}
}
}
func TestWordOrder(t *testing.T) {
big := Register{Encoding: "uint32", WordOrder: "big"}
little := Register{Encoding: "uint32", WordOrder: "little"}
words := []uint16{0x0001, 0x0002} // big: 0x00010002, little reverses to 0x00020001
gb, _ := big.decode(words)
gl, _ := little.decode(words)
if gb != int64(0x00010002) {
t.Errorf("big = %v, want %d", gb, 0x00010002)
}
if gl != int64(0x00020001) {
t.Errorf("little = %v, want %d", gl, 0x00020001)
}
}
func TestEncodeRoundTrip(t *testing.T) {
for _, enc := range []string{"uint16", "int16", "uint32", "int32", "float32", "float64"} {
reg := Register{Encoding: enc}
words, err := reg.encode(123)
if err != nil {
t.Fatalf("%s encode: %v", enc, err)
}
got, err := reg.decode(words)
if err != nil {
t.Fatalf("%s decode: %v", enc, err)
}
var f float64
switch v := got.(type) {
case int64:
f = float64(v)
case float64:
f = v
}
if math.Abs(f-123) > 1e-3 {
t.Errorf("%s round-trip = %v, want 123", enc, f)
}
}
}
func TestDataTypeAndWritable(t *testing.T) {
if got := (Register{Kind: "coil"}).dataType(); got != datasource.TypeBool {
t.Errorf("coil type = %v, want bool", got)
}
if got := (Register{Encoding: "float32"}).dataType(); got != datasource.TypeFloat64 {
t.Errorf("float type = %v, want float64", got)
}
if got := (Register{Encoding: "uint16", Scale: 0.5}).dataType(); got != datasource.TypeFloat64 {
t.Errorf("scaled int type = %v, want float64", got)
}
if got := (Register{Encoding: "uint16"}).dataType(); got != datasource.TypeInt64 {
t.Errorf("plain int type = %v, want int64", got)
}
if (Register{Kind: "input", Writable: true}).writable() {
t.Error("input register must be read-only")
}
if (Register{Kind: "discrete", Writable: true}).writable() {
t.Error("discrete input must be read-only")
}
if !(Register{Kind: "holding", Writable: true}).writable() {
t.Error("writable holding should be writable")
}
}
func f32Words(v float32) []uint16 {
u := math.Float32bits(v)
return []uint16{uint16(u >> 16), uint16(u)}
}
func f64Words(v float64) []uint16 {
u := math.Float64bits(v)
return []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
}
+258
View File
@@ -0,0 +1,258 @@
// Package modbus implements a Modbus TCP data source. Each configured device is
// polled over its own TCP connection; registers are exposed as signals named
// "device:register". Reads use the holding/input/coil/discrete function codes;
// writable holding registers and coils accept Write.
package modbus
import (
"context"
"fmt"
"strings"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const defaultPollInterval = time.Second
// deviceClient bundles a parsed device with its wire client and register lookup.
type deviceClient struct {
dev Device
cli *client
byName map[string]Register
}
// Modbus is a datasource.DataSource backed by one or more Modbus TCP devices.
type Modbus struct {
pollInterval time.Duration
devices map[string]*deviceClient // device name → client
signals map[string]signalRef // "device:register" → ref
}
type signalRef struct {
device string
reg Register
}
// New builds a Modbus source from config. It does not dial; connections are
// established lazily on first poll/write.
func New(cfg Config) (*Modbus, error) {
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
if poll <= 0 {
poll = defaultPollInterval
}
m := &Modbus{
pollInterval: poll,
devices: make(map[string]*deviceClient),
signals: make(map[string]signalRef),
}
for _, dev := range cfg.Devices {
if dev.Name == "" || dev.Address == "" {
return nil, fmt.Errorf("modbus: device needs name and address")
}
if _, dup := m.devices[dev.Name]; dup {
return nil, fmt.Errorf("modbus: duplicate device %q", dev.Name)
}
addr := dev.Address
if !strings.Contains(addr, ":") {
addr += ":502"
}
dc := &deviceClient{
dev: dev,
cli: newClient(addr, time.Duration(dev.TimeoutMs)*time.Millisecond),
byName: make(map[string]Register),
}
for _, reg := range dev.Registers {
if reg.Name == "" {
return nil, fmt.Errorf("modbus: device %q has a register with no name", dev.Name)
}
if _, err := reg.wordCount(); !reg.isBool() && err != nil {
return nil, err
}
if _, dup := dc.byName[reg.Name]; dup {
return nil, fmt.Errorf("modbus: device %q duplicate register %q", dev.Name, reg.Name)
}
dc.byName[reg.Name] = reg
m.signals[dev.Name+":"+reg.Name] = signalRef{device: dev.Name, reg: reg}
}
m.devices[dev.Name] = dc
}
return m, nil
}
// Name implements datasource.DataSource.
func (m *Modbus) Name() string { return "modbus" }
// Connect is a no-op; TCP connections are dialled lazily per device.
func (m *Modbus) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every configured register.
func (m *Modbus) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
out := make([]datasource.Metadata, 0, len(m.signals))
for _, ref := range m.signals {
out = append(out, ref.reg.metadata(ref.device))
}
return out, nil
}
// GetMetadata returns metadata for one signal.
func (m *Modbus) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
ref, ok := m.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return ref.reg.metadata(ref.device), nil
}
// readSignal performs one synchronous read of a register's current value.
func (m *Modbus) readSignal(ref signalRef) (datasource.Value, error) {
dc := m.devices[ref.device]
reg := ref.reg
now := time.Now()
if reg.isBool() {
fc := byte(fcReadCoils)
if reg.kind() == kindDiscrete {
fc = fcReadDiscrete
}
bits, err := dc.cli.readBits(dc.dev.UnitID, fc, reg.Address, 1)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: now, Data: bits[0], Quality: datasource.QualityGood}, nil
}
count, err := reg.wordCount()
if err != nil {
return datasource.Value{}, err
}
fc := byte(fcReadHolding)
if reg.kind() == kindInput {
fc = fcReadInput
}
words, err := dc.cli.readRegisters(dc.dev.UnitID, fc, reg.Address, uint16(count))
if err != nil {
return datasource.Value{}, err
}
data, err := reg.decode(words)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: now, Data: data, Quality: datasource.QualityGood}, nil
}
// Subscribe polls the register at the configured interval and pushes values
// into ch. On a read error a QualityBad value is emitted and polling continues.
func (m *Modbus) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
ref, ok := m.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(m.pollInterval)
defer ticker.Stop()
emit := func() {
v, err := m.readSignal(ref)
if err != nil {
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
}
select {
case ch <- v:
case <-ctx.Done():
}
}
emit() // first reading without waiting a full interval
for {
select {
case <-ticker.C:
emit()
case <-ctx.Done():
return
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write sets a writable holding register or coil.
func (m *Modbus) Write(_ context.Context, signal string, value any) error {
ref, ok := m.signals[signal]
if !ok {
return datasource.ErrNotFound
}
reg := ref.reg
if !reg.writable() {
return datasource.ErrNotWritable
}
dc := m.devices[ref.device]
if reg.isBool() {
on, err := toBool(value)
if err != nil {
return err
}
return dc.cli.writeSingleCoil(dc.dev.UnitID, reg.Address, on)
}
f, err := toFloat(value)
if err != nil {
return err
}
words, err := reg.encode(f)
if err != nil {
return err
}
if len(words) == 1 {
return dc.cli.writeSingleRegister(dc.dev.UnitID, reg.Address, words[0])
}
return dc.cli.writeMultipleRegisters(dc.dev.UnitID, reg.Address, words)
}
// History is unavailable for Modbus devices.
func (m *Modbus) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// Close tears down every device connection.
func (m *Modbus) Close() {
for _, dc := range m.devices {
dc.cli.close()
}
}
func toFloat(v any) (float64, error) {
switch x := v.(type) {
case float64:
return x, nil
case float32:
return float64(x), nil
case int:
return float64(x), nil
case int64:
return float64(x), nil
case bool:
if x {
return 1, nil
}
return 0, nil
default:
return 0, fmt.Errorf("modbus: cannot write value of type %T", v)
}
}
func toBool(v any) (bool, error) {
switch x := v.(type) {
case bool:
return x, nil
case float64:
return x != 0, nil
case int:
return x != 0, nil
case int64:
return x != 0, nil
default:
return false, fmt.Errorf("modbus: cannot write bool value of type %T", v)
}
}
+364
View File
@@ -0,0 +1,364 @@
package modbus
import (
"context"
"encoding/binary"
"io"
"net"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
// mockServer is an in-process Modbus TCP slave for tests. It serves a small
// register/coil store and records writes. Only the function codes exercised by
// the data source are implemented.
type mockServer struct {
ln net.Listener
mu sync.Mutex
holding map[uint16]uint16
input map[uint16]uint16
coils map[uint16]bool
discrete map[uint16]bool
lastWrite []uint16 // registers from the most recent write
}
func newMockServer(t *testing.T) *mockServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s := &mockServer{
ln: ln,
holding: map[uint16]uint16{},
input: map[uint16]uint16{},
coils: map[uint16]bool{},
discrete: map[uint16]bool{},
}
go s.serve()
t.Cleanup(func() { ln.Close() })
return s
}
func (s *mockServer) addr() string { return s.ln.Addr().String() }
func (s *mockServer) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
go s.handle(conn)
}
}
func (s *mockServer) handle(conn net.Conn) {
defer conn.Close()
for {
head := make([]byte, 7)
if _, err := io.ReadFull(conn, head); err != nil {
return
}
tx := binary.BigEndian.Uint16(head[0:])
length := binary.BigEndian.Uint16(head[4:])
body := make([]byte, length-1)
if _, err := io.ReadFull(conn, body); err != nil {
return
}
resp := s.respond(body)
out := make([]byte, 7+len(resp))
binary.BigEndian.PutUint16(out[0:], tx)
binary.BigEndian.PutUint16(out[2:], 0)
binary.BigEndian.PutUint16(out[4:], uint16(1+len(resp)))
out[6] = head[6]
copy(out[7:], resp)
if _, err := conn.Write(out); err != nil {
return
}
}
}
func (s *mockServer) respond(pdu []byte) []byte {
s.mu.Lock()
defer s.mu.Unlock()
fc := pdu[0]
switch fc {
case fcReadHolding, fcReadInput:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
out := []byte{fc, byte(qty * 2)}
src := s.holding
if fc == fcReadInput {
src = s.input
}
for i := uint16(0); i < qty; i++ {
out = binary.BigEndian.AppendUint16(out, src[addr+i])
}
return out
case fcReadCoils, fcReadDiscrete:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
nbytes := (int(qty) + 7) / 8
out := []byte{fc, byte(nbytes)}
bits := make([]byte, nbytes)
src := s.coils
if fc == fcReadDiscrete {
src = s.discrete
}
for i := uint16(0); i < qty; i++ {
if src[addr+i] {
bits[i/8] |= 1 << (i % 8)
}
}
return append(out, bits...)
case fcWriteSingleReg:
addr := binary.BigEndian.Uint16(pdu[1:])
val := binary.BigEndian.Uint16(pdu[3:])
s.holding[addr] = val
s.lastWrite = []uint16{val}
return pdu // echo
case fcWriteSingleCoil:
addr := binary.BigEndian.Uint16(pdu[1:])
s.coils[addr] = binary.BigEndian.Uint16(pdu[3:]) == 0xFF00
return pdu
case fcWriteMultipleRegs:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
s.lastWrite = nil
for i := uint16(0); i < qty; i++ {
v := binary.BigEndian.Uint16(pdu[6+i*2:])
s.holding[addr+i] = v
s.lastWrite = append(s.lastWrite, v)
}
return append([]byte{fc}, pdu[1:5]...)
default:
return []byte{fc | 0x80, 0x01}
}
}
func (s *mockServer) setHolding(addr, val uint16) {
s.mu.Lock()
s.holding[addr] = val
s.mu.Unlock()
}
func (s *mockServer) setInput(addr, val uint16) {
s.mu.Lock()
s.input[addr] = val
s.mu.Unlock()
}
func (s *mockServer) setDiscrete(addr uint16, on bool) {
s.mu.Lock()
s.discrete[addr] = on
s.mu.Unlock()
}
func testConfig(addr string) Config {
return Config{
Enabled: true,
PollIntervalMs: 20,
Devices: []Device{{
Name: "dev",
Address: addr,
UnitID: 1,
Registers: []Register{
{Name: "temp", Kind: "holding", Address: 10, Encoding: "int16", Scale: 0.1, Unit: "C", Writable: true},
{Name: "count", Kind: "holding", Address: 20, Encoding: "uint16"},
{Name: "big", Kind: "input", Address: 30, Encoding: "uint32"},
{Name: "flag", Kind: "discrete", Address: 5},
{Name: "relay", Kind: "coil", Address: 6, Writable: true},
{Name: "sp", Kind: "holding", Address: 40, Encoding: "float32", Writable: true},
},
}},
}
}
func TestReadRegisters(t *testing.T) {
srv := newMockServer(t)
srv.setHolding(10, 235) // int16, scale 0.1 → 23.5
srv.setHolding(20, 7)
srv.setInput(30, 0)
srv.setInput(31, 1000) // uint32 big-word-first: low word at 31
srv.setDiscrete(5, true)
m, err := New(testConfig(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer m.Close()
temp, err := m.readSignal(m.signals["dev:temp"])
if err != nil {
t.Fatalf("read temp: %v", err)
}
if f, ok := temp.Data.(float64); !ok || f < 23.49 || f > 23.51 {
t.Errorf("temp = %v (%T), want 23.5", temp.Data, temp.Data)
}
count, err := m.readSignal(m.signals["dev:count"])
if err != nil {
t.Fatalf("read count: %v", err)
}
if v, ok := count.Data.(int64); !ok || v != 7 {
t.Errorf("count = %v (%T), want int64 7", count.Data, count.Data)
}
big, err := m.readSignal(m.signals["dev:big"])
if err != nil {
t.Fatalf("read big: %v", err)
}
if v, ok := big.Data.(int64); !ok || v != 1000 {
t.Errorf("big = %v (%T), want int64 1000", big.Data, big.Data)
}
flag, err := m.readSignal(m.signals["dev:flag"])
if err != nil {
t.Fatalf("read flag: %v", err)
}
if b, ok := flag.Data.(bool); !ok || !b {
t.Errorf("flag = %v, want true", flag.Data)
}
}
func TestWriteRoundTrip(t *testing.T) {
srv := newMockServer(t)
m, err := New(testConfig(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer m.Close()
ctx := context.Background()
// Scaled int16: writing 23.5 with scale 0.1 should store raw 235.
if err := m.Write(ctx, "dev:temp", 23.5); err != nil {
t.Fatalf("write temp: %v", err)
}
srv.mu.Lock()
raw := srv.holding[10]
srv.mu.Unlock()
if raw != 235 {
t.Errorf("holding[10] = %d, want 235", raw)
}
// Coil write.
if err := m.Write(ctx, "dev:relay", true); err != nil {
t.Fatalf("write relay: %v", err)
}
srv.mu.Lock()
on := srv.coils[6]
srv.mu.Unlock()
if !on {
t.Error("coil 6 not set")
}
// float32 multi-register write.
if err := m.Write(ctx, "dev:sp", 12.5); err != nil {
t.Fatalf("write sp: %v", err)
}
got, err := m.readSignal(m.signals["dev:sp"])
if err != nil {
t.Fatalf("read sp: %v", err)
}
if f, ok := got.Data.(float64); !ok || f < 12.49 || f > 12.51 {
t.Errorf("sp = %v, want 12.5", got.Data)
}
}
func TestWriteErrors(t *testing.T) {
srv := newMockServer(t)
m, _ := New(testConfig(srv.addr()))
defer m.Close()
ctx := context.Background()
if err := m.Write(ctx, "dev:missing", 1); err != datasource.ErrNotFound {
t.Errorf("missing write err = %v, want ErrNotFound", err)
}
// Input register is read-only.
if err := m.Write(ctx, "dev:big", 1); err != datasource.ErrNotWritable {
t.Errorf("input write err = %v, want ErrNotWritable", err)
}
}
func TestSubscribe(t *testing.T) {
srv := newMockServer(t)
srv.setHolding(20, 42)
m, _ := New(testConfig(srv.addr()))
defer m.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := m.Subscribe(ctx, "dev:count", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("quality = %v, want good", v.Quality)
}
if iv, ok := v.Data.(int64); !ok || iv != 42 {
t.Errorf("first value = %v, want 42", v.Data)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for first value")
}
}
func TestSubscribeBadQualityOnError(t *testing.T) {
// Point at a closed port so reads fail; expect QualityBad, not a hang.
cfg := testConfig("127.0.0.1:1") // port 1: connection refused
m, _ := New(cfg)
defer m.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 1)
stop, err := m.Subscribe(ctx, "dev:count", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityBad {
t.Errorf("quality = %v, want bad", v.Quality)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestNewValidation(t *testing.T) {
if _, err := New(Config{Devices: []Device{{Name: "", Address: "x"}}}); err == nil {
t.Error("expected error for missing device name")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
t.Error("expected error for duplicate device")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r", Encoding: "bogus"}}}}}); err == nil {
t.Error("expected error for bad encoding")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r"}, {Name: "r"}}}}}); err == nil {
t.Error("expected error for duplicate register")
}
}
func TestSubscribeUnknownSignal(t *testing.T) {
m, _ := New(testConfig("127.0.0.1:502"))
defer m.Close()
if _, err := m.Subscribe(context.Background(), "nope", nil); err != datasource.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
}
if _, err := m.GetMetadata(context.Background(), "nope"); err != datasource.ErrNotFound {
t.Errorf("meta err = %v, want ErrNotFound", err)
}
}
+388
View File
@@ -0,0 +1,388 @@
// Package pva provides an EPICS PV Access data source for uopi using the
// pure-Go gopva library.
package pva
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
gopva "github.com/uopi/gopva"
"github.com/uopi/gopva/pvdata"
"github.com/uopi/uopi/internal/datasource"
)
// PVA is the PV Access data source.
type PVA struct {
client *gopva.Client
mu sync.RWMutex
metadata map[string]datasource.Metadata
}
// New creates a new PV Access data source.
// Server addresses are read from the EPICS_PVA_ADDR_LIST environment variable;
// addrList overrides that when non-empty.
func New(addrList []string) datasource.DataSource {
if len(addrList) > 0 {
// Inject addresses into the environment so gopva.NewClient picks them up.
_ = os.Setenv("EPICS_PVA_ADDR_LIST", strings.Join(addrList, " "))
}
return &PVA{
metadata: make(map[string]datasource.Metadata),
}
}
// Name implements datasource.DataSource.
func (p *PVA) Name() string { return "pva" }
// Connect creates the PVA client.
func (p *PVA) Connect(_ context.Context) error {
cl, err := gopva.NewClient()
if err != nil {
return fmt.Errorf("pva: connect: %w", err)
}
p.client = cl
slog.Info("pva: client started")
return nil
}
// -------------------------------------------------------------------------- //
// Subscribe //
// -------------------------------------------------------------------------- //
// Subscribe registers ch to receive live PVA value updates for signal.
func (p *PVA) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
slog.Info("pva: subscribing", "pv", signal)
monCh := p.client.Monitor(ctx, signal)
// Wait for the first event to confirm the subscription is live.
// We do this in the goroutine to avoid blocking the caller.
done := make(chan struct{})
go func() {
defer close(done)
for evt := range monCh {
if evt.Err != nil {
slog.Error("pva: monitor error", "pv", signal, "err", evt.Err)
select {
case ch <- datasource.Value{
Timestamp: time.Now(),
Data: nil,
Quality: datasource.QualityBad,
}:
default:
}
return
}
// Cache metadata from the first value.
p.updateMetadata(signal, evt.Value)
val := structToValue(evt.Value)
select {
case ch <- val:
default:
}
}
}()
cancel := func() {
// cancelling the context passed to Monitor stops it; the channel will be closed.
// The goroutine will exit when monCh is closed.
<-done
}
return datasource.CancelFunc(cancel), nil
}
// -------------------------------------------------------------------------- //
// GetMetadata //
// -------------------------------------------------------------------------- //
// GetMetadata returns metadata for signal. It performs a one-shot GET if
// metadata has not been cached yet.
func (p *PVA) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
p.mu.RLock()
if m, ok := p.metadata[signal]; ok {
p.mu.RUnlock()
return m, nil
}
p.mu.RUnlock()
tctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
sv, err := p.client.Get(tctx, signal)
if err != nil {
return datasource.Metadata{}, fmt.Errorf("pva: GetMetadata %q: %w", signal, err)
}
p.updateMetadata(signal, sv)
p.mu.RLock()
m := p.metadata[signal]
p.mu.RUnlock()
return m, nil
}
// updateMetadata derives and caches metadata from a StructValue.
func (p *PVA) updateMetadata(signal string, sv pvdata.StructValue) {
m := structToMeta(signal, sv)
p.mu.Lock()
p.metadata[signal] = m
p.mu.Unlock()
}
// -------------------------------------------------------------------------- //
// ListSignals //
// -------------------------------------------------------------------------- //
// ListSignals returns metadata for all signals with cached information.
func (p *PVA) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
p.mu.RLock()
defer p.mu.RUnlock()
out := make([]datasource.Metadata, 0, len(p.metadata))
for _, m := range p.metadata {
out = append(out, m)
}
return out, nil
}
// -------------------------------------------------------------------------- //
// Write //
// -------------------------------------------------------------------------- //
// Write is not yet implemented for PVA.
func (p *PVA) Write(_ context.Context, _ string, _ any) error {
return fmt.Errorf("pva: write not yet implemented")
}
// -------------------------------------------------------------------------- //
// History //
// -------------------------------------------------------------------------- //
// History is not supported for PVA sources.
func (p *PVA) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// -------------------------------------------------------------------------- //
// Helpers //
// -------------------------------------------------------------------------- //
// structToValue converts a PVA StructValue to a datasource.Value.
// It follows NTScalar/NTScalarArray conventions: looks for a "value" field
// and an optional "alarm" struct for quality, and "timeStamp" for the timestamp.
func structToValue(sv pvdata.StructValue) datasource.Value {
val := fieldByName(sv, "value")
ts := extractTimestamp(sv)
quality := extractQuality(sv)
severity, status := extractSeverityStatus(sv)
var data any
if val != nil {
data = normaliseValue(val)
}
return datasource.Value{
Timestamp: ts,
Data: data,
Quality: quality,
Severity: severity,
Status: status,
}
}
// structToMeta derives datasource.Metadata from a PVA StructValue.
func structToMeta(name string, sv pvdata.StructValue) datasource.Metadata {
m := datasource.Metadata{
Name: name,
Writable: true, // PVA channels are assumed writable until proven otherwise
}
val := fieldByName(sv, "value")
if val == nil && len(sv.Fields) > 0 {
val = sv.Fields[0].Value
}
if val != nil {
switch val.(type) {
case float64, float32:
m.Type = datasource.TypeFloat64
case int64, int32, int16, int8, uint64, uint32, uint16, uint8:
m.Type = datasource.TypeInt64
case string:
m.Type = datasource.TypeString
case bool:
m.Type = datasource.TypeBool
case []float64, []float32:
m.Type = datasource.TypeFloat64Array
default:
m.Type = datasource.TypeFloat64
}
}
// Extract display limits from NTScalar display structure.
if disp := structByName(sv, "display"); disp != nil {
if v := fieldByName(*disp, "limitLow"); v != nil {
m.DisplayLow, _ = toFloat64(v)
}
if v := fieldByName(*disp, "limitHigh"); v != nil {
m.DisplayHigh, _ = toFloat64(v)
}
if v := fieldByName(*disp, "units"); v != nil {
m.Unit, _ = v.(string)
}
}
// Extract control limits from NTScalar control structure.
if ctrl := structByName(sv, "control"); ctrl != nil {
if v := fieldByName(*ctrl, "limitLow"); v != nil {
m.DriveLow, _ = toFloat64(v)
}
if v := fieldByName(*ctrl, "limitHigh"); v != nil {
m.DriveHigh, _ = toFloat64(v)
}
}
return m
}
// normaliseValue converts any PVA scalar/array value to datasource-compatible types.
func normaliseValue(v any) any {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int64:
return x
case int32:
return int64(x)
case int16:
return int64(x)
case int8:
return int64(x)
case uint64:
return int64(x)
case uint32:
return int64(x)
case uint16:
return int64(x)
case uint8:
return int64(x)
case bool:
return x
case string:
return x
case []float64:
return x
case []float32:
out := make([]float64, len(x))
for i, f := range x {
out[i] = float64(f)
}
return out
default:
return fmt.Sprintf("%v", v)
}
}
func extractTimestamp(sv pvdata.StructValue) time.Time {
ts := structByName(sv, "timeStamp")
if ts == nil {
return time.Now()
}
sec := int64(0)
nsec := int32(0)
if v := fieldByName(*ts, "secondsPastEpoch"); v != nil {
sec, _ = v.(int64)
}
if v := fieldByName(*ts, "nanoseconds"); v != nil {
nsec, _ = v.(int32)
}
if sec == 0 {
return time.Now()
}
// EPICS epoch: 1990-01-01 00:00:00 UTC = Unix 631152000
const epicsEpoch = 631152000
return time.Unix(sec+epicsEpoch, int64(nsec)).UTC()
}
func extractQuality(sv pvdata.StructValue) datasource.Quality {
alarm := structByName(sv, "alarm")
if alarm == nil {
return datasource.QualityGood
}
sev := fieldByName(*alarm, "severity")
if sev == nil {
return datasource.QualityGood
}
s, _ := sev.(int32)
switch s {
case 1:
return datasource.QualityUncertain
case 2, 3:
return datasource.QualityBad
default:
return datasource.QualityGood
}
}
// extractSeverityStatus pulls the raw EPICS alarm severity and status from the
// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present.
func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) {
alarm := structByName(sv, "alarm")
if alarm == nil {
return 0, 0
}
if v := fieldByName(*alarm, "severity"); v != nil {
if s, ok := v.(int32); ok {
severity = int(s)
}
}
if v := fieldByName(*alarm, "status"); v != nil {
if s, ok := v.(int32); ok {
status = int(s)
}
}
return severity, status
}
func toFloat64(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int64:
return float64(x), true
case int32:
return float64(x), true
default:
return 0, false
}
}
func fieldByName(sv pvdata.StructValue, name string) any {
for _, f := range sv.Fields {
if f.Name == name {
return f.Value
}
}
return nil
}
func structByName(sv pvdata.StructValue, name string) *pvdata.StructValue {
for _, f := range sv.Fields {
if f.Name == name {
if s, ok := f.Value.(pvdata.StructValue); ok {
return &s
}
}
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package scpi
import "github.com/uopi/uopi/internal/datasource"
// Config is the [datasource.scpi] section. Each instrument is polled
// independently over its own TCP socket.
type Config struct {
Enabled bool `toml:"enabled"`
// PollIntervalMs is the default polling period for channels that do not
// override it. Zero → 1000 ms.
PollIntervalMs int `toml:"poll_interval_ms"`
Instruments []Instrument `toml:"instruments"`
}
// Instrument is one SCPI device reachable over a raw TCP socket. Address is
// "host:port"; the conventional SCPI-raw port 5025 is appended if absent.
type Instrument struct {
Name string `toml:"name"`
// Transport selects the link type. "raw" (default) is line-based SCPI over
// TCP. Reserved: "vxi11" (not yet implemented).
Transport string `toml:"transport"`
Address string `toml:"address"`
TimeoutMs int `toml:"timeout_ms"`
// Terminator is appended to every command. Empty → "\n".
Terminator string `toml:"terminator"`
Channels []Channel `toml:"channels"`
}
// Channel maps a SCPI query/command pair onto a signal named
// "instrument:channel".
type Channel struct {
Name string `toml:"name"`
// Query is the SCPI command whose response is the channel value,
// e.g. "MEAS:VOLT?". Required.
Query string `toml:"query"`
// WriteCmd is a printf-style template used by Write; "%v" is replaced with
// the value, e.g. "VOLT %v". Empty → channel is read-only.
WriteCmd string `toml:"write_cmd"`
// Type is the value type: "float" (default), "string", "int", or "bool".
Type string `toml:"type"`
Unit string `toml:"unit"`
Min float64 `toml:"min"`
Max float64 `toml:"max"`
PollIntervalMs int `toml:"poll_interval_ms"`
Description string `toml:"description"`
}
func (c Channel) dataType() datasource.DataType {
switch c.Type {
case "string":
return datasource.TypeString
case "int":
return datasource.TypeInt64
case "bool":
return datasource.TypeBool
default:
return datasource.TypeFloat64
}
}
func (c Channel) writable() bool { return c.WriteCmd != "" }
func (c Channel) metadata(instrument string) datasource.Metadata {
return datasource.Metadata{
Name: instrument + ":" + c.Name,
Type: c.dataType(),
Unit: c.Unit,
Description: c.Description,
DisplayLow: c.Min,
DisplayHigh: c.Max,
DriveLow: c.Min,
DriveHigh: c.Max,
Writable: c.writable(),
}
}
+237
View File
@@ -0,0 +1,237 @@
// Package scpi implements a SCPI instrument data source. Each configured
// instrument is reached over a raw TCP socket (line-based SCPI, the "SCPI raw" /
// port 5025 convention); a VXI-11 transport is reserved for later. Channels are
// exposed as signals named "instrument:channel" and polled at a configurable
// interval. Channels with a write_cmd template accept Write.
package scpi
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const defaultPollInterval = time.Second
type instrumentConn struct {
inst Instrument
tr transport
}
// Scpi is a datasource.DataSource backed by one or more SCPI instruments.
type Scpi struct {
pollInterval time.Duration
instruments map[string]*instrumentConn
signals map[string]signalRef // "instrument:channel" → ref
}
type signalRef struct {
instrument string
ch Channel
}
// New builds a SCPI source from config. It does not dial; connections are
// established lazily on first poll/write.
func New(cfg Config) (*Scpi, error) {
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
if poll <= 0 {
poll = defaultPollInterval
}
s := &Scpi{
pollInterval: poll,
instruments: make(map[string]*instrumentConn),
signals: make(map[string]signalRef),
}
for _, inst := range cfg.Instruments {
if inst.Name == "" || inst.Address == "" {
return nil, fmt.Errorf("scpi: instrument needs name and address")
}
if _, dup := s.instruments[inst.Name]; dup {
return nil, fmt.Errorf("scpi: duplicate instrument %q", inst.Name)
}
tr, err := newTransport(inst)
if err != nil {
return nil, err
}
ic := &instrumentConn{inst: inst, tr: tr}
for _, ch := range inst.Channels {
if ch.Name == "" || ch.Query == "" {
return nil, fmt.Errorf("scpi: instrument %q channel needs name and query", inst.Name)
}
key := inst.Name + ":" + ch.Name
if _, dup := s.signals[key]; dup {
return nil, fmt.Errorf("scpi: instrument %q duplicate channel %q", inst.Name, ch.Name)
}
s.signals[key] = signalRef{instrument: inst.Name, ch: ch}
}
s.instruments[inst.Name] = ic
}
return s, nil
}
// newTransport selects the transport implementation for an instrument.
func newTransport(inst Instrument) (transport, error) {
addr := inst.Address
switch strings.ToLower(inst.Transport) {
case "", "raw":
if !strings.Contains(addr, ":") {
addr += ":5025"
}
return newRawSocket(addr, time.Duration(inst.TimeoutMs)*time.Millisecond, inst.Terminator), nil
case "vxi11":
return nil, fmt.Errorf("scpi: vxi11 transport not yet implemented")
default:
return nil, fmt.Errorf("scpi: unknown transport %q", inst.Transport)
}
}
// Name implements datasource.DataSource.
func (s *Scpi) Name() string { return "scpi" }
// Connect is a no-op; connections are dialled lazily per instrument.
func (s *Scpi) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every configured channel.
func (s *Scpi) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
out := make([]datasource.Metadata, 0, len(s.signals))
for _, ref := range s.signals {
out = append(out, ref.ch.metadata(ref.instrument))
}
return out, nil
}
// GetMetadata returns metadata for one signal.
func (s *Scpi) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
ref, ok := s.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return ref.ch.metadata(ref.instrument), nil
}
// readSignal performs one synchronous query of a channel.
func (s *Scpi) readSignal(ref signalRef) (datasource.Value, error) {
ic := s.instruments[ref.instrument]
resp, err := ic.tr.query(ref.ch.Query)
if err != nil {
return datasource.Value{}, err
}
data, err := parseValue(ref.ch.dataType(), resp)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: time.Now(), Data: data, Quality: datasource.QualityGood}, nil
}
// Subscribe polls the channel at its configured interval (falling back to the
// source default) and pushes values into ch. A query error emits QualityBad and
// polling continues.
func (s *Scpi) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
ref, ok := s.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
interval := s.pollInterval
if ref.ch.PollIntervalMs > 0 {
interval = time.Duration(ref.ch.PollIntervalMs) * time.Millisecond
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
emit := func() {
v, err := s.readSignal(ref)
if err != nil {
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
}
select {
case ch <- v:
case <-ctx.Done():
}
}
emit()
for {
select {
case <-ticker.C:
emit()
case <-ctx.Done():
return
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write sends the channel's write_cmd template with the value substituted.
func (s *Scpi) Write(_ context.Context, signal string, value any) error {
ref, ok := s.signals[signal]
if !ok {
return datasource.ErrNotFound
}
if !ref.ch.writable() {
return datasource.ErrNotWritable
}
ic := s.instruments[ref.instrument]
cmd := formatWrite(ref.ch.WriteCmd, value)
return ic.tr.write(cmd)
}
// History is unavailable for SCPI instruments.
func (s *Scpi) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// Close tears down every instrument connection.
func (s *Scpi) Close() {
for _, ic := range s.instruments {
ic.tr.close()
}
}
// formatWrite substitutes value into the write template. A "%" in the template
// is treated as a printf verb; otherwise the value is appended after a space.
func formatWrite(tmpl string, value any) string {
if strings.Contains(tmpl, "%") {
return fmt.Sprintf(tmpl, value)
}
return fmt.Sprintf("%s %v", tmpl, value)
}
// parseValue converts a raw SCPI response string into the channel's data type.
func parseValue(t datasource.DataType, resp string) (any, error) {
resp = strings.TrimSpace(resp)
switch t {
case datasource.TypeString:
return resp, nil
case datasource.TypeInt64:
// Accept "12", "12.0", or scientific notation by going through float.
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse int %q: %w", resp, err)
}
return int64(f), nil
case datasource.TypeBool:
switch strings.ToUpper(resp) {
case "1", "ON", "TRUE":
return true, nil
case "0", "OFF", "FALSE":
return false, nil
}
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse bool %q: %w", resp, err)
}
return f != 0, nil
default:
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse float %q: %w", resp, err)
}
return f, nil
}
}
+267
View File
@@ -0,0 +1,267 @@
package scpi
import (
"bufio"
"context"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
// mockInstrument is an in-process line-based SCPI server. It answers queries
// from a fixed table and records commands that produce no response (writes).
type mockInstrument struct {
ln net.Listener
mu sync.Mutex
answers map[string]string // query → response
writes []string
}
func newMockInstrument(t *testing.T) *mockInstrument {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
m := &mockInstrument{ln: ln, answers: map[string]string{}}
go m.serve()
t.Cleanup(func() { ln.Close() })
return m
}
func (m *mockInstrument) addr() string { return m.ln.Addr().String() }
func (m *mockInstrument) setAnswer(q, a string) {
m.mu.Lock()
m.answers[q] = a
m.mu.Unlock()
}
func (m *mockInstrument) writeLog() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.writes...)
}
func (m *mockInstrument) serve() {
for {
conn, err := m.ln.Accept()
if err != nil {
return
}
go m.handle(conn)
}
}
func (m *mockInstrument) handle(conn net.Conn) {
defer conn.Close()
br := bufio.NewReader(conn)
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimRight(line, "\r\n")
m.mu.Lock()
if strings.HasSuffix(cmd, "?") {
resp, ok := m.answers[cmd]
if !ok {
resp = "0"
}
m.mu.Unlock()
conn.Write([]byte(resp + "\n"))
continue
}
m.writes = append(m.writes, cmd)
m.mu.Unlock()
}
}
func testCfg(addr string) Config {
return Config{
Enabled: true,
PollIntervalMs: 20,
Instruments: []Instrument{{
Name: "dmm",
Address: addr,
Channels: []Channel{
{Name: "volt", Query: "MEAS:VOLT?", WriteCmd: "VOLT %v", Type: "float", Unit: "V"},
{Name: "id", Query: "*IDN?", Type: "string"},
{Name: "n", Query: "COUNT?", Type: "int"},
{Name: "out", Query: "OUTP?", WriteCmd: "OUTP", Type: "bool"},
},
}},
}
}
func TestQueryTypes(t *testing.T) {
srv := newMockInstrument(t)
srv.setAnswer("MEAS:VOLT?", "12.34")
srv.setAnswer("*IDN?", "ACME,DMM,1,2.0")
srv.setAnswer("COUNT?", "7")
srv.setAnswer("OUTP?", "ON")
s, err := New(testCfg(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer s.Close()
v, err := s.readSignal(s.signals["dmm:volt"])
if err != nil {
t.Fatalf("read volt: %v", err)
}
if f, ok := v.Data.(float64); !ok || f < 12.33 || f > 12.35 {
t.Errorf("volt = %v (%T), want 12.34", v.Data, v.Data)
}
id, _ := s.readSignal(s.signals["dmm:id"])
if id.Data != "ACME,DMM,1,2.0" {
t.Errorf("id = %v", id.Data)
}
n, _ := s.readSignal(s.signals["dmm:n"])
if iv, ok := n.Data.(int64); !ok || iv != 7 {
t.Errorf("n = %v (%T), want 7", n.Data, n.Data)
}
out, _ := s.readSignal(s.signals["dmm:out"])
if b, ok := out.Data.(bool); !ok || !b {
t.Errorf("out = %v, want true", out.Data)
}
}
func TestWrite(t *testing.T) {
srv := newMockInstrument(t)
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx := context.Background()
if err := s.Write(ctx, "dmm:volt", 3.3); err != nil {
t.Fatalf("write volt: %v", err)
}
// bool write uses a template with no verb → "OUTP <val>".
if err := s.Write(ctx, "dmm:out", true); err != nil {
t.Fatalf("write out: %v", err)
}
// Give the server a moment to record both writes.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(srv.writeLog()) >= 2 {
break
}
time.Sleep(5 * time.Millisecond)
}
got := srv.writeLog()
if len(got) != 2 || got[0] != "VOLT 3.3" || got[1] != "OUTP true" {
t.Errorf("writes = %v, want [VOLT 3.3, OUTP true]", got)
}
}
func TestWriteErrors(t *testing.T) {
srv := newMockInstrument(t)
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx := context.Background()
if err := s.Write(ctx, "dmm:missing", 1); err != datasource.ErrNotFound {
t.Errorf("missing = %v, want ErrNotFound", err)
}
if err := s.Write(ctx, "dmm:id", 1); err != datasource.ErrNotWritable {
t.Errorf("read-only = %v, want ErrNotWritable", err)
}
}
func TestSubscribe(t *testing.T) {
srv := newMockInstrument(t)
srv.setAnswer("MEAS:VOLT?", "5.0")
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "dmm:volt", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("quality = %v", v.Quality)
}
if f, ok := v.Data.(float64); !ok || f != 5.0 {
t.Errorf("value = %v, want 5.0", v.Data)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestSubscribeBadQuality(t *testing.T) {
cfg := testCfg("127.0.0.1:1") // refused
s, _ := New(cfg)
defer s.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 1)
stop, _ := s.Subscribe(ctx, "dmm:volt", ch)
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityBad {
t.Errorf("quality = %v, want bad", v.Quality)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestValidation(t *testing.T) {
if _, err := New(Config{Instruments: []Instrument{{Name: "", Address: "x"}}}); err == nil {
t.Error("want error for missing name")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
t.Error("want error for duplicate instrument")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "vxi11"}}}); err == nil {
t.Error("want error for unimplemented vxi11 transport")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "bogus"}}}); err == nil {
t.Error("want error for unknown transport")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Channels: []Channel{{Name: "c"}}}}}); err == nil {
t.Error("want error for channel without query")
}
}
func TestFormatWrite(t *testing.T) {
if got := formatWrite("VOLT %v", 3.3); got != "VOLT 3.3" {
t.Errorf("verb template = %q", got)
}
if got := formatWrite("OUTP", true); got != "OUTP true" {
t.Errorf("plain template = %q", got)
}
}
func TestParseValue(t *testing.T) {
if v, _ := parseValue(datasource.TypeBool, "OFF"); v != false {
t.Errorf("OFF = %v, want false", v)
}
if v, _ := parseValue(datasource.TypeBool, "2.0"); v != true {
t.Errorf("2.0 bool = %v, want true", v)
}
if _, err := parseValue(datasource.TypeFloat64, "notnum"); err == nil {
t.Error("want parse error for non-numeric float")
}
}
+107
View File
@@ -0,0 +1,107 @@
package scpi
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"time"
)
// transport is the request/response channel to an instrument. The raw-socket
// implementation below speaks line-oriented SCPI over TCP (the common
// "SCPI raw" / port 5025 convention). The interface is kept narrow so a VXI-11
// (ONC-RPC) transport can be added later without touching the data source.
type transport interface {
// query sends cmd and returns the instrument's single-line response.
query(cmd string) (string, error)
// write sends cmd and does not wait for a response.
write(cmd string) error
close()
}
// rawSocket is a line-based SCPI transport over a single TCP connection. The
// connection is dialled lazily and dropped on any I/O error so the next call
// reconnects. Calls are serialised by mu because SCPI is request/response.
type rawSocket struct {
addr string
timeout time.Duration
terminator string
mu sync.Mutex
conn net.Conn
br *bufio.Reader
}
func newRawSocket(addr string, timeout time.Duration, terminator string) *rawSocket {
if timeout <= 0 {
timeout = 3 * time.Second
}
if terminator == "" {
terminator = "\n"
}
return &rawSocket{addr: addr, timeout: timeout, terminator: terminator}
}
func (s *rawSocket) dialLocked() error {
if s.conn != nil {
return nil
}
conn, err := net.DialTimeout("tcp", s.addr, s.timeout)
if err != nil {
return fmt.Errorf("scpi: dial %s: %w", s.addr, err)
}
s.conn = conn
s.br = bufio.NewReader(conn)
return nil
}
func (s *rawSocket) closeLocked() {
if s.conn != nil {
_ = s.conn.Close()
s.conn = nil
s.br = nil
}
}
func (s *rawSocket) close() {
s.mu.Lock()
s.closeLocked()
s.mu.Unlock()
}
func (s *rawSocket) sendLocked(cmd string) error {
_ = s.conn.SetDeadline(time.Now().Add(s.timeout))
if _, err := s.conn.Write([]byte(cmd + s.terminator)); err != nil {
s.closeLocked()
return fmt.Errorf("scpi: write: %w", err)
}
return nil
}
func (s *rawSocket) write(cmd string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.dialLocked(); err != nil {
return err
}
return s.sendLocked(cmd)
}
func (s *rawSocket) query(cmd string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.dialLocked(); err != nil {
return "", err
}
if err := s.sendLocked(cmd); err != nil {
return "", err
}
line, err := s.br.ReadString('\n')
if err != nil {
s.closeLocked()
return "", fmt.Errorf("scpi: read: %w", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
+212
View File
@@ -0,0 +1,212 @@
// Package servervar provides a small persistent key/value data source for
// "server variables": named scalar values that the server-side control-logic
// engine writes (e.g. the state of a sequence) and that interface panels can
// read live. Panels may read any variable; writes from panels are gated to
// control-logic editors in the WebSocket write handler.
package servervar
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const fileName = "servervars.json"
type variable struct {
value float64
ts time.Time
}
// Source is the "srv" data source. Variables are created on first write and
// persisted so their last value survives a restart.
type Source struct {
path string
mu sync.RWMutex
vars map[string]*variable
subs map[string]map[int]chan<- datasource.Value
nextID int
}
// New opens (or initialises) the server-variable store under storageDir.
func New(storageDir string) (*Source, error) {
s := &Source{
path: filepath.Join(storageDir, fileName),
vars: map[string]*variable{},
subs: map[string]map[int]chan<- datasource.Value{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Source) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var raw map[string]float64
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
now := time.Now()
for name, v := range raw {
s.vars[name] = &variable{value: v, ts: now}
}
return nil
}
// saveLocked persists the current values atomically. Caller holds s.mu.
func (s *Source) saveLocked() {
raw := make(map[string]float64, len(s.vars))
for name, v := range s.vars {
raw[name] = v.value
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
func meta(name string) datasource.Metadata {
return datasource.Metadata{
Name: name,
Type: datasource.TypeFloat64,
Description: "Server variable",
Writable: true,
}
}
// Name implements datasource.DataSource.
func (s *Source) Name() string { return "srv" }
// Connect is a no-op — the store is opened in New.
func (s *Source) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every defined server variable.
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.vars))
for name := range s.vars {
names = append(names, name)
}
sort.Strings(names)
out := make([]datasource.Metadata, 0, len(names))
for _, name := range names {
out = append(out, meta(name))
}
return out, nil
}
// GetMetadata returns metadata for a single variable. Unknown names still report
// writable metadata so that control logic / authorised panels may create them.
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
return meta(signal), nil
}
// Subscribe registers ch for updates and immediately delivers the current value.
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
if s.subs[signal] == nil {
s.subs[signal] = map[int]chan<- datasource.Value{}
}
id := s.nextID
s.nextID++
s.subs[signal][id] = ch
cur, ok := s.vars[signal]
var first datasource.Value
if ok {
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
}
s.mu.Unlock()
if ok {
go func() {
select {
case ch <- first:
case <-ctx.Done():
}
}()
}
return func() {
s.mu.Lock()
if m := s.subs[signal]; m != nil {
delete(m, id)
if len(m) == 0 {
delete(s.subs, signal)
}
}
s.mu.Unlock()
}, nil
}
func toFloat64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case bool:
if x {
return 1
}
return 0
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
// Write sets a variable (creating it if needed), persists, and fans the new value
// out to all subscribers.
func (s *Source) Write(_ context.Context, signal string, value any) error {
v := toFloat64(value)
s.mu.Lock()
now := time.Now()
s.vars[signal] = &variable{value: v, ts: now}
s.saveLocked()
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
for _, ch := range s.subs[signal] {
subs = append(subs, ch)
}
s.mu.Unlock()
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
for _, ch := range subs {
select {
case ch <- upd:
default:
}
}
return nil
}
// History is not supported.
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
@@ -0,0 +1,74 @@
package servervar
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
func TestWriteSubscribePersist(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
t.Fatalf("Write: %v", err)
}
// Subscribe should deliver the current value immediately.
ch := make(chan datasource.Value, 4)
cancel, err := s.Subscribe(ctx, "seq_state", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer cancel()
select {
case v := <-ch:
if got := v.Data.(float64); got != 3.0 {
t.Fatalf("initial value = %v, want 3", got)
}
case <-time.After(time.Second):
t.Fatal("no initial value delivered")
}
// A later write fans out to the subscriber.
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
t.Fatalf("Write 2: %v", err)
}
select {
case v := <-ch:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("updated value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("no update delivered")
}
// ListSignals reports the variable.
sigs, err := s.ListSignals(ctx)
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
}
// A fresh store over the same dir recovers the last value.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
ch2 := make(chan datasource.Value, 1)
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
defer cancel2()
select {
case v := <-ch2:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("persisted value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("persisted value not delivered")
}
}
+48 -5
View File
@@ -5,6 +5,7 @@ package stub
import (
"context"
"fmt"
"math"
"math/rand/v2"
"sync"
@@ -13,11 +14,12 @@ import (
"github.com/uopi/uopi/internal/datasource"
)
const updateInterval = 100 * time.Millisecond // 10 Hz
const updateInterval = 100 * time.Millisecond // 10 Hz default
type signalDef struct {
meta datasource.Metadata
fn func(t time.Time) any
meta datasource.Metadata
interval time.Duration // 0 → updateInterval
fn func(t time.Time) any
}
// Stub is a data source that emits canned signals for development and testing.
@@ -96,6 +98,41 @@ func New() *Stub {
fn: func(t time.Time) any { return 25.0 }, // default; overwritten by Write
})
s.values["setpoint"] = 25.0
s.register(signalDef{
meta: datasource.Metadata{
Name: "counter_fast", Type: datasource.TypeFloat64,
Description: "Fast monotonic counter (1 ms tick) for benchmarks",
},
interval: time.Millisecond,
fn: func(t time.Time) any { return float64(t.UnixMilli()) },
})
return s
}
// NewN creates a Stub with n dynamically generated signals named "pv_0" … "pv_{n-1}".
// Each signal is a sine wave at a distinct frequency, emitted at 10 Hz.
// Use this for stress and scale testing.
func NewN(n int) *Stub {
s := &Stub{
signals: make(map[string]signalDef),
values: make(map[string]any),
}
for i := range n {
freq := 0.1 + float64(i)*0.01 // spread frequencies so signals differ
s.register(signalDef{
meta: datasource.Metadata{
Name: fmt.Sprintf("pv_%d", i),
Type: datasource.TypeFloat64,
Unit: "V",
DisplayLow: -1,
DisplayHigh: 1,
Description: fmt.Sprintf("Synthetic PV #%d (%.2f Hz sine)", i, freq),
},
fn: func(t time.Time) any {
return math.Sin(2 * math.Pi * freq * float64(t.UnixNano()) / 1e9)
},
})
}
return s
}
@@ -127,16 +164,22 @@ func (s *Stub) GetMetadata(_ context.Context, signal string) (datasource.Metadat
return d.meta, nil
}
// Subscribe starts a 10 Hz ticker that pushes generated values into ch.
// Subscribe starts a ticker that pushes generated values into ch.
// The tick interval is the signal's configured interval (default 10 Hz).
func (s *Stub) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
def, ok := s.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
interval := def.interval
if interval == 0 {
interval = updateInterval
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(updateInterval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
+210
View File
@@ -0,0 +1,210 @@
package stub_test
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/stub"
)
func newStub(t *testing.T) *stub.Stub {
t.Helper()
s := stub.New()
if err := s.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
return s
}
func TestName(t *testing.T) {
if stub.New().Name() != "stub" {
t.Error("Name() should return 'stub'")
}
}
func TestListSignals(t *testing.T) {
s := newStub(t)
sigs, err := s.ListSignals(context.Background())
if err != nil {
t.Fatalf("ListSignals: %v", err)
}
if len(sigs) == 0 {
t.Fatal("expected at least one signal")
}
names := make(map[string]bool)
for _, m := range sigs {
names[m.Name] = true
}
for _, want := range []string{"sine_1hz", "ramp_10s", "toggle_1hz", "noise", "setpoint"} {
if !names[want] {
t.Errorf("signal %q not in ListSignals", want)
}
}
}
func TestGetMetadata(t *testing.T) {
s := newStub(t)
m, err := s.GetMetadata(context.Background(), "sine_1hz")
if err != nil {
t.Fatalf("GetMetadata: %v", err)
}
if m.Type != datasource.TypeFloat64 {
t.Errorf("Type = %v, want TypeFloat64", m.Type)
}
if m.Unit == "" {
t.Error("expected non-empty Unit")
}
}
func TestGetMetadataNotFound(t *testing.T) {
s := newStub(t)
_, err := s.GetMetadata(context.Background(), "no:such:pv")
if err != datasource.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestSubscribeReceivesValues(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "sine_1hz", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("Quality = %v, want Good", v.Quality)
}
if _, ok := v.Data.(float64); !ok {
t.Errorf("Data type = %T, want float64", v.Data)
}
if v.Timestamp.IsZero() {
t.Error("Timestamp is zero")
}
case <-ctx.Done():
t.Fatal("timeout waiting for first value")
}
}
func TestSubscribeUnknown(t *testing.T) {
s := newStub(t)
_, err := s.Subscribe(context.Background(), "no:such", make(chan datasource.Value, 1))
if err != datasource.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestSubscribeCancel(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "ramp_10s", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
// Drain one value to confirm it's running.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for first value")
}
stop() // cancel subscription
// Drain remaining buffered values then confirm channel goes quiet.
time.Sleep(200 * time.Millisecond)
for len(ch) > 0 {
<-ch
}
time.Sleep(300 * time.Millisecond)
if len(ch) > 0 {
t.Error("channel still receiving after stop()")
}
}
func TestWriteSetpoint(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.Write(context.Background(), "setpoint", float64(99.0)); err != nil {
t.Fatalf("Write: %v", err)
}
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "setpoint", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
f, ok := v.Data.(float64)
if !ok {
t.Fatalf("Data type = %T, want float64", v.Data)
}
if f != 99.0 {
t.Errorf("setpoint value = %g, want 99.0", f)
}
case <-ctx.Done():
t.Fatal("timeout waiting for setpoint value")
}
}
func TestWriteReadOnly(t *testing.T) {
s := newStub(t)
err := s.Write(context.Background(), "sine_1hz", float64(0))
if err != datasource.ErrNotWritable {
t.Errorf("want ErrNotWritable, got %v", err)
}
}
func TestWriteNotFound(t *testing.T) {
s := newStub(t)
err := s.Write(context.Background(), "no:such", float64(0))
if err != datasource.ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestHistory(t *testing.T) {
s := newStub(t)
_, err := s.History(context.Background(), "sine_1hz", time.Now().Add(-time.Hour), time.Now(), 100)
if err != datasource.ErrHistoryUnavailable {
t.Errorf("want ErrHistoryUnavailable, got %v", err)
}
}
func TestToggleBool(t *testing.T) {
s := newStub(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "toggle_1hz", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if _, ok := v.Data.(bool); !ok {
t.Errorf("toggle_1hz Data type = %T, want bool", v.Data)
}
case <-ctx.Done():
t.Fatal("timeout")
}
}

Some files were not shown because too many files have changed in this diff Show More