Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 336095c052 | |||
| c53a49e540 | |||
| b6bc9dc2f2 | |||
| 3133e50e09 | |||
| 550ec06bc8 | |||
| 9c1f2685a7 | |||
| 088063d9cd | |||
| a6fa4e7c7c | |||
| 519c1f2df4 | |||
| 3ddffc14d7 | |||
| e76865d132 | |||
| 5012511306 | |||
| 44c8f98e01 | |||
| 05b06d64a4 | |||
| cb4e81beb2 | |||
| 9d9e538a90 | |||
| 9d48292976 | |||
| 91661485ae | |||
| 603574f86f | |||
| b82e8852b3 | |||
| 8f50bc2498 | |||
| 062bb44dba | |||
| f776de378f | |||
| 5578bceea2 | |||
| 2f40a9d1a0 | |||
| 3b8c6540e1 | |||
| 774e28453b | |||
| e4e67ee0c2 | |||
| cf9da3df0a | |||
| 999a1510d4 | |||
| 6f7c90cc98 | |||
| c0f7e662be | |||
| 11120bedca |
+13
@@ -12,3 +12,16 @@ resources
|
||||
.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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all frontend backend backend-debug release catools 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 #
|
||||
@@ -51,6 +51,14 @@ $(CATOOLS): $(GO_SRCS)
|
||||
@mkdir -p dist
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
||||
|
||||
# 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
|
||||
@@ -81,11 +89,18 @@ release: $(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:
|
||||
@@ -94,6 +109,10 @@ 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)
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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:
|
||||
@@ -75,11 +78,11 @@
|
||||
- [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)
|
||||
- [ ] 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)
|
||||
- [ ] panel tree by user / group / global
|
||||
- [ ] signal tree by user / group / global
|
||||
- [ ] config tree by user / group / global
|
||||
- [ ] control sequence by user / group / global
|
||||
- [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)
|
||||
@@ -91,13 +94,14 @@
|
||||
- [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`
|
||||
- [ ] Logic editor:
|
||||
- [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
|
||||
- add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||
- array functions should work with new local array
|
||||
- [ ] Control loop:
|
||||
- [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
|
||||
- add full support to server side array values
|
||||
- [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
|
||||
|
||||
+121
-1
@@ -13,6 +13,7 @@ import (
|
||||
"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"
|
||||
@@ -20,10 +21,14 @@ import (
|
||||
"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"
|
||||
@@ -143,6 +148,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -220,7 +254,93 @@ func main() {
|
||||
ctrlEngine.SetDebugObserver(debugHub)
|
||||
ctrlEngine.Reload()
|
||||
|
||||
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, log)
|
||||
// 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)
|
||||
|
||||
+151
-2
@@ -346,10 +346,40 @@ mount and `clear()` on unmount. The engine subscribes to every referenced signal
|
||||
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, in-memory data arrays (accumulate/export-CSV/clear) and
|
||||
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
|
||||
@@ -359,6 +389,26 @@ and writes results back to signals. Graphs are persisted by a store and managed
|
||||
`/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
|
||||
@@ -384,7 +434,42 @@ instance's `setId` in its `Meta`.
|
||||
|
||||
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. Access is **role-based** through group
|
||||
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:
|
||||
@@ -425,6 +510,39 @@ opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-gr
|
||||
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
|
||||
@@ -534,6 +652,7 @@ Both edit and view modes support mouse-drag panel resizing:
|
||||
- `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`)
|
||||
@@ -622,6 +741,36 @@ storage_dir = "./interfaces"
|
||||
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
|
||||
|
||||
@@ -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 80–100% 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).
|
||||
@@ -11,11 +11,18 @@ require (
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,32 +1,97 @@
|
||||
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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+59
-16
@@ -44,12 +44,13 @@ type Handler struct {
|
||||
audit audit.Recorder // never nil; audit.Nop when disabled
|
||||
channelFinderURL string // empty if not configured
|
||||
archiverURL string // empty if not configured
|
||||
uiDefaultZoom float64 // base UI scale sent to clients; 0 means 1.0
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
@@ -65,6 +66,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg
|
||||
audit: rec,
|
||||
channelFinderURL: channelFinderURL,
|
||||
archiverURL: archiverURL,
|
||||
uiDefaultZoom: uiDefaultZoom,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@@ -192,6 +194,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
defaultZoom := h.uiDefaultZoom
|
||||
if defaultZoom <= 0 {
|
||||
defaultZoom = 1.0
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"user": user,
|
||||
"level": h.policy.Level(user).String(),
|
||||
@@ -199,6 +205,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
"canEditLogic": h.policy.CanEditLogic(user),
|
||||
"canViewAudit": h.policy.CanViewAudit(user),
|
||||
"canAdmin": h.policy.CanAdmin(user),
|
||||
"defaultZoom": defaultZoom,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -366,8 +373,9 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
|
||||
if dsName == "synthetic" && h.synthetic != nil {
|
||||
user := caller(r)
|
||||
panel := r.URL.Query().Get("panel")
|
||||
userGroups := h.policy.GroupsOf(user)
|
||||
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
||||
return synVisible(d, user, panel)
|
||||
return synVisible(d, user, panel, userGroups)
|
||||
})
|
||||
out := make([]signalInfo, len(metas))
|
||||
for i, m := range metas {
|
||||
@@ -398,12 +406,15 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// synVisible reports whether a synthetic signal should be listed for the given
|
||||
// caller while editing the given panel. An empty Visibility is treated as
|
||||
// "global" so legacy definitions remain visible everywhere.
|
||||
func synVisible(d synthetic.SignalDef, user, panel string) bool {
|
||||
// caller (a member of userGroups) while editing the given panel. An empty
|
||||
// Visibility is treated as "global" so legacy definitions remain visible
|
||||
// everywhere.
|
||||
func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool {
|
||||
switch d.Visibility {
|
||||
case "user":
|
||||
return user != "" && d.Owner == user
|
||||
case "group":
|
||||
return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
|
||||
case "panel":
|
||||
return panel != "" && d.Panel == panel
|
||||
default: // "global" or legacy empty
|
||||
@@ -431,13 +442,14 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user := caller(r)
|
||||
panel := r.URL.Query().Get("panel")
|
||||
userGroups := h.policy.GroupsOf(user)
|
||||
|
||||
var out []signalInfo
|
||||
for _, ds := range sources {
|
||||
var metas []datasource.Metadata
|
||||
if ds.Name() == "synthetic" && h.synthetic != nil {
|
||||
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
||||
return synVisible(d, user, panel)
|
||||
return synVisible(d, user, panel, userGroups)
|
||||
})
|
||||
} else {
|
||||
var err error
|
||||
@@ -593,10 +605,33 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
|
||||
// render sharing affordances and filter the visible set.
|
||||
type interfaceListItem struct {
|
||||
storage.InterfaceMeta
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Order float64 `json:"order,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Order float64 `json:"order,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
Scope string `json:"scope,omitempty"` // derived visibility bucket: private|group|global
|
||||
Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with
|
||||
}
|
||||
|
||||
// panelScope derives a uniform visibility token (and, for group scope, the
|
||||
// shared group names) from a panel's ACL record so the selector tree can bucket
|
||||
// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel
|
||||
// (nil record) or any panel exposed publicly is global; otherwise a panel shared
|
||||
// with one or more user-groups is group-scoped; everything else is private.
|
||||
func panelScope(acl *panelacl.PanelACL) (string, []string) {
|
||||
if acl == nil || acl.Public != "" {
|
||||
return access.ScopeGlobal, nil
|
||||
}
|
||||
var groups []string
|
||||
for _, g := range acl.Grants {
|
||||
if g.Kind == "group" {
|
||||
groups = append(groups, g.Name)
|
||||
}
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
return access.ScopeGroup, groups
|
||||
}
|
||||
return access.ScopePrivate, nil
|
||||
}
|
||||
|
||||
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -613,11 +648,13 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
continue // hide panels the caller cannot see
|
||||
}
|
||||
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
|
||||
if acl := h.acl.GetPanel(m.ID); acl != nil {
|
||||
acl := h.acl.GetPanel(m.ID)
|
||||
if acl != nil {
|
||||
item.Owner = acl.Owner
|
||||
item.Folder = acl.Folder
|
||||
item.Order = acl.Order
|
||||
}
|
||||
item.Scope, item.Groups = panelScope(acl)
|
||||
out = append(out, item)
|
||||
}
|
||||
h.log.Info("list interfaces", "count", len(out))
|
||||
@@ -1548,14 +1585,17 @@ func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ── Control logic ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonOK(w, []any{})
|
||||
return
|
||||
}
|
||||
graphs := h.ctrlLogic.List()
|
||||
if graphs == nil {
|
||||
graphs = []controllogic.Graph{}
|
||||
user := caller(r)
|
||||
graphs := []controllogic.Graph{}
|
||||
for _, g := range h.ctrlLogic.List() {
|
||||
if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) {
|
||||
graphs = append(graphs, g)
|
||||
}
|
||||
}
|
||||
jsonOK(w, graphs)
|
||||
}
|
||||
@@ -1588,6 +1628,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
g.ID = genID("cl")
|
||||
g.Owner = caller(r) // stamp ownership from the trusted identity
|
||||
if err := h.ctrlLogic.Save(g); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -1608,7 +1649,8 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := h.ctrlLogic.Get(id); err != nil {
|
||||
prev, err := h.ctrlLogic.Get(id)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
||||
return
|
||||
}
|
||||
@@ -1618,6 +1660,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
g.ID = id
|
||||
g.Owner = prev.Owner // owner is immutable across revisions
|
||||
if err := h.ctrlLogic.Save(g); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 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() {
|
||||
@@ -485,7 +485,7 @@ func adminSetup(t *testing.T) (*httptest.Server, func()) {
|
||||
}
|
||||
|
||||
inner := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1")
|
||||
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) {
|
||||
|
||||
+23
-4
@@ -37,7 +37,7 @@ func configStatus(err error) int {
|
||||
|
||||
// ── config sets ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, sets)
|
||||
jsonOK(w, h.filterConfigMetas(r, sets))
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -92,6 +92,9 @@ func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
@@ -227,7 +230,7 @@ func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
|
||||
|
||||
// ── config instances ────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
@@ -236,7 +239,20 @@ func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, insts)
|
||||
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) {
|
||||
@@ -282,6 +298,9 @@ func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -141,9 +141,9 @@ func TestStress_RapidSubscribeUnsubscribe(t *testing.T) {
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 20
|
||||
nSignals = 20
|
||||
nGoroutines = 50
|
||||
duration = 2 * time.Second
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
brk, cancel := newBrokerN(t, nSignals)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,31 @@ import (
|
||||
"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
|
||||
@@ -65,6 +80,116 @@ type ServerConfig struct {
|
||||
// {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 {
|
||||
@@ -72,6 +197,8 @@ type DatasourceConfig struct {
|
||||
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 {
|
||||
@@ -146,6 +273,45 @@ func applyEnv(cfg *Config) {
|
||||
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")
|
||||
}
|
||||
@@ -170,6 +336,11 @@ func applyEnv(cfg *Config) {
|
||||
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 {
|
||||
|
||||
@@ -112,6 +112,88 @@ func TestEnvOverrides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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("")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,10 @@ type RuleViolation struct {
|
||||
// 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"`
|
||||
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
|
||||
|
||||
@@ -36,18 +36,18 @@ func (t ParamType) valid() bool {
|
||||
// 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
|
||||
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
|
||||
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
|
||||
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"`
|
||||
}
|
||||
@@ -61,6 +61,8 @@ type ConfigSet struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -75,6 +77,8 @@ type ConfigInstance struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,11 @@ type SnapshotEntry struct {
|
||||
// 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"`
|
||||
SetID string `json:"setId"`
|
||||
Entries []SnapshotEntry `json:"entries"`
|
||||
Captured int `json:"captured"`
|
||||
Failed int `json:"failed"`
|
||||
Values map[string]any `json:"-"`
|
||||
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
|
||||
|
||||
@@ -20,9 +20,9 @@ func TestSnapshotCapturesAndCoerces(t *testing.T) {
|
||||
}
|
||||
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: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",
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ type Meta struct {
|
||||
// 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.
|
||||
@@ -84,12 +89,15 @@ func New(storageDir string) (*Store, error) {
|
||||
|
||||
// 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"`
|
||||
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 {
|
||||
@@ -154,7 +162,7 @@ func (s *Store) List(k Kind) ([]Meta, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,15 @@ type writableSource struct {
|
||||
|
||||
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) 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) {
|
||||
@@ -102,7 +103,7 @@ func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
|
||||
|
||||
func TestApplyConfigWritesEveryParam(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
e.applyConfig(cg, instID)
|
||||
|
||||
@@ -116,7 +117,7 @@ func TestApplyConfigWritesEveryParam(t *testing.T) {
|
||||
|
||||
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
|
||||
e, src, _ := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
e.applyConfig(cg, "does-not-exist")
|
||||
|
||||
@@ -143,7 +144,7 @@ func TestReadConfigParam(t *testing.T) {
|
||||
|
||||
func TestWriteConfigParam(t *testing.T) {
|
||||
e, _, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
e.writeConfigParam(cg, instID, "gain", 7.5)
|
||||
|
||||
@@ -162,7 +163,7 @@ func TestWriteConfigParam(t *testing.T) {
|
||||
|
||||
func TestCreateConfigInstance(t *testing.T) {
|
||||
e, _, srcID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
src, err := e.cfg.GetInstance(srcID)
|
||||
if err != nil {
|
||||
@@ -196,7 +197,7 @@ func TestCreateConfigInstance(t *testing.T) {
|
||||
|
||||
func TestSnapshotConfig(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
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)
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
// 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
|
||||
// - 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
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
// 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 float64 `json:"value"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
TS int64 `json:"ts"` // unix millis
|
||||
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)
|
||||
@@ -83,7 +83,7 @@ func (e *Engine) FireTrigger(graphID, triggerID string) bool {
|
||||
|
||||
// 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 float64, hasValue bool) {
|
||||
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] {
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestDebugObserverCapturesNodeValues(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("no event for write node 'w'")
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
if !wEv.HasValue || wEv.Value != 42.0 {
|
||||
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
if wEv.GraphID != "g1" {
|
||||
@@ -136,7 +136,7 @@ func TestFireTriggerForcesRun(t *testing.T) {
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
if !wEv.HasValue || wEv.Value != 42.0 {
|
||||
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestSimulateSuppressesWrites(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("simulate produced no event for write node 'w'")
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
if !wEv.HasValue || wEv.Value != 42.0 {
|
||||
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
if wEv.GraphID != "sim" {
|
||||
|
||||
+154
-15
@@ -98,10 +98,10 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf
|
||||
rec = audit.Nop()
|
||||
}
|
||||
return &Engine{
|
||||
broker: brk,
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
audit: rec,
|
||||
broker: brk,
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
audit: rec,
|
||||
log: log,
|
||||
root: root,
|
||||
live: map[string]float64{},
|
||||
@@ -276,17 +276,51 @@ func (e *Engine) liveGet(ds, name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// setPath assigns v at the nested index path within arr, growing (zero-filling)
|
||||
// as needed and resolving negative indices against length. Adapted from logic.ts;
|
||||
// unlike the single-threaded TS source it copies on descent rather than mutating in
|
||||
// place, because a graph-local slice (and its nested sub-slices) may be read
|
||||
// concurrently by other flow goroutines — in-place mutation of shared backing would
|
||||
// race. Every level returns a freshly allocated slice.
|
||||
func setPath(arr []Value, path []int, v Value) []Value {
|
||||
if len(path) == 0 {
|
||||
return arr
|
||||
}
|
||||
k := path[0]
|
||||
if k < 0 {
|
||||
k = len(arr) + k
|
||||
}
|
||||
if k < 0 {
|
||||
return arr
|
||||
}
|
||||
out := append([]Value{}, arr...) // copy: backing may be shared with the live local
|
||||
for len(out) <= k {
|
||||
out = append(out, 0.0)
|
||||
}
|
||||
if len(path) == 1 {
|
||||
out[k] = v
|
||||
return out
|
||||
}
|
||||
sub, _ := out[k].([]Value)
|
||||
out[k] = setPath(sub, path[1:], v)
|
||||
return out
|
||||
}
|
||||
|
||||
// write applies an action.write/lua-set to a target: a bare name updates a
|
||||
// graph-local var; a ds:name target writes to the data source.
|
||||
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
func (e *Engine) write(cg *compiledGraph, target string, val Value) {
|
||||
ds, name, ok := parseRef(target)
|
||||
if !ok || math.IsNaN(val) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ds == "local" {
|
||||
cg.setLocal(name, val)
|
||||
return
|
||||
}
|
||||
f, isNum := val.(float64)
|
||||
if !isNum || math.IsNaN(f) {
|
||||
return
|
||||
}
|
||||
if cg.dryRun {
|
||||
return // simulate: no real data-source write
|
||||
}
|
||||
@@ -301,11 +335,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(val, 'g', -1, 64),
|
||||
Value: strconv.FormatFloat(f, 'g', -1, 64),
|
||||
Detail: "control logic: " + cg.name,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
if err := src.Write(e.root, name, val); err != nil {
|
||||
if err := src.Write(e.root, name, f); err != nil {
|
||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
@@ -608,7 +642,8 @@ type compiledGraph struct {
|
||||
prevVal map[string]float64 // last value for change triggers
|
||||
hasVal map[string]bool
|
||||
lastFire map[string]int64 // ns wall clock each trigger last fired
|
||||
locals map[string]float64
|
||||
locals map[string]Value
|
||||
decls map[string]StateVar
|
||||
}
|
||||
|
||||
func compile(g Graph) *compiledGraph {
|
||||
@@ -626,12 +661,21 @@ func compile(g Graph) *compiledGraph {
|
||||
prevVal: map[string]float64{},
|
||||
hasVal: map[string]bool{},
|
||||
lastFire: map[string]int64{},
|
||||
locals: map[string]float64{},
|
||||
locals: map[string]Value{},
|
||||
decls: map[string]StateVar{},
|
||||
fireCh: make(chan string, 16),
|
||||
}
|
||||
for _, n := range g.Nodes {
|
||||
cg.byId[n.ID] = n
|
||||
}
|
||||
for _, sv := range g.StateVars {
|
||||
cg.decls[sv.Name] = sv
|
||||
if sv.Type == "array" {
|
||||
cg.locals[sv.Name] = applySizing(parseInitialArray(sv), sv)
|
||||
} else {
|
||||
cg.locals[sv.Name] = parseScalarInitial(sv)
|
||||
}
|
||||
}
|
||||
for _, w := range g.Wires {
|
||||
port := w.FromPort
|
||||
if port == "" {
|
||||
@@ -672,6 +716,13 @@ func compile(g Graph) *compiledGraph {
|
||||
}
|
||||
case "action.write", "action.log":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.array.push":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.array.set":
|
||||
wantExpr(n.param("expr"))
|
||||
wantExpr(n.param("index"))
|
||||
case "action.array.remove":
|
||||
wantExpr(n.param("index"))
|
||||
case "action.lua":
|
||||
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
||||
for _, r := range luaGetRefs(n.param("script")) {
|
||||
@@ -682,18 +733,38 @@ func compile(g Graph) *compiledGraph {
|
||||
return cg
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v float64) {
|
||||
func parseScalarInitial(sv StateVar) float64 {
|
||||
s := strings.TrimSpace(sv.Initial)
|
||||
switch s {
|
||||
case "true":
|
||||
return 1
|
||||
case "false":
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v Value) {
|
||||
cg.stateMu.Lock()
|
||||
if sv, ok := cg.decls[name]; ok && sv.Type == "array" {
|
||||
if arr, isArr := v.([]Value); isArr {
|
||||
v = applySizing(arr, sv)
|
||||
}
|
||||
}
|
||||
cg.locals[name] = v
|
||||
cg.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) getLocal(name string) float64 {
|
||||
func (cg *compiledGraph) getLocal(name string) Value {
|
||||
cg.stateMu.Lock()
|
||||
defer cg.stateMu.Unlock()
|
||||
v, ok := cg.locals[name]
|
||||
if !ok {
|
||||
return 0
|
||||
return 0.0
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -897,7 +968,7 @@ func (cg *compiledGraph) activate(triggerID string) {
|
||||
dt = float64(now-last) / 1e9
|
||||
}
|
||||
|
||||
resolve := func(ds, name string) float64 {
|
||||
resolve := func(ds, name string) Value {
|
||||
switch ds {
|
||||
case "sys":
|
||||
if name == "dt" {
|
||||
@@ -986,7 +1057,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
cg.follow(node.ID, "done", ctx)
|
||||
|
||||
case "action.write":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||
cg.emitDebug(node.ID, val, true)
|
||||
cg.engine.write(cg, node.param("target"), val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
@@ -1057,6 +1128,74 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.push":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
cg.setLocal(name, append(append([]Value{}, cur...), val))
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.set":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
var path []int
|
||||
ok := true
|
||||
for _, s := range strings.Split(node.param("index"), ",") {
|
||||
f := EvalExpr(strings.TrimSpace(s), ctx.resolve)
|
||||
if math.IsNaN(f) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
path = append(path, int(f))
|
||||
}
|
||||
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||
if ok && len(path) > 0 {
|
||||
arr = setPath(arr, path, val)
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.remove":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
i := int(EvalExpr(node.param("index"), ctx.resolve))
|
||||
k := i
|
||||
if k < 0 {
|
||||
k = len(arr) + k
|
||||
}
|
||||
if k >= 0 && k < len(arr) {
|
||||
arr = append(arr[:k], arr[k+1:]...)
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.pop":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
if len(arr) > 0 {
|
||||
arr = arr[:len(arr)-1]
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.clear":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cg.setLocal(name, []Value{}) // setLocal applies sizing (fixed → zero-pad)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
default:
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+367
-61
@@ -2,16 +2,15 @@
|
||||
//
|
||||
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||
// parentheses, and a handful of math functions. Two kinds of variable
|
||||
// reference are resolved live at evaluation time:
|
||||
// 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 (the brace content is split on the
|
||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||
// {ds:name} a data-source signal value (brace content split on FIRST ':').
|
||||
// bareIdent a graph-local state variable (data source "local").
|
||||
//
|
||||
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
|
||||
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
|
||||
// it walks a parsed AST against a caller-supplied Resolver.
|
||||
// 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 (
|
||||
@@ -22,8 +21,8 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Resolver returns the current numeric value of a signal/local reference.
|
||||
type Resolver func(ds, name string) float64
|
||||
// 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 {
|
||||
@@ -33,11 +32,13 @@ type RefLite struct {
|
||||
|
||||
// ── AST ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type exprNode interface{ eval(R Resolver) float64 }
|
||||
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
|
||||
@@ -52,33 +53,84 @@ type callNode struct {
|
||||
args []exprNode
|
||||
}
|
||||
|
||||
func (n numNode) eval(R Resolver) float64 { return n.v }
|
||||
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
|
||||
func (n unNode) eval(R Resolver) float64 {
|
||||
if n.op == "-" {
|
||||
return -n.a.eval(R)
|
||||
func mustNum(v Value) float64 {
|
||||
f, err := asNum(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if n.a.eval(R) == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return f
|
||||
}
|
||||
func (n ternNode) eval(R Resolver) float64 {
|
||||
if n.c.eval(R) != 0 {
|
||||
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) float64 {
|
||||
args := make([]float64, len(n.args))
|
||||
func (n callNode) eval(R Resolver) Value {
|
||||
args := make([]Value, len(n.args))
|
||||
for i, a := range n.args {
|
||||
args[i] = a.eval(R)
|
||||
}
|
||||
return funcs[n.fn](args)
|
||||
// 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) float64 {
|
||||
a, b := n.a.eval(R), n.b.eval(R)
|
||||
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
|
||||
@@ -107,7 +159,7 @@ func (n binNode) eval(R Resolver) float64 {
|
||||
case "||":
|
||||
return boolf(a != 0 || b != 0)
|
||||
}
|
||||
return math.NaN()
|
||||
panic(fmt.Errorf("unknown operator %q", n.op))
|
||||
}
|
||||
|
||||
func boolf(b bool) float64 {
|
||||
@@ -117,15 +169,34 @@ func boolf(b bool) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var funcs = map[string]func([]float64) float64{
|
||||
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]) },
|
||||
"min": func(a []float64) float64 { return minSlice(a) },
|
||||
"max": func(a []float64) float64 { return maxSlice(a) },
|
||||
"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(sign(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]) },
|
||||
@@ -133,27 +204,145 @@ var funcs = map[string]func([]float64) float64{
|
||||
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
|
||||
}
|
||||
|
||||
func minSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Min(m, x)
|
||||
}
|
||||
return m
|
||||
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 maxSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(-1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Max(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func sign(x float64) int {
|
||||
|
||||
func signOf(x float64) int {
|
||||
switch {
|
||||
case x > 0:
|
||||
return 1
|
||||
@@ -164,6 +353,47 @@ func sign(x float64) int {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -223,7 +453,7 @@ func tokenize(src string) ([]tok, error) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune("+-*/%<>!()?:,", c) {
|
||||
if strings.ContainsRune("+-*/%<>!()?:,[]", c) {
|
||||
toks = append(toks, tok{k: string(c)})
|
||||
i++
|
||||
continue
|
||||
@@ -279,7 +509,7 @@ func parse(src string) (exprNode, error) {
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (ps *parser) primary() (exprNode, error) {
|
||||
func (ps *parser) atom() (exprNode, error) {
|
||||
t, ok := ps.peek()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
@@ -292,6 +522,32 @@ func (ps *parser) primary() (exprNode, error) {
|
||||
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, ':')
|
||||
@@ -333,7 +589,7 @@ func (ps *parser) primary() (exprNode, error) {
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := funcs[id]; !ok {
|
||||
if !knownFunc(id) {
|
||||
return nil, fmt.Errorf("unknown function %q", id)
|
||||
}
|
||||
return callNode{fn: id, args: args}, nil
|
||||
@@ -353,6 +609,39 @@ func (ps *parser) primary() (exprNode, error) {
|
||||
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("")
|
||||
@@ -449,8 +738,9 @@ func parseCached(src string) (exprNode, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
|
||||
func EvalExpr(src string, resolve Resolver) float64 {
|
||||
// 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()
|
||||
@@ -458,7 +748,7 @@ func EvalExpr(src string, resolve Resolver) float64 {
|
||||
return safeEval(n, resolve)
|
||||
}
|
||||
|
||||
func safeEval(n exprNode, resolve Resolver) (out float64) {
|
||||
func safeEval(n exprNode, resolve Resolver) (out Value) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = math.NaN()
|
||||
@@ -467,14 +757,23 @@ func safeEval(n exprNode, resolve Resolver) (out float64) {
|
||||
return n.eval(resolve)
|
||||
}
|
||||
|
||||
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
|
||||
// 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, for
|
||||
// subscription. Returns nil for an unparseable expression.
|
||||
// CollectRefs returns every signal/local reference an expression reads.
|
||||
func CollectRefs(src string) []RefLite {
|
||||
root, err := parseCached(src)
|
||||
if err != nil {
|
||||
@@ -496,6 +795,13 @@ func CollectRefs(src string) []RefLite {
|
||||
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:
|
||||
|
||||
@@ -2,11 +2,12 @@ package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalExpr(t *testing.T) {
|
||||
resolve := func(ds, name string) float64 {
|
||||
resolve := func(ds, name string) Value {
|
||||
switch {
|
||||
case ds == "stub" && name == "x":
|
||||
return 10
|
||||
@@ -45,7 +46,7 @@ func TestEvalExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvalExprErrors(t *testing.T) {
|
||||
r := func(ds, name string) float64 { return 0 }
|
||||
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)
|
||||
@@ -84,3 +85,72 @@ func TestEpicsRefSplitFirstColon(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
@@ -60,9 +61,11 @@ func (lr *luaRuntime) ensure() error {
|
||||
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
|
||||
target := s.CheckString(1)
|
||||
ds, name, ok := parseRef(target)
|
||||
var v float64
|
||||
v := math.NaN()
|
||||
if ok && lr.curResolve != nil {
|
||||
v = lr.curResolve(ds, name)
|
||||
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
|
||||
v = f
|
||||
}
|
||||
}
|
||||
s.Push(lua.LNumber(v))
|
||||
return 1
|
||||
|
||||
@@ -61,14 +61,21 @@ type NodeGroup struct {
|
||||
|
||||
// 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")
|
||||
Nodes []Node `json:"nodes"`
|
||||
Wires []Wire `json:"wires"`
|
||||
Groups []NodeGroup `json:"groups,omitempty"`
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -36,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
|
||||
|
||||
@@ -67,14 +67,13 @@ type caChannel struct {
|
||||
|
||||
// EPICS is the Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
|
||||
// caCtx is the CA context created in Connect().
|
||||
Stored as unsafe.Pointer
|
||||
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
|
||||
// because the C type (ca_client_context *) is opaque. Every goroutine
|
||||
// that calls CA functions must call caAttachContext(caCtx) first, because
|
||||
// Go goroutines can run on any OS thread and CA contexts are thread-local.
|
||||
|
||||
@@ -28,9 +28,9 @@ func Available() bool { return true }
|
||||
|
||||
// EPICS is the pure-Go Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
pvNames []string // pre-fetched at connect time for ListSignals
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 (0–255).
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const updateInterval = 100 * time.Millisecond // 10 Hz default
|
||||
|
||||
type signalDef struct {
|
||||
meta datasource.Metadata
|
||||
interval time.Duration // 0 → updateInterval
|
||||
interval time.Duration // 0 → updateInterval
|
||||
fn func(t time.Time) any
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,25 @@ package synthetic
|
||||
|
||||
// SignalDef describes one synthetic signal.
|
||||
type SignalDef struct {
|
||||
Name string `json:"name"`
|
||||
DS string `json:"ds"` // upstream data source name
|
||||
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
Name string `json:"name"`
|
||||
DS string `json:"ds"` // upstream data source name
|
||||
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
|
||||
// Visibility controls who sees this signal in the signal tree:
|
||||
// "global" — listed in every panel's edit mode
|
||||
// "user" — listed in every panel owned by Owner
|
||||
// "group" — listed for Owner and members of any group in Groups
|
||||
// "panel" — listed only when editing the bound Panel
|
||||
// An empty value is treated as "global" for backward compatibility with
|
||||
// definitions created before this field existed.
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Groups []string `json:"groups,omitempty"` // groups for "group" visibility
|
||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||
|
||||
// Version and Tag implement git-style revisioning. Version is bumped on
|
||||
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
// each node's inputs already resolved. Op-node state maps persist across
|
||||
// evaluations (for stateful nodes like moving_average / lua).
|
||||
type runtimeGraph struct {
|
||||
order []*rtNode // topological order (sources first, output last)
|
||||
sources []rtSource // source nodes, in topological order
|
||||
outputID string // id of the output node
|
||||
outType dsp.ValType // best-effort output type (scalar/array/unknown)
|
||||
order []*rtNode // topological order (sources first, output last)
|
||||
sources []rtSource // source nodes, in topological order
|
||||
outputID string // id of the output node
|
||||
outType dsp.ValType // best-effort output type (scalar/array/unknown)
|
||||
}
|
||||
|
||||
type rtNode struct {
|
||||
|
||||
@@ -18,8 +18,8 @@ type seqSource struct {
|
||||
seq []datasource.Value
|
||||
}
|
||||
|
||||
func (s *seqSource) Name() string { return s.name }
|
||||
func (s *seqSource) Connect(context.Context) error { return nil }
|
||||
func (s *seqSource) Name() string { return s.name }
|
||||
func (s *seqSource) Connect(context.Context) error { return nil }
|
||||
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
|
||||
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
|
||||
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWithUserAndUserFrom covers the context identity round-trip, including the
|
||||
// empty-user passthrough and the missing-identity fallback.
|
||||
func TestWithUserAndUserFrom(t *testing.T) {
|
||||
base := context.Background()
|
||||
|
||||
// No identity present.
|
||||
if u, ok := UserFrom(base); ok || u != "" {
|
||||
t.Errorf("UserFrom(empty) = %q,%v want \"\",false", u, ok)
|
||||
}
|
||||
|
||||
// Empty user must not attach a value.
|
||||
if ctx := WithUser(base, ""); ctx != base {
|
||||
t.Error("WithUser with empty user should return the original context")
|
||||
}
|
||||
|
||||
// Real identity round-trips.
|
||||
ctx := WithUser(base, "alice")
|
||||
if u, ok := UserFrom(ctx); !ok || u != "alice" {
|
||||
t.Errorf("UserFrom = %q,%v want alice,true", u, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExprFunctions exercises every built-in function branch of parseCall plus
|
||||
// the two-argument forms and the right-associative power operator.
|
||||
func TestExprFunctions(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
cases := []struct {
|
||||
expr string
|
||||
inputs []float64
|
||||
want float64
|
||||
}{
|
||||
{"exp(a)", []float64{1}, math.E},
|
||||
{"log(a)", []float64{math.E}, 1},
|
||||
{"ln(a)", []float64{math.E}, 1},
|
||||
{"log2(a)", []float64{8}, 3},
|
||||
{"log10(a)", []float64{1000}, 3},
|
||||
{"sqrt(a)", []float64{9}, 3},
|
||||
{"abs(a)", []float64{-4}, 4},
|
||||
{"sin(a)", []float64{0}, 0},
|
||||
{"cos(a)", []float64{0}, 1},
|
||||
{"tan(a)", []float64{0}, 0},
|
||||
{"asin(a)", []float64{1}, math.Pi / 2},
|
||||
{"acos(a)", []float64{1}, 0},
|
||||
{"atan(a)", []float64{1}, math.Pi / 4},
|
||||
{"atan2(a, b)", []float64{1, 1}, math.Pi / 4},
|
||||
{"pow(a, b)", []float64{2, 10}, 1024},
|
||||
{"floor(a)", []float64{2.9}, 2},
|
||||
{"ceil(a)", []float64{2.1}, 3},
|
||||
{"round(a)", []float64{2.5}, 3},
|
||||
{"min(a, b)", []float64{3, 7}, 3},
|
||||
{"max(a, b)", []float64{3, 7}, 7},
|
||||
{"a ^ b", []float64{2, 3}, 8},
|
||||
{"2 ^ 3 ^ 2", []float64{}, 512}, // right-associative: 2^(3^2)
|
||||
{"-a ^ 2", []float64{3}, -9}, // unary minus binds outside power
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.expr, func(t *testing.T) {
|
||||
n := &ExprNode{Expr: tc.expr}
|
||||
got, err := n.Process(tc.inputs, st)
|
||||
if err != nil {
|
||||
t.Fatalf("Process(%q): %v", tc.expr, err)
|
||||
}
|
||||
if math.Abs(got-tc.want) > 1e-9 {
|
||||
t.Errorf("Process(%q) = %v, want %v", tc.expr, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExprFunctionErrors covers the error branches of parseCall/parseFactor.
|
||||
func TestExprFunctionErrors(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
cases := []string{
|
||||
"bogus(a)", // unknown function
|
||||
"sqrt(a", // missing ')'
|
||||
"sqrt(", // empty / unexpected end inside call
|
||||
"@", // unexpected character
|
||||
"(a + 1", // missing closing parenthesis
|
||||
"1.2.3", // invalid number
|
||||
}
|
||||
for _, expr := range cases {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
n := &ExprNode{Expr: expr}
|
||||
if _, err := n.Process([]float64{1}, st); err == nil {
|
||||
t.Errorf("Process(%q): want error", expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayNodeScalarAdapters covers the legacy scalar Node interface
|
||||
// (Type + Process) on the array nodes, which the array-path tests skip.
|
||||
func TestArrayNodeScalarAdapters(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
|
||||
// Reductions: Process treats its float64 inputs as a single-element array,
|
||||
// so each reduction over one value returns that value.
|
||||
reductions := []struct {
|
||||
node ArrayNode
|
||||
typ string
|
||||
}{
|
||||
{&SumNode{}, "sum"},
|
||||
{&MeanNode{}, "mean"},
|
||||
{&MinNode{}, "min"},
|
||||
{&MaxNode{}, "max"},
|
||||
{&IndexNode{I: 0}, "index"},
|
||||
}
|
||||
for _, r := range reductions {
|
||||
t.Run(r.typ, func(t *testing.T) {
|
||||
if r.node.Type() != r.typ {
|
||||
t.Errorf("Type() = %q, want %q", r.node.Type(), r.typ)
|
||||
}
|
||||
got, err := r.node.Process([]float64{42}, st)
|
||||
if err != nil {
|
||||
t.Fatalf("Process: %v", err)
|
||||
}
|
||||
if got != 42 {
|
||||
t.Errorf("Process = %v, want 42", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// LengthNode over a single scalar input → length 1.
|
||||
ln := &LengthNode{}
|
||||
if ln.Type() != "length" {
|
||||
t.Errorf("LengthNode.Type() = %q", ln.Type())
|
||||
}
|
||||
if got, err := ln.Process([]float64{7}, st); err != nil || got != 1 {
|
||||
t.Errorf("LengthNode.Process = %v, %v; want 1", got, err)
|
||||
}
|
||||
|
||||
// SliceNode.Process returns the first element of the resulting slice.
|
||||
sn := &SliceNode{Start: 0, End: 0}
|
||||
if sn.Type() != "slice" {
|
||||
t.Errorf("SliceNode.Type() = %q", sn.Type())
|
||||
}
|
||||
if got, err := sn.Process([]float64{5}, st); err != nil || got != 5 {
|
||||
t.Errorf("SliceNode.Process = %v, %v; want 5", got, err)
|
||||
}
|
||||
|
||||
// FFTNode.Process returns the first magnitude bin (DC term = the value).
|
||||
fn := &FFTNode{}
|
||||
if fn.Type() != "fft" {
|
||||
t.Errorf("FFTNode.Type() = %q", fn.Type())
|
||||
}
|
||||
if got, err := fn.Process([]float64{3}, st); err != nil || math.Abs(got-3) > 1e-9 {
|
||||
t.Errorf("FFTNode.Process = %v, %v; want 3", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayNodeProcessErrors covers the error propagation through the scalar
|
||||
// Process adapters.
|
||||
func TestArrayNodeProcessErrors(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
// IndexNode with an out-of-range index propagates the reduction error.
|
||||
n := &IndexNode{I: 5}
|
||||
if _, err := n.Process([]float64{1}, st); err == nil {
|
||||
t.Error("IndexNode.Process out of range: want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Package ldapauth validates a username/password pair against an LDAP directory
|
||||
// using the standard "search then bind" pattern, exactly as an SSSD/LDAP client
|
||||
// would: connect to the directory, find the user's entry under the configured
|
||||
// search base, then attempt a bind as that entry's DN with the supplied password.
|
||||
//
|
||||
// It is a pure-Go alternative to the PAM backend (internal/pamauth): because it
|
||||
// speaks LDAP over the wire with no cgo, uopi keeps its fully-static
|
||||
// (CGO_ENABLED=0) release binary while still authenticating against the same
|
||||
// directory the host logs in with. It feeds the same HTTP Basic pipeline
|
||||
// (internal/server/basicauth.go) as PAM.
|
||||
//
|
||||
// Unlike PAM it only verifies the password; it does not run the rest of the PAM
|
||||
// stack (account expiry, access.conf, MFA). For a monitoring HMI that is normally
|
||||
// sufficient.
|
||||
package ldapauth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// Config configures the LDAP authenticator. URIs and SearchBase are required; the
|
||||
// remaining fields mirror SSSD defaults so an unconfigured directory (anonymous
|
||||
// search, RFC2307 schema) works out of the box.
|
||||
type Config struct {
|
||||
// URIs are the directory endpoints, tried in order until one connects, e.g.
|
||||
// "ldaps://ldap.example.com" or "ldap://ldap.example.com". Mirrors SSSD's
|
||||
// ldap_uri.
|
||||
URIs []string
|
||||
// SearchBase is the subtree under which user entries are searched. Mirrors
|
||||
// SSSD's ldap_search_base.
|
||||
SearchBase string
|
||||
// UserAttr is the attribute matched against the login name. Empty defaults to
|
||||
// "uid" (SSSD's ldap_user_name default for RFC2307).
|
||||
UserAttr string
|
||||
// UserObjectClass restricts the search to this objectClass. Empty defaults to
|
||||
// "posixAccount" (SSSD's ldap_user_object_class default).
|
||||
UserObjectClass string
|
||||
// BindDN / BindPassword optionally authenticate the *search* (service
|
||||
// account). Empty BindDN performs an anonymous search, matching a directory
|
||||
// configured without ldap_default_bind_dn.
|
||||
BindDN string
|
||||
BindPassword string
|
||||
// StartTLS upgrades a plain ldap:// connection to TLS before any credentials
|
||||
// are sent. Ignored for ldaps:// (already TLS).
|
||||
StartTLS bool
|
||||
// CACertFile is an optional PEM file of CA certs to trust for the TLS
|
||||
// connection (for a directory using a private CA).
|
||||
CACertFile string
|
||||
// InsecureSkipVerify disables TLS certificate verification. Insecure; use only
|
||||
// for testing against a self-signed directory.
|
||||
InsecureSkipVerify bool
|
||||
// Timeout bounds each connection attempt. Zero defaults to 10s.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ErrInvalidCredentials is returned when the directory rejects the user's bind.
|
||||
var ErrInvalidCredentials = errors.New("ldap: invalid credentials")
|
||||
|
||||
// Authenticator validates credentials against a fixed directory configuration.
|
||||
// It is safe for concurrent use: each Authenticate call opens and closes its own
|
||||
// connection.
|
||||
type Authenticator struct {
|
||||
cfg Config
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
// New validates cfg and returns an Authenticator. It fails fast on missing
|
||||
// required fields or an unreadable CA file so misconfiguration surfaces at
|
||||
// startup rather than on the first login.
|
||||
func New(cfg Config) (*Authenticator, error) {
|
||||
if len(cfg.URIs) == 0 {
|
||||
return nil, errors.New("ldap: at least one uri is required")
|
||||
}
|
||||
if cfg.SearchBase == "" {
|
||||
return nil, errors.New("ldap: search_base is required")
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "uid"
|
||||
}
|
||||
if cfg.UserObjectClass == "" {
|
||||
cfg.UserObjectClass = "posixAccount"
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
|
||||
if cfg.CACertFile != "" {
|
||||
pem, err := os.ReadFile(cfg.CACertFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap: reading ca_cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("ldap: ca_cert %q contained no certificates", cfg.CACertFile)
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
|
||||
return &Authenticator{cfg: cfg, tlsConfig: tlsConfig}, nil
|
||||
}
|
||||
|
||||
// Authenticate verifies username/password against the directory. It returns nil
|
||||
// on success, ErrInvalidCredentials when the directory rejects the bind, or
|
||||
// another error on connection/search failure.
|
||||
func (a *Authenticator) Authenticate(username, password string) error {
|
||||
// A bind with a non-empty DN but an empty password is an "unauthenticated
|
||||
// bind" that many servers accept as success — which would let anyone in with a
|
||||
// blank password. Reject empty passwords before we ever bind.
|
||||
if password == "" {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
conn, err := a.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Bind for the search: service account if configured, else anonymous.
|
||||
if a.cfg.BindDN != "" {
|
||||
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil {
|
||||
return fmt.Errorf("ldap: search bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Locate the user's entry. The login name is escaped to prevent LDAP filter
|
||||
// injection.
|
||||
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
|
||||
ldap.EscapeFilter(a.cfg.UserObjectClass),
|
||||
a.cfg.UserAttr,
|
||||
ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
a.cfg.SearchBase,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
2, int(a.cfg.Timeout.Seconds()), false,
|
||||
filter,
|
||||
[]string{"dn"}, nil,
|
||||
)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ldap: search failed: %w", err)
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return ErrInvalidCredentials // unknown user — do not distinguish from bad password
|
||||
}
|
||||
if len(res.Entries) > 1 {
|
||||
return fmt.Errorf("ldap: %q matched %d entries; refusing ambiguous bind", username, len(res.Entries))
|
||||
}
|
||||
userDN := res.Entries[0].DN
|
||||
|
||||
// Verify the password by binding as the user. Use a fresh connection so the
|
||||
// search identity is fully dropped first.
|
||||
userConn, err := a.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer userConn.Close()
|
||||
if err := userConn.Bind(userDN, password); err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return fmt.Errorf("ldap: user bind failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dial connects to the first reachable URI and applies StartTLS when requested.
|
||||
func (a *Authenticator) dial() (*ldap.Conn, error) {
|
||||
var lastErr error
|
||||
for _, uri := range a.cfg.URIs {
|
||||
conn, err := ldap.DialURL(uri, ldap.DialWithTLSConfig(a.tlsConfig))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
conn.SetTimeout(a.cfg.Timeout)
|
||||
if a.cfg.StartTLS {
|
||||
if err := conn.StartTLS(a.tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
lastErr = fmt.Errorf("ldap: starttls on %q: %w", uri, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ldap: could not connect to any uri: %w", lastErr)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ldapauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewRequiresURIAndBase(t *testing.T) {
|
||||
if _, err := New(Config{SearchBase: "dc=x"}); err == nil {
|
||||
t.Fatal("want error for missing uri")
|
||||
}
|
||||
if _, err := New(Config{URIs: []string{"ldap://x"}}); err == nil {
|
||||
t.Fatal("want error for missing search_base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAppliesSSSDDefaults(t *testing.T) {
|
||||
a, err := New(Config{URIs: []string{"ldap://x"}, SearchBase: "dc=x"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if a.cfg.UserAttr != "uid" {
|
||||
t.Errorf("UserAttr default = %q, want uid", a.cfg.UserAttr)
|
||||
}
|
||||
if a.cfg.UserObjectClass != "posixAccount" {
|
||||
t.Errorf("UserObjectClass default = %q, want posixAccount", a.cfg.UserObjectClass)
|
||||
}
|
||||
if a.cfg.Timeout <= 0 {
|
||||
t.Errorf("Timeout default not applied: %v", a.cfg.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty passwords must be rejected before any bind: a non-empty DN + empty
|
||||
// password is an "unauthenticated bind" many servers accept as success.
|
||||
func TestAuthenticateRejectsEmptyPasswordWithoutDialing(t *testing.T) {
|
||||
// An unreachable URI guarantees the test fails loudly if it ever tries to dial.
|
||||
a, err := New(Config{URIs: []string{"ldap://127.0.0.1:1"}, SearchBase: "dc=x"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("alice", ""); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("want ErrInvalidCredentials for empty password, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadCACert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bad := filepath.Join(dir, "ca.pem")
|
||||
if err := os.WriteFile(bad, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: bad}); err == nil {
|
||||
t.Fatal("want error for CA file with no certificates")
|
||||
}
|
||||
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: filepath.Join(dir, "missing.pem")}); err == nil {
|
||||
t.Fatal("want error for missing CA file")
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@ var startTime = time.Now()
|
||||
|
||||
// Counters and gauges — updated by callers in ws.go and api.go.
|
||||
var (
|
||||
wsConns atomic.Int64 // current open WebSocket connections (gauge)
|
||||
msgIn atomic.Int64 // total WS messages received (counter)
|
||||
msgOut atomic.Int64 // total WS messages sent (counter)
|
||||
writeOps atomic.Int64 // total signal write operations (counter)
|
||||
historyReqs atomic.Int64 // total history requests served (counter)
|
||||
wsConns atomic.Int64 // current open WebSocket connections (gauge)
|
||||
msgIn atomic.Int64 // total WS messages received (counter)
|
||||
msgOut atomic.Int64 // total WS messages sent (counter)
|
||||
writeOps atomic.Int64 // total signal write operations (counter)
|
||||
historyReqs atomic.Int64 // total history requests served (counter)
|
||||
)
|
||||
|
||||
// IncWsConns increments the active WebSocket connection gauge.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build pam
|
||||
|
||||
// Package pamauth authenticates a username/password pair against the host's PAM
|
||||
// stack (/etc/pam.d/<service>). It is the backend for uopi's built-in HTTP Basic
|
||||
// authentication: because the uopi host is typically already an SSSD/LDAP client,
|
||||
// validating through PAM reuses the exact same login path as `login`/`ssh`
|
||||
// (pam_sss → the site directory) without uopi needing any directory schema.
|
||||
//
|
||||
// This file is the real implementation, compiled only with the `pam` build tag
|
||||
// (which also requires cgo + libpam). The default fully-static CGO_ENABLED=0
|
||||
// build uses stub.go instead, where Authenticate reports PAM is unavailable.
|
||||
package pamauth
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -lpam
|
||||
#include <security/pam_appl.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// uopiPamConv answers every password-style PAM prompt with the password passed
|
||||
// through appdata_ptr. Informational/error messages get a NULL response. The PAM
|
||||
// library takes ownership of the returned responses and frees them.
|
||||
static int uopiPamConv(int num_msg, const struct pam_message **msg,
|
||||
struct pam_response **resp, void *appdata_ptr) {
|
||||
if (num_msg <= 0 || num_msg > PAM_MAX_NUM_MSG) {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
struct pam_response *r = calloc((size_t)num_msg, sizeof(struct pam_response));
|
||||
if (r == NULL) {
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
for (int i = 0; i < num_msg; i++) {
|
||||
int style = msg[i]->msg_style;
|
||||
if (style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON) {
|
||||
r[i].resp = strdup((const char *)appdata_ptr);
|
||||
if (r[i].resp == NULL) {
|
||||
for (int j = 0; j < i; j++) {
|
||||
free(r[j].resp);
|
||||
}
|
||||
free(r);
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
}
|
||||
r[i].resp_retcode = 0;
|
||||
}
|
||||
*resp = r;
|
||||
return PAM_SUCCESS;
|
||||
}
|
||||
|
||||
// uopiPamAuth runs authentication + account management for service/user using
|
||||
// pass. Returns PAM_SUCCESS or the failing PAM error code.
|
||||
static int uopiPamAuth(const char *service, const char *user, char *pass) {
|
||||
struct pam_conv conv;
|
||||
conv.conv = uopiPamConv;
|
||||
conv.appdata_ptr = (void *)pass;
|
||||
|
||||
pam_handle_t *pamh = NULL;
|
||||
int ret = pam_start(service, user, &conv, &pamh);
|
||||
if (ret != PAM_SUCCESS) {
|
||||
return ret;
|
||||
}
|
||||
ret = pam_authenticate(pamh, PAM_DISALLOW_NULL_AUTHTOK);
|
||||
if (ret == PAM_SUCCESS) {
|
||||
ret = pam_acct_mgmt(pamh, PAM_DISALLOW_NULL_AUTHTOK);
|
||||
}
|
||||
pam_end(pamh, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
// uopiStrerror maps a PAM error code to a human-readable string. Linux-PAM
|
||||
// ignores the handle, so NULL is fine after pam_end.
|
||||
static const char *uopiStrerror(int code) {
|
||||
return pam_strerror(NULL, code);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Available reports whether this build includes PAM support. True here.
|
||||
const Available = true
|
||||
|
||||
// ErrUnavailable is returned by Authenticate in builds without PAM support. It
|
||||
// is declared in both build variants so callers can compare against it.
|
||||
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in")
|
||||
|
||||
// Authenticate verifies username/password against the named PAM service
|
||||
// (/etc/pam.d/<service>). It returns nil on success or an error describing the
|
||||
// PAM failure. It is safe for concurrent use.
|
||||
func Authenticate(service, username, password string) error {
|
||||
cService := C.CString(service)
|
||||
cUser := C.CString(username)
|
||||
cPass := C.CString(password)
|
||||
defer C.free(unsafe.Pointer(cService))
|
||||
defer C.free(unsafe.Pointer(cUser))
|
||||
defer C.free(unsafe.Pointer(cPass))
|
||||
|
||||
if ret := C.uopiPamAuth(cService, cUser, cPass); ret != C.PAM_SUCCESS {
|
||||
return fmt.Errorf("pam: %s", C.GoString(C.uopiStrerror(ret)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !pam
|
||||
|
||||
// Package pamauth authenticates a username/password pair against the host's PAM
|
||||
// stack. This is the stub compiled into the default fully-static
|
||||
// (CGO_ENABLED=0) build, which has no PAM/libpam linkage: Authenticate always
|
||||
// reports PAM is unavailable. Build with `make backend-pam` (CGO_ENABLED=1
|
||||
// -tags pam) to get the real implementation in pam.go.
|
||||
package pamauth
|
||||
|
||||
import "errors"
|
||||
|
||||
// Available reports whether this build includes PAM support. False here.
|
||||
const Available = false
|
||||
|
||||
// ErrUnavailable is returned by Authenticate because this build lacks PAM.
|
||||
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in (rebuild with: make backend-pam)")
|
||||
|
||||
// Authenticate always fails in the non-PAM build.
|
||||
func Authenticate(service, username, password string) error {
|
||||
return ErrUnavailable
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !pam
|
||||
|
||||
package pamauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStubUnavailable verifies the default (non-PAM) build reports PAM as
|
||||
// unavailable and Authenticate always fails with ErrUnavailable.
|
||||
func TestStubUnavailable(t *testing.T) {
|
||||
if Available {
|
||||
t.Error("Available should be false in the non-PAM build")
|
||||
}
|
||||
if err := Authenticate("login", "alice", "secret"); !errors.Is(err, ErrUnavailable) {
|
||||
t.Errorf("Authenticate = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package panelacl
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestPermString covers the Perm→token rendering.
|
||||
func TestPermString(t *testing.T) {
|
||||
cases := map[Perm]string{PermNone: "none", PermRead: "read", PermWrite: "write"}
|
||||
for p, want := range cases {
|
||||
if got := p.String(); got != want {
|
||||
t.Errorf("Perm(%d).String() = %q, want %q", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceAndDeletePanel covers PlacePanel (organizational-only record that
|
||||
// stays open) and DeletePanel (present + absent).
|
||||
func TestPlaceAndDeletePanel(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.CreateFolder("f1", "Folder 1", "", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Placing a legacy panel into a folder must not lock it (no owner record).
|
||||
if err := s.PlacePanel("legacy", "f1", 2.5); err != nil {
|
||||
t.Fatalf("PlacePanel: %v", err)
|
||||
}
|
||||
if got := s.PanelPerm("legacy", "bob", nil); got != PermWrite {
|
||||
t.Errorf("placed legacy panel perm = %v, want write", got)
|
||||
}
|
||||
|
||||
// DeletePanel on a missing record is a no-op success.
|
||||
if err := s.DeletePanel("never"); err != nil {
|
||||
t.Errorf("DeletePanel missing: %v", err)
|
||||
}
|
||||
// DeletePanel on the placed record removes it.
|
||||
if err := s.DeletePanel("legacy"); err != nil {
|
||||
t.Fatalf("DeletePanel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFoldersAndGetFolder covers the folder accessors, FolderPerm on an owner
|
||||
// vs a stranger, and reload from the persisted index.
|
||||
func TestFoldersAndGetFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal("New:", err)
|
||||
}
|
||||
if _, err := s.CreateFolder("root", "Root", "", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all := s.Folders()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("Folders: want 1, got %d", len(all))
|
||||
}
|
||||
if f, err := s.GetFolder("root"); err != nil || f.Name != "Root" {
|
||||
t.Errorf("GetFolder = %+v, %v", f, err)
|
||||
}
|
||||
if _, err := s.GetFolder("ghost"); err != ErrNotFound {
|
||||
t.Errorf("GetFolder missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// Owner has write on the folder; an unrelated user has none.
|
||||
if got := s.FolderPerm("root", "alice", nil); got != PermWrite {
|
||||
t.Errorf("owner FolderPerm = %v, want write", got)
|
||||
}
|
||||
if got := s.FolderPerm("root", "bob", nil); got != PermNone {
|
||||
t.Errorf("stranger FolderPerm = %v, want none", got)
|
||||
}
|
||||
|
||||
// Reload: a fresh Store over the same dir still sees the folder.
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
if _, err := s2.GetFolder("root"); err != nil {
|
||||
t.Errorf("reloaded GetFolder: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ type PanelACL struct {
|
||||
Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
|
||||
Public string `json:"public,omitempty"` // "" | "read" | "write"
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
Order float64 `json:"order,omitempty"` // sort position within its folder
|
||||
Order float64 `json:"order,omitempty"` // sort position within its folder
|
||||
}
|
||||
|
||||
// Folder is a node in the panel-organisation hierarchy. Permissions set on a
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// credCache memoises successful Basic-auth validations for a short TTL. Browsers
|
||||
// resend the Authorization header on every request, so without this each one
|
||||
// would trigger a full PAM round-trip (slow, and a brute-force/lockout risk).
|
||||
// Only positive results are cached, keyed by a salted SHA-256 of user+password
|
||||
// so the cache never holds a recoverable secret. A fresh random salt per process
|
||||
// keeps keys from being precomputable.
|
||||
type credCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
salt []byte
|
||||
entries map[string]time.Time
|
||||
}
|
||||
|
||||
func newCredCache(ttl time.Duration) *credCache {
|
||||
salt := make([]byte, 16)
|
||||
_, _ = rand.Read(salt)
|
||||
return &credCache{ttl: ttl, salt: salt, entries: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
func (c *credCache) key(user, pass string) string {
|
||||
h := sha256.New()
|
||||
h.Write(c.salt)
|
||||
h.Write([]byte(user))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(pass))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// valid reports whether user/pass was validated within the TTL.
|
||||
func (c *credCache) valid(user, pass string) bool {
|
||||
if c.ttl <= 0 {
|
||||
return false
|
||||
}
|
||||
k := c.key(user, pass)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
exp, ok := c.entries[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(c.entries, k)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// store records a successful validation, opportunistically pruning expired keys.
|
||||
func (c *credCache) store(user, pass string) {
|
||||
if c.ttl <= 0 {
|
||||
return
|
||||
}
|
||||
k := c.key(user, pass)
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries[k] = now.Add(c.ttl)
|
||||
if len(c.entries) > 1024 {
|
||||
for kk, exp := range c.entries {
|
||||
if now.After(exp) {
|
||||
delete(c.entries, kk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuth wraps next with HTTP Basic authentication validated by authFn (PAM,
|
||||
// see internal/pamauth). It mirrors kerberosAuth: a successful login writes the
|
||||
// username into userHeader for the existing access pipeline (accessMiddleware /
|
||||
// wsHandler), and any inbound value of userHeader is always discarded first so a
|
||||
// client cannot spoof an identity.
|
||||
//
|
||||
// challenge controls the no/invalid-credentials case:
|
||||
// - challenge=true (REST/page API): reply 401 + WWW-Authenticate: Basic so the
|
||||
// browser prompts for credentials.
|
||||
// - challenge=false (WebSocket upgrade): fall through unauthenticated; the
|
||||
// session resolves to default_user. Browsers cannot show a login dialog for a
|
||||
// WebSocket, but once they have cached credentials from the page's API calls
|
||||
// they resend them on the upgrade, so the validated path is still taken.
|
||||
func basicAuth(authFn func(user, pass string) error, userHeader, realm string, challenge bool, cache *credCache, log *slog.Logger, next http.Handler) http.Handler {
|
||||
if realm == "" {
|
||||
realm = "uopi"
|
||||
}
|
||||
challengeValue := `Basic realm="` + realm + `", charset="UTF-8"`
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Never trust a client-supplied identity header; only a validated login sets it.
|
||||
r.Header.Del(userHeader)
|
||||
|
||||
if user, pass, ok := r.BasicAuth(); ok && user != "" {
|
||||
if cache.valid(user, pass) {
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if err := authFn(user, pass); err == nil {
|
||||
cache.store(user, pass)
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
} else {
|
||||
log.Warn("basic auth failed", "user", user, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// No or invalid credentials.
|
||||
if challenge {
|
||||
w.Header().Set("WWW-Authenticate", challengeValue)
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r) // best-effort (WebSocket): downstream → default_user
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testUserHeader = "X-Uopi-User"
|
||||
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// echoUser is a terminal handler that reports the resolved identity header so a
|
||||
// test can assert what basicAuth stamped.
|
||||
func echoUser() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, r.Header.Get(testUserHeader))
|
||||
})
|
||||
}
|
||||
|
||||
func basicReq(t *testing.T, user, pass string, setHeader string) *http.Request {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
if user != "" || pass != "" {
|
||||
r.SetBasicAuth(user, pass)
|
||||
}
|
||||
if setHeader != "" {
|
||||
r.Header.Set(testUserHeader, setHeader) // a spoof attempt
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// authFn that accepts only alice/secret.
|
||||
func aliceAuth(calls *int32) func(string, string) error {
|
||||
return func(user, pass string) error {
|
||||
atomic.AddInt32(calls, 1)
|
||||
if user == "alice" && pass == "secret" {
|
||||
return nil
|
||||
}
|
||||
return pamErr{}
|
||||
}
|
||||
}
|
||||
|
||||
type pamErr struct{}
|
||||
|
||||
func (pamErr) Error() string { return "bad credentials" }
|
||||
|
||||
func TestBasicAuthChallengesWithoutCredentials(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
|
||||
t.Fatalf("missing WWW-Authenticate challenge header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthValidCredentialsStampUser(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
// Client also tries to spoof the identity header; it must be ignored.
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "secret", "root"))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "alice" {
|
||||
t.Fatalf("want resolved user alice, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthInvalidCredentialsRejected(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "wrong", ""))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthBestEffortFallsThrough(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
// challenge=false (WebSocket path): no creds → pass through unauthenticated.
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", false, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 pass-through, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "" {
|
||||
t.Fatalf("want empty identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthCachesSuccess(t *testing.T) {
|
||||
var calls int32
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(&calls), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "secret", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("req %d: want 200, got %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("want authFn called once (cached), got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ type DebugHub struct {
|
||||
seq uint64 // unique simulate route ids
|
||||
|
||||
mu sync.Mutex
|
||||
subs map[*wsClient]*debugSub // one debug sub per client
|
||||
routes map[string]map[*wsClient]struct{} // route id → watching clients
|
||||
liveCount map[string]int // live-subscribed graph id → count
|
||||
subs map[*wsClient]*debugSub // one debug sub per client
|
||||
routes map[string]map[*wsClient]struct{} // route id → watching clients
|
||||
liveCount map[string]int // live-subscribed graph id → count
|
||||
}
|
||||
|
||||
type debugSub struct {
|
||||
@@ -55,12 +55,12 @@ func NewDebugHub(engine *controllogic.Engine, log *slog.Logger) *DebugHub {
|
||||
|
||||
// debugOut is the client-bound JSON form of a node-execution event.
|
||||
type debugOut struct {
|
||||
Type string `json:"type"` // always "debugNode"
|
||||
GraphID string `json:"graphId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Value float64 `json:"value"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
TS int64 `json:"ts"`
|
||||
Type string `json:"type"` // always "debugNode"
|
||||
GraphID string `json:"graphId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Value any `json:"value"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// Observe implements controllogic.DebugObserver: push the event to every client
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// accessMiddleware //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// okHandler records that it ran and returns 200.
|
||||
func okHandler(ran *bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*ran = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareWriteUserPassesMutation(t *testing.T) {
|
||||
// A configured policy with an operator (write) user.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
|
||||
req.Header.Set(testUserHeader, "alice")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || !ran {
|
||||
t.Errorf("write user POST: code=%d ran=%v, want 200/true", rec.Code, ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareReadUserBlockedFromMutation(t *testing.T) {
|
||||
// Configured policy → an unlisted user is a read-only viewer.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
|
||||
req.Header.Set(testUserHeader, "bob") // viewer
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("read-only POST: code=%d, want 403", rec.Code)
|
||||
}
|
||||
if ran {
|
||||
t.Error("handler must not run for a forbidden mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareReadUserAllowedToGet(t *testing.T) {
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/interfaces", nil)
|
||||
req.Header.Set(testUserHeader, "bob") // viewer
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || !ran {
|
||||
t.Errorf("read-only GET: code=%d ran=%v, want 200/true", rec.Code, ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareMeAlwaysReachable(t *testing.T) {
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
// Even a mutating method on /me is allowed through (identity discovery).
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
|
||||
req.Header.Set(testUserHeader, "bob")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || !ran {
|
||||
t.Errorf("/me must always be reachable: code=%d ran=%v", rec.Code, ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareStoresUserOnContext(t *testing.T) {
|
||||
policy := access.New("", nil) // unconfigured → everyone write
|
||||
var got string
|
||||
h := accessMiddleware(policy, testUserHeader, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = access.UserFrom(r.Context())
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/foo", nil)
|
||||
req.Header.Set(testUserHeader, "carol")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if got != "carol" {
|
||||
t.Errorf("context user = %q, want carol", got)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// httpsRedirectHandler //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestHTTPSRedirectHandler(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tlsAddr string
|
||||
host string
|
||||
target string
|
||||
want string
|
||||
}{
|
||||
{"default port omitted", ":443", "example.com", "/panels?x=1", "https://example.com/panels?x=1"},
|
||||
{"custom port preserved", ":8443", "host.local:80", "/a", "https://host.local:8443/a"},
|
||||
{"root path", "0.0.0.0:443", "h", "/", "https://h/"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := httpsRedirectHandler(c.tlsAddr)
|
||||
req := httptest.NewRequest(http.MethodGet, c.target, nil)
|
||||
req.Host = c.host
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("code = %d, want 301", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != c.want {
|
||||
t.Errorf("Location = %q, want %q", loc, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// parseDialogTarget //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestParseDialogTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ds, name string
|
||||
ok bool
|
||||
}{
|
||||
{"epics:SR:CURRENT", "epics", "SR:CURRENT", true}, // splits on first ':' only
|
||||
{"bareName", "srv", "bareName", true}, // defaults to srv
|
||||
{" spaced ", "srv", "spaced", true},
|
||||
{"", "", "", false},
|
||||
{" ", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ds, name, ok := parseDialogTarget(c.in)
|
||||
if ds != c.ds || name != c.name || ok != c.ok {
|
||||
t.Errorf("parseDialogTarget(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
||||
c.in, ds, name, ok, c.ds, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// formatAuditValue //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestFormatAuditValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"hello", "hello"},
|
||||
{float64(3.5), "3.5"},
|
||||
{float64(42), "42"},
|
||||
{true, "true"},
|
||||
{false, "false"},
|
||||
{nil, ""},
|
||||
{[]any{1.0, 2.0}, "[1,2]"}, // default → JSON
|
||||
{map[string]any{"a": 1.0}, `{"a":1}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatAuditValue(c.in); got != c.want {
|
||||
t.Errorf("formatAuditValue(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// clientIP //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
t.Run("X-Forwarded-For first hop", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1")
|
||||
if got := clientIP(req); got != "203.0.113.7" {
|
||||
t.Errorf("got %q, want 203.0.113.7", got)
|
||||
}
|
||||
})
|
||||
t.Run("X-Real-IP", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Real-IP", "198.51.100.4")
|
||||
if got := clientIP(req); got != "198.51.100.4" {
|
||||
t.Errorf("got %q, want 198.51.100.4", got)
|
||||
}
|
||||
})
|
||||
t.Run("RemoteAddr fallback", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "192.0.2.9:54321"
|
||||
if got := clientIP(req); got != "192.0.2.9" {
|
||||
t.Errorf("got %q, want 192.0.2.9", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
stdlog "log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jcmturner/goidentity/v6"
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/jcmturner/gokrb5/v8/service"
|
||||
"github.com/jcmturner/gokrb5/v8/spnego"
|
||||
)
|
||||
|
||||
// internalUserHeader is the request header the built-in authentication
|
||||
// middlewares (Kerberos, Basic) use to hand the validated username to the
|
||||
// downstream access pipeline when no external TrustedUserHeader is configured. It
|
||||
// is always stripped from inbound requests before validation, so a client cannot
|
||||
// spoof it.
|
||||
const internalUserHeader = "X-Uopi-User"
|
||||
|
||||
// kerberosAuth wraps next with SPNEGO/Kerberos ("Negotiate") authentication. On a
|
||||
// successful handshake the authenticated principal's short username (realm
|
||||
// stripped) is written into userHeader, so the existing access pipeline
|
||||
// (accessMiddleware / wsHandler, which both read userHeader) resolves identity
|
||||
// uniformly whether it originated from a trusted proxy header or a Kerberos
|
||||
// ticket.
|
||||
//
|
||||
// challenge controls behaviour when a request carries no valid Negotiate
|
||||
// credentials:
|
||||
// - challenge=true (REST/page requests): delegate to gokrb5, which replies
|
||||
// 401 + WWW-Authenticate: Negotiate so the browser performs SPNEGO.
|
||||
// - challenge=false (WebSocket upgrades): fall through unauthenticated.
|
||||
// Browsers cannot attach an Authorization header when opening a WebSocket;
|
||||
// they only send Negotiate proactively to trusted URIs. When the header is
|
||||
// present we still validate it, otherwise the session resolves to
|
||||
// default_user exactly as before.
|
||||
//
|
||||
// Any inbound value of userHeader is always discarded before validation: with
|
||||
// native Kerberos there is no trusted proxy stripping client-supplied headers, so
|
||||
// only the SPNEGO-validated identity may set it.
|
||||
func kerberosAuth(kt *keytab.Keytab, spn, userHeader string, challenge bool, log *slog.Logger, next http.Handler) http.Handler {
|
||||
opts := []func(*service.Settings){
|
||||
service.Logger(stdlog.New(slogWriter{log: log}, "", 0)),
|
||||
}
|
||||
if spn != "" {
|
||||
opts = append(opts, service.KeytabPrincipal(spn))
|
||||
}
|
||||
|
||||
// inner runs only after a successful SPNEGO handshake: it copies the resolved
|
||||
// identity into userHeader and continues down the chain.
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := goidentity.FromHTTPRequestContext(r)
|
||||
if id == nil {
|
||||
http.Error(w, "kerberos: missing identity", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user := id.UserName()
|
||||
if i := strings.IndexByte(user, '@'); i >= 0 {
|
||||
user = user[:i]
|
||||
}
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
validate := spnego.SPNEGOKRB5Authenticate(inner, kt, opts...)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Never trust a client-supplied identity header under native Kerberos.
|
||||
r.Header.Del(userHeader)
|
||||
|
||||
negotiate := strings.HasPrefix(r.Header.Get("Authorization"), spnego.HTTPHeaderAuthResponseValueKey)
|
||||
if !negotiate && !challenge {
|
||||
// Best-effort path (WebSocket without proactive credentials): continue
|
||||
// unauthenticated; downstream resolves the session to default_user.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Credentials are present (validate them) or a challenge is required.
|
||||
validate.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// slogWriter adapts the std logger gokrb5 expects to slog at debug level so SPNEGO
|
||||
// validation diagnostics surface without polluting normal output.
|
||||
type slogWriter struct{ log *slog.Logger }
|
||||
|
||||
func (s slogWriter) Write(p []byte) (int, error) {
|
||||
if s.log != nil {
|
||||
s.log.Debug("kerberos", "msg", strings.TrimRight(string(p), "\n"))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
+105
-10
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
@@ -23,18 +25,35 @@ import (
|
||||
const apiPrefix = "/api/v1"
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
log *slog.Logger
|
||||
httpServer *http.Server
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
// synth may be nil if the synthetic data source is not enabled.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
//
|
||||
// basicAuthFn, when non-nil, enables built-in HTTP Basic authentication validated
|
||||
// by that function (typically PAM): REST requests are challenged (401 Basic) and
|
||||
// WebSocket upgrades validate proactively-resent credentials best-effort. It is
|
||||
// mutually exclusive with Kerberos (krbKeytab). When tlsCert and tlsKey are both
|
||||
// set the server serves HTTPS via ListenAndServeTLS.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, krbKeytab *keytab.Keytab, krbSPN string, basicAuthFn func(user, pass string) error, basicAuthRealm, tlsCert, tlsKey, tlsRedirect string, uiDefaultZoom float64, log *slog.Logger) *Server {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// When built-in authentication (Kerberos or Basic) is enabled but no external
|
||||
// proxy header is configured, use an internal header to carry the validated
|
||||
// username downstream.
|
||||
userHeader := trustedUserHeader
|
||||
if (krbKeytab != nil || basicAuthFn != nil) && userHeader == "" {
|
||||
userHeader = internalUserHeader
|
||||
}
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -42,7 +61,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug})
|
||||
var wsHandlerH http.Handler = &wsHandler{broker: brk, log: log, userHeader: userHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug}
|
||||
|
||||
// Prometheus-format metrics
|
||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||
@@ -50,11 +69,37 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
// REST API — registered on a dedicated mux so it can be wrapped with the
|
||||
// access-control middleware (identity resolution + global level enforcement).
|
||||
apiMux := http.NewServeMux()
|
||||
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix)
|
||||
var apiHandler http.Handler = accessMiddleware(policy, userHeader, apiMux)
|
||||
|
||||
// Native SPNEGO/Kerberos authentication (optional). REST/page requests are
|
||||
// challenged (401 Negotiate); WebSocket upgrades validate proactively-sent
|
||||
// credentials best-effort. Both stash the validated username in userHeader.
|
||||
var frontendHandler http.Handler = http.FileServerFS(webFS)
|
||||
|
||||
if krbKeytab != nil {
|
||||
apiHandler = kerberosAuth(krbKeytab, krbSPN, userHeader, true, log, apiHandler)
|
||||
wsHandlerH = kerberosAuth(krbKeytab, krbSPN, userHeader, false, log, wsHandlerH)
|
||||
} else if basicAuthFn != nil {
|
||||
// Built-in HTTP Basic authentication (validated by basicAuthFn, e.g. PAM).
|
||||
// A shared positive-result cache spares a validation round-trip on every
|
||||
// browser request. REST is challenged; WebSocket upgrades are best-effort.
|
||||
cache := newCredCache(5 * time.Minute)
|
||||
apiHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, apiHandler)
|
||||
wsHandlerH = basicAuth(basicAuthFn, userHeader, basicAuthRealm, false, cache, log, wsHandlerH)
|
||||
// Challenge the top-level page load too. Browsers only show the native
|
||||
// Basic login dialog in response to a 401 on a navigation, not on the
|
||||
// SPA's background fetch('/api/v1/me'); without this the user is never
|
||||
// prompted and silently resolves to default_user. Once credentials are
|
||||
// entered the browser caches them for the origin and resends them on
|
||||
// every asset, API call and the WebSocket upgrade.
|
||||
frontendHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, frontendHandler)
|
||||
}
|
||||
mux.Handle("/ws", wsHandlerH)
|
||||
mux.Handle(apiPrefix+"/", apiHandler)
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
mux.Handle("/", http.FileServerFS(webFS))
|
||||
mux.Handle("/", frontendHandler)
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
@@ -64,7 +109,10 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
},
|
||||
log: log,
|
||||
tlsCert: tlsCert,
|
||||
tlsKey: tlsKey,
|
||||
tlsRedirect: tlsRedirect,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,15 +156,40 @@ func accessMiddleware(policy *access.Policy, userHeader string, next http.Handle
|
||||
|
||||
// Start listens and serves until ctx is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr)
|
||||
tls := s.tlsCert != "" && s.tlsKey != ""
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr, "tls", tls)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
var err error
|
||||
if tls {
|
||||
err = s.httpServer.ListenAndServeTLS(s.tlsCert, s.tlsKey)
|
||||
} else {
|
||||
err = s.httpServer.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Optional plain-HTTP redirector: upgrade http:// visitors to the HTTPS
|
||||
// service instead of letting them hit the TLS port with a cleartext request.
|
||||
var redirectSrv *http.Server
|
||||
if tls && s.tlsRedirect != "" {
|
||||
redirectSrv = &http.Server{
|
||||
Addr: s.tlsRedirect,
|
||||
Handler: httpsRedirectHandler(s.httpServer.Addr),
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
s.log.Info("http→https redirect listening", "addr", s.tlsRedirect)
|
||||
go func() {
|
||||
if err := redirectSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
@@ -124,6 +197,28 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.log.Info("shutting down")
|
||||
if redirectSrv != nil {
|
||||
_ = redirectSrv.Shutdown(shutCtx)
|
||||
}
|
||||
return s.httpServer.Shutdown(shutCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// httpsRedirectHandler 301-redirects any plain-HTTP request to the HTTPS service.
|
||||
// It preserves the requested hostname and path, swapping in the TLS listener's
|
||||
// port (omitted when 443).
|
||||
func httpsRedirectHandler(tlsAddr string) http.Handler {
|
||||
_, tlsPort, _ := net.SplitHostPort(tlsAddr)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
target := "https://" + host
|
||||
if tlsPort != "" && tlsPort != "443" {
|
||||
target += ":" + tlsPort
|
||||
}
|
||||
target += r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,11 +197,11 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// ── wsClient ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
broker *broker.Broker
|
||||
log *slog.Logger
|
||||
user string // end-user identity from the trusted proxy header ("" if none)
|
||||
ip string // client address, for audit attribution
|
||||
conn *websocket.Conn
|
||||
broker *broker.Broker
|
||||
log *slog.Logger
|
||||
user string // end-user identity from the trusted proxy header ("" if none)
|
||||
ip string // client address, for audit attribution
|
||||
policy *access.Policy // global access-level enforcement
|
||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
|
||||
// mkVersioned creates an interface and pushes it to version 3 by updating
|
||||
// twice, returning the id. After this the store holds: current (v3) plus
|
||||
// backups id.v1.xml and id.v2.xml.
|
||||
func mkVersioned(t *testing.T) (*storage.Store, string) {
|
||||
t.Helper()
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(`<interface id="vers" name="V1" version="1"><widget/></interface>`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := s.Update(id, []byte(`<interface id="vers" name="V2" version="1"><widget/></interface>`), "second"); err != nil {
|
||||
t.Fatalf("Update→v2: %v", err)
|
||||
}
|
||||
if err := s.Update(id, []byte(`<interface id="vers" name="V3" version="1"><widget/></interface>`), ""); err != nil {
|
||||
t.Fatalf("Update→v3: %v", err)
|
||||
}
|
||||
return s, id
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Groups //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestReadGroupsDefaultsEmptyArray(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data, err := s.ReadGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadGroups: %v", err)
|
||||
}
|
||||
if string(data) != "[]" {
|
||||
t.Errorf("ReadGroups default = %q, want %q", data, "[]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadGroups(t *testing.T) {
|
||||
s := newStore(t)
|
||||
want := `[{"id":"a","name":"Area A"}]`
|
||||
if err := s.WriteGroups([]byte(want)); err != nil {
|
||||
t.Fatalf("WriteGroups: %v", err)
|
||||
}
|
||||
got, err := s.ReadGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadGroups: %v", err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("ReadGroups = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Versions //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestVersionsNewestFirstWithCurrentFlag(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
vs, err := s.Versions(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(vs) != 3 {
|
||||
t.Fatalf("expected 3 versions, got %d", len(vs))
|
||||
}
|
||||
if vs[0].Version != 3 || vs[1].Version != 2 || vs[2].Version != 1 {
|
||||
t.Errorf("not newest-first: %d, %d, %d", vs[0].Version, vs[1].Version, vs[2].Version)
|
||||
}
|
||||
if !vs[0].Current {
|
||||
t.Error("v3 should be flagged Current")
|
||||
}
|
||||
if vs[1].Current || vs[2].Current {
|
||||
t.Error("backup revisions must not be flagged Current")
|
||||
}
|
||||
// The "second" tag was stamped on the revision that became backup v2.
|
||||
if vs[1].Tag != "second" {
|
||||
t.Errorf("v2 tag = %q, want %q", vs[1].Tag, "second")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionsNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Versions("ghost"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionsInvalidID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Versions("../x"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Versions bad ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// GetVersion //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestGetVersionCurrentAndBackup(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
|
||||
cur, err := s.GetVersion(id, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVersion(3): %v", err)
|
||||
}
|
||||
if !strings.Contains(string(cur), `name="V3"`) {
|
||||
t.Errorf("GetVersion(3) wrong content: %s", cur)
|
||||
}
|
||||
|
||||
old, err := s.GetVersion(id, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVersion(1): %v", err)
|
||||
}
|
||||
if !strings.Contains(string(old), `name="V1"`) {
|
||||
t.Errorf("GetVersion(1) wrong content: %s", old)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersionMissingRevision(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if _, err := s.GetVersion(id, 99); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("GetVersion(99): want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersionMissingInterface(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.GetVersion("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("GetVersion missing iface: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// SetVersionTag //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestSetVersionTagOnBackupAndClear(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
|
||||
// Tag a backup revision in place (no new revision created).
|
||||
if err := s.SetVersionTag(id, 1, "milestone"); err != nil {
|
||||
t.Fatalf("SetVersionTag(1): %v", err)
|
||||
}
|
||||
vs, _ := s.Versions(id)
|
||||
if len(vs) != 3 {
|
||||
t.Fatalf("tagging must not add a revision: got %d", len(vs))
|
||||
}
|
||||
var v1 *storage.VersionMeta
|
||||
for i := range vs {
|
||||
if vs[i].Version == 1 {
|
||||
v1 = &vs[i]
|
||||
}
|
||||
}
|
||||
if v1 == nil || v1.Tag != "milestone" {
|
||||
t.Fatalf("v1 tag not set: %+v", v1)
|
||||
}
|
||||
|
||||
// Clearing the tag.
|
||||
if err := s.SetVersionTag(id, 1, ""); err != nil {
|
||||
t.Fatalf("SetVersionTag clear: %v", err)
|
||||
}
|
||||
vs, _ = s.Versions(id)
|
||||
for _, v := range vs {
|
||||
if v.Version == 1 && v.Tag != "" {
|
||||
t.Errorf("v1 tag should be cleared, got %q", v.Tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVersionTagCurrent(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.SetVersionTag(id, 3, "live"); err != nil {
|
||||
t.Fatalf("SetVersionTag(3): %v", err)
|
||||
}
|
||||
vs, _ := s.Versions(id)
|
||||
if vs[0].Version != 3 || vs[0].Tag != "live" {
|
||||
t.Errorf("current tag not applied: %+v", vs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVersionTagMissing(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.SetVersionTag(id, 99, "x"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("SetVersionTag missing rev: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if err := s.SetVersionTag("../bad", 1, "x"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("SetVersionTag bad ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Promote //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPromoteIsNonDestructive(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
|
||||
// Promote v1 — its content becomes the new current revision (v4), while
|
||||
// every prior revision is preserved.
|
||||
if err := s.Promote(id, 1); err != nil {
|
||||
t.Fatalf("Promote(1): %v", err)
|
||||
}
|
||||
vs, err := s.Versions(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions after promote: %v", err)
|
||||
}
|
||||
if len(vs) != 4 {
|
||||
t.Fatalf("expected 4 versions after promote, got %d", len(vs))
|
||||
}
|
||||
if vs[0].Version != 4 || !vs[0].Current {
|
||||
t.Errorf("new current should be v4: %+v", vs[0])
|
||||
}
|
||||
if vs[0].Name != "V1" {
|
||||
t.Errorf("promoted content should be V1's, got name %q", vs[0].Name)
|
||||
}
|
||||
if vs[0].Tag != "restored from v1" {
|
||||
t.Errorf("promote tag = %q", vs[0].Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteMissing(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.Promote(id, 99); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Promote missing rev: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Fork //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestForkResetsVersionAndClearsTag(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.SetVersionTag(id, 2, "tagged"); err != nil {
|
||||
t.Fatalf("SetVersionTag: %v", err)
|
||||
}
|
||||
|
||||
newID, err := s.Fork(id, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork(2): %v", err)
|
||||
}
|
||||
if newID == id {
|
||||
t.Fatal("Fork must produce a distinct ID")
|
||||
}
|
||||
|
||||
data, err := s.Get(newID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(fork): %v", err)
|
||||
}
|
||||
str := string(data)
|
||||
if !strings.Contains(str, `name="V2"`) {
|
||||
t.Errorf("fork should carry V2 content: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `version="1"`) {
|
||||
t.Errorf("fork version should reset to 1: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `id="`+newID+`"`) {
|
||||
t.Errorf("fork id attr should be stamped: %s", str)
|
||||
}
|
||||
if strings.Contains(str, "tagged") {
|
||||
t.Errorf("fork should clear the tag: %s", str)
|
||||
}
|
||||
|
||||
// The fork is an independent, listable interface at v1.
|
||||
vs, err := s.Versions(newID)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions(fork): %v", err)
|
||||
}
|
||||
if len(vs) != 1 || vs[0].Version != 1 {
|
||||
t.Errorf("fork should have a single v1 revision, got %+v", vs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkMissing(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Fork("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Create: slug derivation + attribute stamping //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestCreateSlugifiesNameWhenNoID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(`<interface name="My Cool Panel!" version="1"/>`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id != "my-cool-panel" {
|
||||
t.Errorf("slug id = %q, want %q", id, "my-cool-panel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEmptyNameFallsBackToInterface(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(`<interface name="" version="1"/>`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id != "interface" {
|
||||
t.Errorf("fallback id = %q, want %q", id, "interface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStampsMissingVersionAndID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
// XML carries neither id nor version attributes — Create must insert both.
|
||||
id, err := s.Create([]byte(`<interface name="Stamp Me"><widget/></interface>`), "label")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
str := string(data)
|
||||
if !strings.Contains(str, `version="1"`) {
|
||||
t.Errorf("Create should stamp version=1: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `id="`+id+`"`) {
|
||||
t.Errorf("Create should stamp id: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `tag="label"`) {
|
||||
t.Errorf("Create should stamp tag: %s", str)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Update tagging + backup chain //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDeleteMovesVersionedBackups(t *testing.T) {
|
||||
s, id := mkVersioned(t) // current + id.v1.xml + id.v2.xml
|
||||
if err := s.Delete(id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := s.Get(id); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := s.Versions(id); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Versions after delete: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSkipsVersionedBackups(t *testing.T) {
|
||||
s, _ := mkVersioned(t) // leaves id.v1.xml and id.v2.xml on disk
|
||||
list, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List should skip versioned backups, got %d entries: %+v", len(list), list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStampsIncrementingVersion(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `version="3"`) {
|
||||
t.Errorf("current should be version 3 after two updates: %s", data)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -264,8 +264,8 @@ type CtrlInfo struct {
|
||||
Access uint32 // proto.AccessRead | proto.AccessWrite bitmask
|
||||
|
||||
Double *proto.CtrlDouble // non-nil for DBFDouble / DBFFloat
|
||||
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
|
||||
Enum *proto.CtrlEnum // non-nil for DBFEnum
|
||||
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
|
||||
Enum *proto.CtrlEnum // non-nil for DBFEnum
|
||||
Str *proto.CtrlString // non-nil for DBFString
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca"
|
||||
"github.com/uopi/goca/testca"
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
)
|
||||
|
||||
// newTestClient creates a Client pointing only at the fake server's addresses.
|
||||
|
||||
+10
-10
@@ -57,15 +57,15 @@ type chanState struct {
|
||||
cid uint32
|
||||
pvName string
|
||||
|
||||
mu sync.RWMutex
|
||||
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
|
||||
dbfType int // native DBF field type
|
||||
count uint32 // element count
|
||||
access uint32 // AccessRead | AccessWrite bitmask
|
||||
gotChan bool // CREATE_CHAN reply received for current connection
|
||||
gotAccess bool // ACCESS_RIGHTS received for current connection
|
||||
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
|
||||
monitors []*monState // active subscriptions
|
||||
mu sync.RWMutex
|
||||
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
|
||||
dbfType int // native DBF field type
|
||||
count uint32 // element count
|
||||
access uint32 // AccessRead | AccessWrite bitmask
|
||||
gotChan bool // CREATE_CHAN reply received for current connection
|
||||
gotAccess bool // ACCESS_RIGHTS received for current connection
|
||||
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
|
||||
monitors []*monState // active subscriptions
|
||||
}
|
||||
|
||||
func newChanState(cid uint32, pvName string) *chanState {
|
||||
@@ -151,7 +151,7 @@ type circuit struct {
|
||||
bySubID map[uint32]*monState // subID → monState (fast event dispatch)
|
||||
byIOID map[uint32]chan reply // ioid → GET/PUT callback (circuit-wide)
|
||||
|
||||
writeQ chan []byte // serialised outbound message queue
|
||||
writeQ chan []byte // serialised outbound message queue
|
||||
seq atomic.Uint32 // shared ID sequence (cid, ioid, subID)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// ---- encodePut: every DBF branch + error paths -----------------------
|
||||
|
||||
func TestEncodePut(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dbf int
|
||||
v any
|
||||
wantDBR uint16
|
||||
}{
|
||||
{"double", proto.DBFDouble, 1.5, proto.DBRDouble},
|
||||
{"float", proto.DBFFloat, float32(2.0), proto.DBRDouble},
|
||||
{"long", proto.DBFLong, int(3), proto.DBRLong},
|
||||
{"short", proto.DBFShort, int16(4), proto.DBRShort},
|
||||
{"char", proto.DBFChar, int(5), proto.DBRShort},
|
||||
{"enum", proto.DBFEnum, int(1), proto.DBRShort},
|
||||
{"string", proto.DBFString, "hi", proto.DBRString},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dbr, payload, err := encodePut(c.dbf, c.v)
|
||||
if err != nil {
|
||||
t.Fatalf("encodePut: %v", err)
|
||||
}
|
||||
if dbr != c.wantDBR {
|
||||
t.Errorf("dbr = %d, want %d", dbr, c.wantDBR)
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
t.Error("empty payload")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Coercion failures propagate.
|
||||
if _, _, err := encodePut(proto.DBFDouble, "nope"); err == nil {
|
||||
t.Error("double from string: want error")
|
||||
}
|
||||
if _, _, err := encodePut(proto.DBFLong, "nope"); err == nil {
|
||||
t.Error("long from string: want error")
|
||||
}
|
||||
if _, _, err := encodePut(proto.DBFShort, "nope"); err == nil {
|
||||
t.Error("short from string: want error")
|
||||
}
|
||||
// Unsupported field type.
|
||||
if _, _, err := encodePut(9999, 1); err == nil {
|
||||
t.Error("unsupported DBF: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NewClient: error path + default name/host -----------------------
|
||||
|
||||
func TestNewClientNoAddrs(t *testing.T) {
|
||||
if _, err := NewClient(context.Background(), Config{AutoAddrList: false}); err == nil {
|
||||
t.Error("NewClient with no addresses: want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientDefaultsNameHost(t *testing.T) {
|
||||
// AddrList present but ClientName/HostName empty → default branches run.
|
||||
cli, err := NewClient(context.Background(), Config{
|
||||
AddrList: []string{"127.0.0.1:5064"},
|
||||
AutoAddrList: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
if cli.cfg.ClientName == "" {
|
||||
t.Error("ClientName should be defaulted")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- EncodeSearchReply with an explicit server IP --------------------
|
||||
|
||||
func TestEncodeSearchReplyExplicitIP(t *testing.T) {
|
||||
pkt := EncodeSearchReply(7, net.ParseIP("10.1.2.3"), 5064)
|
||||
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeHeader: %v", err)
|
||||
}
|
||||
if h.Command != proto.CmdSearch {
|
||||
t.Errorf("command = %d, want CmdSearch", h.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseReply robustness -------------------------------------------
|
||||
|
||||
func TestParseReplyTruncated(t *testing.T) {
|
||||
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
|
||||
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
|
||||
// Must not panic on a too-short datagram.
|
||||
se.parseReply([]byte{0x00, 0x01}, src)
|
||||
}
|
||||
|
||||
func TestParseReplyUnknownSearchID(t *testing.T) {
|
||||
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
|
||||
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
|
||||
// Valid reply but no waiter is registered for this search ID → dropped.
|
||||
se.parseReply(EncodeSearchReply(12345, nil, 5064), src)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGetCtrlNotFound covers the resolve-failure branch of GetCtrl.
|
||||
func TestGetCtrlNotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := cli.GetCtrl(ctx, "NO:SUCH:PV"); err == nil {
|
||||
t.Fatal("GetCtrl on missing PV: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPutNotFound covers the resolve-failure branch of Put.
|
||||
func TestPutNotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := cli.Put(ctx, "NO:SUCH:PV", 1.0); err == nil {
|
||||
t.Fatal("Put on missing PV: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPutUncoercibleValue covers the encodePut-failure branch of Put: the PV
|
||||
// resolves but the supplied value cannot be coerced to the native type.
|
||||
func TestPutUncoercibleValue(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := cli.Put(ctx, "TEST:DOUBLE", []int{1, 2, 3}); err == nil {
|
||||
t.Fatal("Put with uncoercible value: want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package ca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
)
|
||||
|
||||
// TestGetDisconnectMidRequest covers the "circuit disconnected during GET" path
|
||||
// in conn.go: the server drops the connection while a READ_NOTIFY is in flight,
|
||||
// so the in-flight reply channel is closed by the reconnect loop and the waiter
|
||||
// unblocks with ok=false.
|
||||
func TestGetDisconnectMidRequest(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// First GET establishes the channel/circuit successfully.
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
|
||||
t.Fatalf("initial Get: %v", err)
|
||||
}
|
||||
|
||||
// Arm a mid-request disconnect for the next READ_NOTIFY.
|
||||
srv.SetGetFault(testca.GetFaultDisconnect)
|
||||
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
|
||||
t.Fatal("Get with mid-request disconnect: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCorruptReply covers the "failed to decode GET reply" path: the server
|
||||
// answers READ_NOTIFY with a payload too short for DecodeTimeValue.
|
||||
func TestGetCorruptReply(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Establish the channel first so resolution succeeds.
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
|
||||
t.Fatalf("initial Get: %v", err)
|
||||
}
|
||||
|
||||
srv.SetGetFault(testca.GetFaultCorrupt)
|
||||
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
|
||||
t.Fatal("Get with corrupt reply: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerDisconnectReconnects covers the CmdServerDisc dispatch branch and the
|
||||
// reconnect loop: after an orderly server disconnect the client transparently
|
||||
// reconnects, re-creates its channel, re-subscribes the monitor, and continues to
|
||||
// receive updates.
|
||||
func TestServerDisconnectReconnects(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan proto.TimeValue, 16)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain the initial value.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for initial value")
|
||||
}
|
||||
|
||||
// Force an orderly disconnect; the client must reconnect on its own.
|
||||
srv.Disconnect()
|
||||
|
||||
// After the reconnect settles, push a fresh value and expect to see it.
|
||||
// The reconnect back-off is ~1s, so poll generously and re-arm the value
|
||||
// until it is observed (the re-subscribe also delivers an initial value).
|
||||
deadline := time.After(8 * time.Second)
|
||||
tick := time.NewTicker(300 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if math.Abs(tv.Double-88.8) < 1e-6 {
|
||||
return // reconnect succeeded and the update flowed through
|
||||
}
|
||||
case <-tick.C:
|
||||
_ = srv.SetValue("TEST:DOUBLE", 88.8)
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for post-reconnect update")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package ca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGetCtrl exercises GetCtrl across the native DBF types served by the mock,
|
||||
// covering NativeCtrlType dispatch and the DBR_CTRL_* decoders.
|
||||
func TestGetCtrl(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Run("double", func(t *testing.T) {
|
||||
ci, err := cli.GetCtrl(ctx, "TEST:DOUBLE")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCtrl: %v", err)
|
||||
}
|
||||
if ci.Double == nil {
|
||||
t.Fatal("expected Double ctrl info")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("long", func(t *testing.T) {
|
||||
ci, err := cli.GetCtrl(ctx, "TEST:LONG")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCtrl: %v", err)
|
||||
}
|
||||
if ci.Long == nil {
|
||||
t.Fatal("expected Long ctrl info")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enum", func(t *testing.T) {
|
||||
ci, err := cli.GetCtrl(ctx, "TEST:ENUM")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCtrl: %v", err)
|
||||
}
|
||||
if ci.Enum == nil {
|
||||
t.Fatal("expected Enum ctrl info")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string", func(t *testing.T) {
|
||||
ci, err := cli.GetCtrl(ctx, "TEST:STRING")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCtrl: %v", err)
|
||||
}
|
||||
if ci.Str == nil {
|
||||
t.Fatal("expected Str ctrl info")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// ---- value coercion helpers ------------------------------------------
|
||||
|
||||
func TestToFloat64(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want float64
|
||||
}{
|
||||
{float64(1.5), 1.5},
|
||||
{float32(2.5), 2.5},
|
||||
{int(3), 3},
|
||||
{int64(4), 4},
|
||||
{int32(5), 5},
|
||||
{int16(6), 6},
|
||||
{uint64(7), 7},
|
||||
{uint32(8), 8},
|
||||
{true, 1},
|
||||
{false, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := toFloat64(c.in)
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("toFloat64(%v) = %v, %v; want %v", c.in, got, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := toFloat64("nope"); err == nil {
|
||||
t.Error("toFloat64(string): want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToInt32(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want int32
|
||||
}{
|
||||
{int(3), 3},
|
||||
{int64(4), 4},
|
||||
{int32(5), 5},
|
||||
{int16(6), 6},
|
||||
{float64(7.9), 7},
|
||||
{float32(8.9), 8},
|
||||
{true, 1},
|
||||
{false, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := toInt32(c.in)
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("toInt32(%v) = %v, %v; want %v", c.in, got, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := toInt32("nope"); err == nil {
|
||||
t.Error("toInt32(string): want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToString(t *testing.T) {
|
||||
if s, _ := toString("hi"); s != "hi" {
|
||||
t.Errorf("toString(string) = %q", s)
|
||||
}
|
||||
if s, _ := toString([]byte("bytes")); s != "bytes" {
|
||||
t.Errorf("toString([]byte) = %q", s)
|
||||
}
|
||||
if s, _ := toString(42); s != "42" {
|
||||
t.Errorf("toString(int) = %q, want 42", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ConfigFromEnv ---------------------------------------------------
|
||||
|
||||
func TestConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("EPICS_CA_ADDR_LIST", "1.2.3.4 5.6.7.8")
|
||||
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "no")
|
||||
cfg := ConfigFromEnv()
|
||||
if len(cfg.AddrList) != 2 || cfg.AddrList[0] != "1.2.3.4" {
|
||||
t.Errorf("AddrList = %v", cfg.AddrList)
|
||||
}
|
||||
if cfg.AutoAddrList {
|
||||
t.Error("AutoAddrList should be false when env = no")
|
||||
}
|
||||
if cfg.ClientName == "" {
|
||||
t.Error("ClientName should be populated")
|
||||
}
|
||||
|
||||
t.Setenv("EPICS_CA_ADDR_LIST", "")
|
||||
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "yes")
|
||||
cfg = ConfigFromEnv()
|
||||
if !cfg.AutoAddrList {
|
||||
t.Error("AutoAddrList should default to true")
|
||||
}
|
||||
if len(cfg.AddrList) != 0 {
|
||||
t.Errorf("AddrList should be empty, got %v", cfg.AddrList)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- address helpers -------------------------------------------------
|
||||
|
||||
func TestResolveAddrs(t *testing.T) {
|
||||
got := resolveAddrs([]string{"host", "host2:9999", ""}, 5064)
|
||||
if len(got) != 2 || got[0] != "host:5064" || got[1] != "host2:9999" {
|
||||
t.Errorf("resolveAddrs = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalBroadcastAddrs(t *testing.T) {
|
||||
// Should not panic; the result is environment-dependent.
|
||||
_ = localBroadcastAddrs(5064)
|
||||
}
|
||||
|
||||
// ---- isAllFF ---------------------------------------------------------
|
||||
|
||||
func TestIsAllFF(t *testing.T) {
|
||||
if !isAllFF([]byte{0xFF, 0xFF, 0xFF}) {
|
||||
t.Error("all-FF should be true")
|
||||
}
|
||||
if isAllFF([]byte{0xFF, 0x00}) {
|
||||
t.Error("mixed should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- EncodedSearchPair -----------------------------------------------
|
||||
|
||||
func TestEncodedSearchPair(t *testing.T) {
|
||||
pkt := EncodedSearchPair("TEST:PV", 7)
|
||||
// First header must be a VERSION message.
|
||||
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeHeader: %v", err)
|
||||
}
|
||||
if h.Command != proto.CmdVersion {
|
||||
t.Errorf("first command = %d, want CmdVersion", h.Command)
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -218,19 +218,19 @@ func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, b
|
||||
|
||||
// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload.
|
||||
type CtrlDouble struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Precision int16
|
||||
Units string
|
||||
UpperDispLimit float64
|
||||
LowerDispLimit float64
|
||||
UpperAlarmLimit float64
|
||||
UpperWarnLimit float64
|
||||
LowerWarnLimit float64
|
||||
LowerAlarmLimit float64
|
||||
UpperCtrlLimit float64
|
||||
LowerCtrlLimit float64
|
||||
Value float64
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Precision int16
|
||||
Units string
|
||||
UpperDispLimit float64
|
||||
LowerDispLimit float64
|
||||
UpperAlarmLimit float64
|
||||
UpperWarnLimit float64
|
||||
LowerWarnLimit float64
|
||||
LowerAlarmLimit float64
|
||||
UpperCtrlLimit float64
|
||||
LowerCtrlLimit float64
|
||||
Value float64
|
||||
}
|
||||
|
||||
// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes).
|
||||
|
||||
@@ -31,8 +31,8 @@ const DefaultPort = 5064
|
||||
|
||||
// Search reply data_type values.
|
||||
const (
|
||||
SearchReply = 10 // DO_REPLY: ask server to respond
|
||||
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
|
||||
SearchReply = 10 // DO_REPLY: ask server to respond
|
||||
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
|
||||
)
|
||||
|
||||
// AccessRights bitmask values (parameter2 of CmdAccessRights message).
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package proto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// DBR_TIME_* variants not covered by proto_test.go //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDecodeTimeFloat(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
val := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(val, math.Float32bits(2.5))
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeFloat, 1, append(hdr, val...))
|
||||
if !ok || math.Abs(float64(tv.Float)-2.5) > 1e-6 {
|
||||
t.Errorf("float: ok=%v Float=%g", ok, tv.Float)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeShort(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
val := []byte{0x00, 0x00, 0xFF, 0xD6} // 2-byte pad + int16(-42)
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeShort, 1, append(hdr, val...))
|
||||
if !ok || tv.Short != -42 {
|
||||
t.Errorf("short: ok=%v Short=%d", ok, tv.Short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeChar(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
val := []byte{0x00, 0x00, 0x00, 0x41} // pad0 + pad1 + value 'A'
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeChar, 1, append(hdr, val...))
|
||||
if !ok || tv.Char != 0x41 {
|
||||
t.Errorf("char: ok=%v Char=%d", ok, tv.Char)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeUnknownType(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
if _, ok := proto.DecodeTimeValue(9999, 1, append(hdr, make([]byte, 16)...)); ok {
|
||||
t.Error("unknown DBR type should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// DBR_CTRL_LONG / DBR_CTRL_STRING //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDecodeCtrlLong(t *testing.T) {
|
||||
p := make([]byte, 48)
|
||||
binary.BigEndian.PutUint16(p[2:], 2) // severity = Major
|
||||
copy(p[4:], "counts")
|
||||
i32 := func(off int, v int32) { binary.BigEndian.PutUint32(p[off:], uint32(v)) }
|
||||
i32(12, 1000) // upper_disp
|
||||
i32(16, -10) // lower_disp
|
||||
i32(44, 777) // value
|
||||
|
||||
cl, ok := proto.DecodeCtrlLong(p)
|
||||
if !ok {
|
||||
t.Fatal("DecodeCtrlLong returned false")
|
||||
}
|
||||
if cl.Units != "counts" || cl.Value != 777 || cl.UpperDispLimit != 1000 || cl.LowerDispLimit != -10 {
|
||||
t.Errorf("got %+v", cl)
|
||||
}
|
||||
if cl.Severity != proto.SeverityMajor {
|
||||
t.Errorf("severity = %v, want Major", cl.Severity)
|
||||
}
|
||||
if _, ok := proto.DecodeCtrlLong(make([]byte, 10)); ok {
|
||||
t.Error("short CtrlLong payload should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCtrlString(t *testing.T) {
|
||||
p := make([]byte, 44)
|
||||
binary.BigEndian.PutUint16(p[2:], 1) // severity = Minor
|
||||
copy(p[4:], "READY")
|
||||
|
||||
cs, ok := proto.DecodeCtrlString(p)
|
||||
if !ok {
|
||||
t.Fatal("DecodeCtrlString returned false")
|
||||
}
|
||||
if cs.Value != "READY" || cs.Severity != proto.SeverityMinor {
|
||||
t.Errorf("got %+v", cs)
|
||||
}
|
||||
if _, ok := proto.DecodeCtrlString(make([]byte, 10)); ok {
|
||||
t.Error("short CtrlString payload should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// EncodeShort //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestEncodeShort(t *testing.T) {
|
||||
b := proto.EncodeShort(-7)
|
||||
if len(b) != 8 {
|
||||
t.Fatalf("len = %d, want 8", len(b))
|
||||
}
|
||||
if got := int16(binary.BigEndian.Uint16(b)); got != -7 {
|
||||
t.Errorf("decoded = %d, want -7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// NativeCtrlType //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestNativeCtrlType(t *testing.T) {
|
||||
cases := []struct {
|
||||
dbf int
|
||||
want uint16
|
||||
}{
|
||||
{proto.DBFDouble, proto.DBRCtrlDouble},
|
||||
{proto.DBFFloat, proto.DBRCtrlDouble},
|
||||
{proto.DBFLong, proto.DBRCtrlLong},
|
||||
{proto.DBFShort, proto.DBRCtrlLong},
|
||||
{proto.DBFChar, proto.DBRCtrlLong},
|
||||
{proto.DBFEnum, proto.DBRCtrlEnum},
|
||||
{proto.DBFString, proto.DBRCtrlString},
|
||||
{9999, proto.DBRCtrlDouble}, // default
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := proto.NativeCtrlType(c.dbf); got != c.want {
|
||||
t.Errorf("NativeCtrlType(%d) = %d, want %d", c.dbf, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// DecodeHeader: extended encoding + error path //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDecodeHeaderExtended(t *testing.T) {
|
||||
raw := make([]byte, 24)
|
||||
binary.BigEndian.PutUint16(raw[0:], proto.CmdReadNotify)
|
||||
binary.BigEndian.PutUint16(raw[2:], 0xFFFF) // extended marker
|
||||
binary.BigEndian.PutUint16(raw[6:], 0x0000)
|
||||
binary.BigEndian.PutUint32(raw[16:], 70000) // real payload size
|
||||
binary.BigEndian.PutUint32(raw[20:], 5) // real count
|
||||
|
||||
h, n, err := proto.DecodeHeader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 24 {
|
||||
t.Errorf("bytes read = %d, want 24", n)
|
||||
}
|
||||
if h.PayloadSize != 70000 || h.DataCount != 5 {
|
||||
t.Errorf("extended header = %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHeaderTruncated(t *testing.T) {
|
||||
if _, _, err := proto.DecodeHeader(bytes.NewReader([]byte{0x00, 0x01})); err == nil {
|
||||
t.Error("truncated header: want error")
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func TestDecodeTimeDouble(t *testing.T) {
|
||||
const want = 3.14159
|
||||
|
||||
hdr := buildTimeHeader(0, 0, sec, nsec)
|
||||
pad := make([]byte, 4) // RISC pad
|
||||
pad := make([]byte, 4) // RISC pad
|
||||
val := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(val, math.Float64bits(want))
|
||||
payload := append(append(hdr, pad...), val...)
|
||||
|
||||
+68
-9
@@ -29,13 +29,21 @@ type Server struct {
|
||||
tcpLn net.Listener
|
||||
udpCn *net.UDPConn
|
||||
|
||||
mu sync.RWMutex
|
||||
pvs map[string]*serverPV
|
||||
subs map[uint32]*serverSub // subID → sub
|
||||
mu sync.RWMutex
|
||||
pvs map[string]*serverPV
|
||||
subs map[uint32]*serverSub // subID → sub
|
||||
getFault int // fault to inject on the next READ_NOTIFY
|
||||
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Fault modes for SetGetFault, used to exercise the client's error paths.
|
||||
const (
|
||||
GetFaultNone = iota // serve replies normally
|
||||
GetFaultDisconnect // drop the connection mid-request
|
||||
GetFaultCorrupt // reply with an undecodable (too-short) payload
|
||||
)
|
||||
|
||||
type serverPV struct {
|
||||
spec PVSpec
|
||||
mu sync.RWMutex
|
||||
@@ -43,11 +51,11 @@ type serverPV struct {
|
||||
}
|
||||
|
||||
type serverSub struct {
|
||||
pvName string
|
||||
subID uint32
|
||||
cid uint32
|
||||
sid uint32
|
||||
conn net.Conn
|
||||
pvName string
|
||||
subID uint32
|
||||
cid uint32
|
||||
sid uint32
|
||||
conn net.Conn
|
||||
dbrType uint16
|
||||
count uint32
|
||||
}
|
||||
@@ -93,6 +101,29 @@ func (s *Server) Close() {
|
||||
s.udpCn.Close()
|
||||
}
|
||||
|
||||
// SetGetFault arms a fault to be injected on the next READ_NOTIFY request.
|
||||
// It is reset to GetFaultNone after a single request is faulted.
|
||||
func (s *Server) SetGetFault(mode int) {
|
||||
s.mu.Lock()
|
||||
s.getFault = mode
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Disconnect pushes a SERVER_DISCONN message to every connected subscriber,
|
||||
// simulating an orderly server shutdown that forces clients to reconnect.
|
||||
func (s *Server) Disconnect() {
|
||||
msg := proto.BuildMessage(proto.Header{Command: proto.CmdServerDisc}, nil)
|
||||
seen := make(map[net.Conn]bool)
|
||||
s.mu.RLock()
|
||||
for _, sub := range s.subs {
|
||||
if sub.conn != nil && !seen[sub.conn] {
|
||||
seen[sub.conn] = true
|
||||
_, _ = sub.conn.Write(msg)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
// SetValue updates a PV's value and pushes a monitor event to all subscribers.
|
||||
func (s *Server) SetValue(pvName string, val any) error {
|
||||
s.mu.RLock()
|
||||
@@ -350,6 +381,31 @@ func (s *Server) handleConn(conn net.Conn) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fault injection: consume a one-shot armed fault, if any.
|
||||
s.mu.Lock()
|
||||
fault := s.getFault
|
||||
s.getFault = GetFaultNone
|
||||
s.mu.Unlock()
|
||||
switch fault {
|
||||
case GetFaultDisconnect:
|
||||
// Drop the connection mid-request: the client's in-flight GET
|
||||
// unblocks with ok=false ("circuit disconnected during GET").
|
||||
conn.Close()
|
||||
return
|
||||
case GetFaultCorrupt:
|
||||
// Reply with a too-short payload so DecodeTimeValue fails
|
||||
// ("failed to decode GET reply").
|
||||
reply := proto.BuildMessage(proto.Header{
|
||||
Command: proto.CmdReadNotify,
|
||||
DataType: hdr.DataType,
|
||||
DataCount: hdr.DataCount,
|
||||
Parameter1: cid,
|
||||
Parameter2: ioid,
|
||||
}, []byte{0, 0, 0, 0})
|
||||
conn.Write(reply)
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
pv := s.pvs[pvName]
|
||||
s.mu.RUnlock()
|
||||
@@ -591,7 +647,10 @@ func nullStr(b []byte) string {
|
||||
// Minimal io.Reader for proto.DecodeHeader //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
type bytesReader struct{ b []byte; i int }
|
||||
type bytesReader struct {
|
||||
b []byte
|
||||
i int
|
||||
}
|
||||
|
||||
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
|
||||
|
||||
|
||||
+3
-3
@@ -224,10 +224,10 @@ func (cl *Client) sendSearchRequest(seq uint32, pvName string) {
|
||||
laddr := cl.udp.LocalAddr().(*net.UDPAddr)
|
||||
binary.Write(&buf, binary.LittleEndian, uint16(laddr.Port))
|
||||
|
||||
buf.WriteByte(1) // transportCount = 1
|
||||
buf.WriteByte(0x01) // transport[0]: TCP
|
||||
buf.WriteByte(1) // transportCount = 1
|
||||
buf.WriteByte(0x01) // transport[0]: TCP
|
||||
|
||||
binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
|
||||
binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
|
||||
binary.Write(&buf, binary.LittleEndian, uint32(seq)) // channelID (reuse seq)
|
||||
pvdata.WriteString(&buf, pvName)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user