Compare commits

..

21 Commits

Author SHA1 Message Date
Martino Ferrari 519c1f2df4 controllogic: make expr evaluator value-polymorphic (arrays, indexing, array funcs)
Rewrites internal/controllogic/expr.go to evaluate to Value (scalar float64
or []Value array): adds array literals [a,b], postfix indexing arr[i], and
array functions (len, sum, mean, push, pop, slice, concat, sort, …). Resolver
type changes from func(...) float64 to func(...) Value; EvalValue added;
EvalExpr/EvalBool retain scalar float64/bool returns. Three temporary shims
added in engine.go, lua.go, and expr_test.go pending Tasks 4 and 6.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:32:11 +02:00
Martino Ferrari cf9da3df0a Implemented new datasources (modbus,scpi) 2026-06-24 06:12:52 +02:00
41 changed files with 6715 additions and 277 deletions
+1
View File
@@ -98,6 +98,7 @@
- [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
- 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). Box stays unchecked until **Phase 2** (server-side `internal/controllogic` Go port, see below) lands.
- [ ] 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
+31
View File
@@ -21,7 +21,9 @@ 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"
@@ -146,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)
+31 -1
View File
@@ -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:** This is Phase 1 (panel logic, client-side TypeScript). The equivalent array
> support in the server-side control-logic engine (`internal/controllogic`) is a separate
> Phase 2 effort and is not yet implemented.
### 3.9 Control Logic Engine
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
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).
+5
View File
@@ -7,6 +7,9 @@ import (
"strings"
"github.com/BurntSushi/toml"
"github.com/uopi/uopi/internal/datasource/modbus"
"github.com/uopi/uopi/internal/datasource/scpi"
)
type Config struct {
@@ -194,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 {
+1 -1
View File
@@ -897,7 +897,7 @@ func (cg *compiledGraph) activate(triggerID string) {
dt = float64(now-last) / 1e9
}
resolve := func(ds, name string) float64 {
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes)
switch ds {
case "sys":
if name == "dt" {
+367 -59
View File
@@ -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)
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)
}
m := a[0]
for _, x := range a[1:] {
m = math.Min(m, x)
return s
},
"mean": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return 0.0
}
return m
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,49 @@ func sign(x float64) int {
}
}
// sign is an alias for signOf, kept for backward compatibility with existing tests.
func sign(x float64) int { return signOf(x) }
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 +455,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 +511,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 +524,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 +591,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 +611,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 +740,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 +750,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 +759,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 +797,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:
+72 -2
View File
@@ -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 { // shim: was float64 (Task 4 removes)
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 } // shim: was float64 (Task 4 removes)
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)
}
}
+4 -2
View File
@@ -61,8 +61,10 @@ func (lr *luaRuntime) ensure() error {
target := s.CheckString(1)
ds, name, ok := parseRef(target)
var v float64
if ok && lr.curResolve != nil {
v = lr.curResolve(ds, name)
if ok && lr.curResolve != nil { // shim: Task 6 finalizes lua Value handling
if f, fok := lr.curResolve(ds, name).(float64); fok {
v = f
}
}
s.Push(lua.LNumber(v))
return 1
+4
View File
@@ -72,6 +72,10 @@ type Graph struct {
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 {
+33
View File
@@ -0,0 +1,33 @@
package controllogic
import "testing"
func TestStoreRoundTripStateVars(t *testing.T) {
dir := t.TempDir()
st, err := NewStore(dir) // NewStore takes the storage DIRECTORY
if err != nil {
t.Fatal(err)
}
g := Graph{
ID: "g1",
Name: "with-vars",
StateVars: []StateVar{
{Name: "count", Type: "number", Initial: "0"},
{Name: "buf", Type: "array", Initial: "[1,2]", Elem: "number", Sizing: "capped", Capacity: 5},
},
}
if err := st.Save(g); err != nil { // Save returns only error
t.Fatal(err)
}
st2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
got, err := st2.Get("g1") // Get returns (Graph, error); ErrNotFound if absent
if err != nil {
t.Fatal(err)
}
if len(got.StateVars) != 2 || got.StateVars[1].Name != "buf" || got.StateVars[1].Capacity != 5 {
t.Fatalf("statevars not round-tripped: %#v", got.StateVars)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Value model for control-logic locals/expressions. A Value is either a scalar
// (float64; booleans are 1/0) or an array ([]Value). This is the Go port of
// web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from
// web/src/lib/expr.ts. Pure, dependency-free.
package controllogic
import (
"encoding/json"
"fmt"
"strings"
)
// Value is a scalar (float64) or an array ([]Value).
type Value = any
// ARRAY_MAX is the global hard cap on dynamic array length (drops oldest).
const ARRAY_MAX = 1_000_000
// StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar.
type StateVar struct {
Name string `json:"name"`
Type string `json:"type,omitempty"` // number|bool|string|array (default number)
Initial string `json:"initial"` // initial value, stored as a string
Unit string `json:"unit,omitempty"`
Low float64 `json:"low,omitempty"`
High float64 `json:"high,omitempty"`
Elem string `json:"elem,omitempty"` // array-only: number|bool|array
Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed
Capacity int `json:"capacity,omitempty"` // array-only
}
func asNum(v Value) (float64, error) {
f, ok := v.(float64)
if !ok {
return 0, fmt.Errorf("expected a number, got an array")
}
return f, nil
}
func asArr(v Value) ([]Value, error) {
a, ok := v.([]Value)
if !ok {
return nil, fmt.Errorf("expected an array, got a number")
}
return a, nil
}
// idxResolve resolves a possibly-negative index against length; range-checked.
func idxResolve(i float64, length int) (int, error) {
k := int(i) // truncates toward zero, matching Math.trunc
if k < 0 {
k = length + k
}
if k < 0 || k >= length {
return 0, fmt.Errorf("index %v out of range (len %d)", i, length)
}
return k, nil
}
// normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64,
// bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays).
func normalizeValue(v any) Value {
switch t := v.(type) {
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int64:
return float64(t)
case bool:
if t {
return 1.0
}
return 0.0
case []interface{}:
out := make([]Value, len(t))
for i, e := range t {
out[i] = normalizeValue(e)
}
return out
default:
return 0.0
}
}
func zeroFill(n int) []Value {
if n < 0 {
n = 0
}
out := make([]Value, n)
for i := range out {
out[i] = 0.0
}
return out
}
// parseInitialArray returns the starting contents of an array local. Mirrors
// arraypolicy.ts parseInitialArray.
func parseInitialArray(sv StateVar) []Value {
cap := sv.Capacity
raw := strings.TrimSpace(sv.Initial)
var parsed []Value
if raw != "" {
var j interface{}
if err := json.Unmarshal([]byte(raw), &j); err == nil {
if arr, ok := j.([]interface{}); ok {
parsed = normalizeValue(arr).([]Value)
}
}
}
if sv.Sizing == "fixed" {
if parsed == nil {
return zeroFill(cap)
}
out := make([]Value, 0, cap)
for i := 0; i < len(parsed) && i < cap; i++ {
out = append(out, parsed[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
}
if parsed == nil {
return []Value{}
}
return parsed
}
// applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts.
func applySizing(arr []Value, sv StateVar) []Value {
cap := sv.Capacity
switch sv.Sizing {
case "fixed":
out := make([]Value, 0, cap)
for i := 0; i < len(arr) && i < cap; i++ {
out = append(out, arr[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
case "capped":
if len(arr) > cap {
return arr[len(arr)-cap:]
}
return arr
default:
if len(arr) > ARRAY_MAX {
return arr[len(arr)-ARRAY_MAX:]
}
return arr
}
}
+67
View File
@@ -0,0 +1,67 @@
package controllogic
import (
"reflect"
"testing"
)
func TestAsNumAsArr(t *testing.T) {
if n, err := asNum(3.0); err != nil || n != 3 {
t.Fatalf("asNum(3)=%v,%v", n, err)
}
if _, err := asNum([]Value{1.0}); err == nil {
t.Fatal("asNum(array) should error")
}
if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 {
t.Fatalf("asArr=%v,%v", a, err)
}
if _, err := asArr(3.0); err == nil {
t.Fatal("asArr(number) should error")
}
}
func TestIdxResolve(t *testing.T) {
if k, err := idxResolve(-1, 3); err != nil || k != 2 {
t.Fatalf("idx(-1,3)=%v,%v", k, err)
}
if _, err := idxResolve(3, 3); err == nil {
t.Fatal("idx(3,3) should be out of range")
}
}
func TestNormalizeValue(t *testing.T) {
got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}})
want := []Value{1.0, 1.0, []Value{2.0}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalize=%#v want %#v", got, want)
}
if normalizeValue(5) != Value(5.0) {
t.Fatalf("normalize(int) = %#v", normalizeValue(5))
}
}
func TestParseInitialArray(t *testing.T) {
fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"})
if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) {
t.Fatalf("fixed init = %#v", fixed)
}
dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"})
if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) {
t.Fatalf("dynamic init = %#v", dyn)
}
empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""})
if len(empty) != 0 {
t.Fatalf("empty init = %#v", empty)
}
}
func TestApplySizing(t *testing.T) {
capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2})
if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) {
t.Fatalf("capped = %#v", capped)
}
fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3})
if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) {
t.Fatalf("fixed = %#v", fixed)
}
}
+216
View File
@@ -0,0 +1,216 @@
package modbus
import (
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
)
// Modbus function codes.
const (
fcReadCoils = 0x01
fcReadDiscrete = 0x02
fcReadHolding = 0x03
fcReadInput = 0x04
fcWriteSingleCoil = 0x05
fcWriteSingleReg = 0x06
fcWriteMultipleRegs = 0x10
)
// client is a minimal Modbus TCP master for a single device address. Requests
// are serialised by mu (Modbus TCP is request/response and the connection is
// shared by all of a device's polled registers). The connection is dialled
// lazily and dropped on any I/O error so the next request reconnects.
type client struct {
addr string
timeout time.Duration
mu sync.Mutex
conn net.Conn
txID uint16
}
func newClient(addr string, timeout time.Duration) *client {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &client{addr: addr, timeout: timeout}
}
func (c *client) close() {
c.mu.Lock()
c.closeLocked()
c.mu.Unlock()
}
func (c *client) closeLocked() {
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
// request sends a PDU to unitID and returns the response PDU (function code +
// data). It dials on demand and tears the connection down on error.
func (c *client) request(unitID byte, pdu []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
conn, err := net.DialTimeout("tcp", c.addr, c.timeout)
if err != nil {
return nil, fmt.Errorf("modbus: dial %s: %w", c.addr, err)
}
c.conn = conn
}
resp, err := c.transact(unitID, pdu)
if err != nil {
c.closeLocked()
return nil, err
}
return resp, nil
}
// transact performs one MBAP-framed exchange. Caller holds mu.
func (c *client) transact(unitID byte, pdu []byte) ([]byte, error) {
c.txID++
tx := c.txID
frame := make([]byte, 7+len(pdu))
binary.BigEndian.PutUint16(frame[0:], tx) // transaction id
binary.BigEndian.PutUint16(frame[2:], 0) // protocol id (0 = Modbus)
binary.BigEndian.PutUint16(frame[4:], uint16(1+len(pdu))) // length: unit id + PDU
frame[6] = unitID
copy(frame[7:], pdu)
_ = c.conn.SetDeadline(time.Now().Add(c.timeout))
if _, err := c.conn.Write(frame); err != nil {
return nil, fmt.Errorf("modbus: write: %w", err)
}
head := make([]byte, 7)
if _, err := io.ReadFull(c.conn, head); err != nil {
return nil, fmt.Errorf("modbus: read header: %w", err)
}
if binary.BigEndian.Uint16(head[0:]) != tx {
return nil, fmt.Errorf("modbus: transaction id mismatch")
}
length := binary.BigEndian.Uint16(head[4:])
if length < 2 { // unit id + at least a function code
return nil, fmt.Errorf("modbus: short frame length %d", length)
}
body := make([]byte, length-1) // header already consumed the unit id
if _, err := io.ReadFull(c.conn, body); err != nil {
return nil, fmt.Errorf("modbus: read body: %w", err)
}
fc := body[0]
if fc&0x80 != 0 { // exception response
var ex byte
if len(body) >= 2 {
ex = body[1]
}
return nil, fmt.Errorf("modbus: exception 0x%02x (%s)", ex, exceptionText(ex))
}
return body, nil
}
// readRegisters reads `quantity` 16-bit registers via fc (holding or input).
func (c *client) readRegisters(unitID, fc byte, addr, quantity uint16) ([]uint16, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short register response")
}
byteCount := int(resp[1])
if byteCount != int(quantity)*2 || len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: register byte count %d (want %d)", byteCount, quantity*2)
}
regs := make([]uint16, quantity)
for i := range regs {
regs[i] = binary.BigEndian.Uint16(resp[2+i*2:])
}
return regs, nil
}
// readBits reads `quantity` bits via fc (coils or discrete inputs).
func (c *client) readBits(unitID, fc byte, addr, quantity uint16) ([]bool, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short bit response")
}
byteCount := int(resp[1])
if len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: bit byte count %d exceeds frame", byteCount)
}
bits := make([]bool, quantity)
for i := range bits {
idx := 2 + i/8
if idx >= len(resp) {
break
}
bits[i] = resp[idx]&(1<<(uint(i)%8)) != 0
}
return bits, nil
}
func (c *client) writeSingleRegister(unitID byte, addr, value uint16) error {
pdu := []byte{fcWriteSingleReg, byte(addr >> 8), byte(addr), byte(value >> 8), byte(value)}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeMultipleRegisters(unitID byte, addr uint16, values []uint16) error {
pdu := make([]byte, 6+len(values)*2)
pdu[0] = fcWriteMultipleRegs
binary.BigEndian.PutUint16(pdu[1:], addr)
binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
pdu[5] = byte(len(values) * 2)
for i, v := range values {
binary.BigEndian.PutUint16(pdu[6+i*2:], v)
}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeSingleCoil(unitID byte, addr uint16, on bool) error {
var v uint16
if on {
v = 0xFF00
}
pdu := []byte{fcWriteSingleCoil, byte(addr >> 8), byte(addr), byte(v >> 8), byte(v)}
_, err := c.request(unitID, pdu)
return err
}
func exceptionText(code byte) string {
switch code {
case 0x01:
return "illegal function"
case 0x02:
return "illegal data address"
case 0x03:
return "illegal data value"
case 0x04:
return "server device failure"
case 0x05:
return "acknowledge"
case 0x06:
return "server device busy"
case 0x0B:
return "gateway target failed to respond"
default:
return "unknown"
}
}
+152
View File
@@ -0,0 +1,152 @@
package modbus
import (
"fmt"
"strings"
"github.com/uopi/uopi/internal/datasource"
)
// Config is the [datasource.modbus] section. Devices share no connection state;
// each is polled independently over its own TCP socket.
type Config struct {
Enabled bool `toml:"enabled"`
// PollIntervalMs is the default polling period for every register that does
// not override it. Zero → 1000 ms.
PollIntervalMs int `toml:"poll_interval_ms"`
Devices []Device `toml:"devices"`
}
// Device is one Modbus TCP slave. Address is "host:port" (default port 502 is
// appended if absent). UnitID is the Modbus unit/slave identifier (0255).
type Device struct {
Name string `toml:"name"`
Address string `toml:"address"`
UnitID uint8 `toml:"unit_id"`
TimeoutMs int `toml:"timeout_ms"`
Registers []Register `toml:"registers"`
}
// Register describes one logical signal mapped onto a Modbus address.
//
// - Kind selects the address space / function code:
// "holding" (FC03/06/10), "input" (FC04, read-only),
// "coil" (FC01/05, bool), "discrete" (FC02, read-only bool).
// - Encoding selects how holding/input words are decoded:
// "uint16", "int16", "uint32", "int32", "float32", "float64".
// Ignored for coil/discrete (always bool).
// - WordOrder is "big" (default, high word first) or "little" for the
// multi-word encodings.
// - Scale/Offset transform the raw numeric value: value*Scale + Offset.
// Scale 0 is treated as 1.
type Register struct {
Name string `toml:"name"`
Kind string `toml:"kind"`
Address uint16 `toml:"address"`
Encoding string `toml:"encoding"`
WordOrder string `toml:"word_order"`
Unit string `toml:"unit"`
Scale float64 `toml:"scale"`
Offset float64 `toml:"offset"`
Min float64 `toml:"min"`
Max float64 `toml:"max"`
Writable bool `toml:"writable"`
Description string `toml:"description"`
}
// register kinds.
const (
kindHolding = "holding"
kindInput = "input"
kindCoil = "coil"
kindDiscrete = "discrete"
)
// isBool reports whether the register addresses a single-bit space.
func (r Register) isBool() bool {
return r.kind() == kindCoil || r.kind() == kindDiscrete
}
func (r Register) kind() string {
if r.Kind == "" {
return kindHolding
}
return strings.ToLower(r.Kind)
}
func (r Register) encoding() string {
if r.Encoding == "" {
return "uint16"
}
return strings.ToLower(r.Encoding)
}
func (r Register) littleWordOrder() bool {
return strings.ToLower(r.WordOrder) == "little"
}
func (r Register) scale() float64 {
if r.Scale == 0 {
return 1
}
return r.Scale
}
// wordCount returns the number of 16-bit registers the encoding occupies.
func (r Register) wordCount() (int, error) {
switch r.encoding() {
case "uint16", "int16":
return 1, nil
case "uint32", "int32", "float32":
return 2, nil
case "float64":
return 4, nil
default:
return 0, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
}
// dataType maps the register to a datasource value type.
func (r Register) dataType() datasource.DataType {
if r.isBool() {
return datasource.TypeBool
}
// Integer encodings with no fractional scaling stay integers; anything
// scaled, offset, or float-encoded becomes a float64.
switch r.encoding() {
case "float32", "float64":
return datasource.TypeFloat64
default:
if r.scale() == 1 && r.Offset == 0 {
return datasource.TypeInt64
}
return datasource.TypeFloat64
}
}
// writable reports whether writes are permitted. Input registers and discrete
// inputs are read-only regardless of the Writable flag.
func (r Register) writable() bool {
switch r.kind() {
case kindInput, kindDiscrete:
return false
default:
return r.Writable
}
}
// metadata builds the datasource.Metadata for this register under signal name
// "device:register".
func (r Register) metadata(device string) datasource.Metadata {
return datasource.Metadata{
Name: device + ":" + r.Name,
Type: r.dataType(),
Unit: r.Unit,
Description: r.Description,
DisplayLow: r.Min,
DisplayHigh: r.Max,
DriveLow: r.Min,
DriveHigh: r.Max,
Writable: r.writable(),
}
}
+89
View File
@@ -0,0 +1,89 @@
package modbus
import (
"fmt"
"math"
)
// orderWords returns the registers in big-word-first order, reversing them when
// the register declares little word order. The slice is copied so the caller's
// data is left untouched.
func (r Register) orderWords(words []uint16) []uint16 {
if !r.littleWordOrder() {
return words
}
out := make([]uint16, len(words))
for i, w := range words {
out[len(words)-1-i] = w
}
return out
}
// decode converts the raw registers into the register's numeric value and
// applies scale/offset. The result type is int64 or float64 per dataType.
func (r Register) decode(words []uint16) (any, error) {
w := r.orderWords(words)
var raw float64
var rawInt int64
switch r.encoding() {
case "uint16":
rawInt = int64(w[0])
raw = float64(w[0])
case "int16":
rawInt = int64(int16(w[0]))
raw = float64(int16(w[0]))
case "uint32":
u := uint32(w[0])<<16 | uint32(w[1])
rawInt = int64(u)
raw = float64(u)
case "int32":
u := uint32(w[0])<<16 | uint32(w[1])
rawInt = int64(int32(u))
raw = float64(int32(u))
case "float32":
u := uint32(w[0])<<16 | uint32(w[1])
raw = float64(math.Float32frombits(u))
case "float64":
u := uint64(w[0])<<48 | uint64(w[1])<<32 | uint64(w[2])<<16 | uint64(w[3])
raw = math.Float64frombits(u)
default:
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
// Integer fast-path: no scaling/offset, integer encoding.
if r.scale() == 1 && r.Offset == 0 {
switch r.encoding() {
case "uint16", "int16", "uint32", "int32":
return rawInt, nil
}
}
return raw*r.scale() + r.Offset, nil
}
// encode converts a value destined for a Write back into raw registers,
// inverting scale/offset. Only the holding-register encodings are writable.
func (r Register) encode(value float64) ([]uint16, error) {
v := (value - r.Offset) / r.scale()
var words []uint16
switch r.encoding() {
case "uint16":
words = []uint16{uint16(int64(math.Round(v)))}
case "int16":
words = []uint16{uint16(int16(int64(math.Round(v))))}
case "uint32":
u := uint32(int64(math.Round(v)))
words = []uint16{uint16(u >> 16), uint16(u)}
case "int32":
u := uint32(int32(int64(math.Round(v))))
words = []uint16{uint16(u >> 16), uint16(u)}
case "float32":
u := math.Float32bits(float32(v))
words = []uint16{uint16(u >> 16), uint16(u)}
case "float64":
u := math.Float64bits(v)
words = []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
default:
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
return r.orderWords(words), nil
}
+115
View File
@@ -0,0 +1,115 @@
package modbus
import (
"math"
"testing"
"github.com/uopi/uopi/internal/datasource"
)
func TestDecodeEncodings(t *testing.T) {
cases := []struct {
name string
reg Register
words []uint16
want any
}{
{"uint16", Register{Encoding: "uint16"}, []uint16{42}, int64(42)},
{"int16 neg", Register{Encoding: "int16"}, []uint16{0xFFFF}, int64(-1)},
{"uint32", Register{Encoding: "uint32"}, []uint16{0x0001, 0x0000}, int64(65536)},
{"int32 neg", Register{Encoding: "int32"}, []uint16{0xFFFF, 0xFFFF}, int64(-1)},
{"scaled", Register{Encoding: "int16", Scale: 0.1}, []uint16{235}, 23.5},
{"float32", Register{Encoding: "float32"}, f32Words(3.5), 3.5},
{"float64", Register{Encoding: "float64"}, f64Words(2.25), 2.25},
}
for _, tc := range cases {
got, err := tc.reg.decode(tc.words)
if err != nil {
t.Errorf("%s: decode error %v", tc.name, err)
continue
}
switch w := tc.want.(type) {
case int64:
if got != w {
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
}
case float64:
gf, ok := got.(float64)
if !ok || math.Abs(gf-w) > 1e-6 {
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
}
}
}
}
func TestWordOrder(t *testing.T) {
big := Register{Encoding: "uint32", WordOrder: "big"}
little := Register{Encoding: "uint32", WordOrder: "little"}
words := []uint16{0x0001, 0x0002} // big: 0x00010002, little reverses to 0x00020001
gb, _ := big.decode(words)
gl, _ := little.decode(words)
if gb != int64(0x00010002) {
t.Errorf("big = %v, want %d", gb, 0x00010002)
}
if gl != int64(0x00020001) {
t.Errorf("little = %v, want %d", gl, 0x00020001)
}
}
func TestEncodeRoundTrip(t *testing.T) {
for _, enc := range []string{"uint16", "int16", "uint32", "int32", "float32", "float64"} {
reg := Register{Encoding: enc}
words, err := reg.encode(123)
if err != nil {
t.Fatalf("%s encode: %v", enc, err)
}
got, err := reg.decode(words)
if err != nil {
t.Fatalf("%s decode: %v", enc, err)
}
var f float64
switch v := got.(type) {
case int64:
f = float64(v)
case float64:
f = v
}
if math.Abs(f-123) > 1e-3 {
t.Errorf("%s round-trip = %v, want 123", enc, f)
}
}
}
func TestDataTypeAndWritable(t *testing.T) {
if got := (Register{Kind: "coil"}).dataType(); got != datasource.TypeBool {
t.Errorf("coil type = %v, want bool", got)
}
if got := (Register{Encoding: "float32"}).dataType(); got != datasource.TypeFloat64 {
t.Errorf("float type = %v, want float64", got)
}
if got := (Register{Encoding: "uint16", Scale: 0.5}).dataType(); got != datasource.TypeFloat64 {
t.Errorf("scaled int type = %v, want float64", got)
}
if got := (Register{Encoding: "uint16"}).dataType(); got != datasource.TypeInt64 {
t.Errorf("plain int type = %v, want int64", got)
}
if (Register{Kind: "input", Writable: true}).writable() {
t.Error("input register must be read-only")
}
if (Register{Kind: "discrete", Writable: true}).writable() {
t.Error("discrete input must be read-only")
}
if !(Register{Kind: "holding", Writable: true}).writable() {
t.Error("writable holding should be writable")
}
}
func f32Words(v float32) []uint16 {
u := math.Float32bits(v)
return []uint16{uint16(u >> 16), uint16(u)}
}
func f64Words(v float64) []uint16 {
u := math.Float64bits(v)
return []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
}
+258
View File
@@ -0,0 +1,258 @@
// Package modbus implements a Modbus TCP data source. Each configured device is
// polled over its own TCP connection; registers are exposed as signals named
// "device:register". Reads use the holding/input/coil/discrete function codes;
// writable holding registers and coils accept Write.
package modbus
import (
"context"
"fmt"
"strings"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const defaultPollInterval = time.Second
// deviceClient bundles a parsed device with its wire client and register lookup.
type deviceClient struct {
dev Device
cli *client
byName map[string]Register
}
// Modbus is a datasource.DataSource backed by one or more Modbus TCP devices.
type Modbus struct {
pollInterval time.Duration
devices map[string]*deviceClient // device name → client
signals map[string]signalRef // "device:register" → ref
}
type signalRef struct {
device string
reg Register
}
// New builds a Modbus source from config. It does not dial; connections are
// established lazily on first poll/write.
func New(cfg Config) (*Modbus, error) {
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
if poll <= 0 {
poll = defaultPollInterval
}
m := &Modbus{
pollInterval: poll,
devices: make(map[string]*deviceClient),
signals: make(map[string]signalRef),
}
for _, dev := range cfg.Devices {
if dev.Name == "" || dev.Address == "" {
return nil, fmt.Errorf("modbus: device needs name and address")
}
if _, dup := m.devices[dev.Name]; dup {
return nil, fmt.Errorf("modbus: duplicate device %q", dev.Name)
}
addr := dev.Address
if !strings.Contains(addr, ":") {
addr += ":502"
}
dc := &deviceClient{
dev: dev,
cli: newClient(addr, time.Duration(dev.TimeoutMs)*time.Millisecond),
byName: make(map[string]Register),
}
for _, reg := range dev.Registers {
if reg.Name == "" {
return nil, fmt.Errorf("modbus: device %q has a register with no name", dev.Name)
}
if _, err := reg.wordCount(); !reg.isBool() && err != nil {
return nil, err
}
if _, dup := dc.byName[reg.Name]; dup {
return nil, fmt.Errorf("modbus: device %q duplicate register %q", dev.Name, reg.Name)
}
dc.byName[reg.Name] = reg
m.signals[dev.Name+":"+reg.Name] = signalRef{device: dev.Name, reg: reg}
}
m.devices[dev.Name] = dc
}
return m, nil
}
// Name implements datasource.DataSource.
func (m *Modbus) Name() string { return "modbus" }
// Connect is a no-op; TCP connections are dialled lazily per device.
func (m *Modbus) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every configured register.
func (m *Modbus) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
out := make([]datasource.Metadata, 0, len(m.signals))
for _, ref := range m.signals {
out = append(out, ref.reg.metadata(ref.device))
}
return out, nil
}
// GetMetadata returns metadata for one signal.
func (m *Modbus) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
ref, ok := m.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return ref.reg.metadata(ref.device), nil
}
// readSignal performs one synchronous read of a register's current value.
func (m *Modbus) readSignal(ref signalRef) (datasource.Value, error) {
dc := m.devices[ref.device]
reg := ref.reg
now := time.Now()
if reg.isBool() {
fc := byte(fcReadCoils)
if reg.kind() == kindDiscrete {
fc = fcReadDiscrete
}
bits, err := dc.cli.readBits(dc.dev.UnitID, fc, reg.Address, 1)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: now, Data: bits[0], Quality: datasource.QualityGood}, nil
}
count, err := reg.wordCount()
if err != nil {
return datasource.Value{}, err
}
fc := byte(fcReadHolding)
if reg.kind() == kindInput {
fc = fcReadInput
}
words, err := dc.cli.readRegisters(dc.dev.UnitID, fc, reg.Address, uint16(count))
if err != nil {
return datasource.Value{}, err
}
data, err := reg.decode(words)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: now, Data: data, Quality: datasource.QualityGood}, nil
}
// Subscribe polls the register at the configured interval and pushes values
// into ch. On a read error a QualityBad value is emitted and polling continues.
func (m *Modbus) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
ref, ok := m.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(m.pollInterval)
defer ticker.Stop()
emit := func() {
v, err := m.readSignal(ref)
if err != nil {
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
}
select {
case ch <- v:
case <-ctx.Done():
}
}
emit() // first reading without waiting a full interval
for {
select {
case <-ticker.C:
emit()
case <-ctx.Done():
return
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write sets a writable holding register or coil.
func (m *Modbus) Write(_ context.Context, signal string, value any) error {
ref, ok := m.signals[signal]
if !ok {
return datasource.ErrNotFound
}
reg := ref.reg
if !reg.writable() {
return datasource.ErrNotWritable
}
dc := m.devices[ref.device]
if reg.isBool() {
on, err := toBool(value)
if err != nil {
return err
}
return dc.cli.writeSingleCoil(dc.dev.UnitID, reg.Address, on)
}
f, err := toFloat(value)
if err != nil {
return err
}
words, err := reg.encode(f)
if err != nil {
return err
}
if len(words) == 1 {
return dc.cli.writeSingleRegister(dc.dev.UnitID, reg.Address, words[0])
}
return dc.cli.writeMultipleRegisters(dc.dev.UnitID, reg.Address, words)
}
// History is unavailable for Modbus devices.
func (m *Modbus) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// Close tears down every device connection.
func (m *Modbus) Close() {
for _, dc := range m.devices {
dc.cli.close()
}
}
func toFloat(v any) (float64, error) {
switch x := v.(type) {
case float64:
return x, nil
case float32:
return float64(x), nil
case int:
return float64(x), nil
case int64:
return float64(x), nil
case bool:
if x {
return 1, nil
}
return 0, nil
default:
return 0, fmt.Errorf("modbus: cannot write value of type %T", v)
}
}
func toBool(v any) (bool, error) {
switch x := v.(type) {
case bool:
return x, nil
case float64:
return x != 0, nil
case int:
return x != 0, nil
case int64:
return x != 0, nil
default:
return false, fmt.Errorf("modbus: cannot write bool value of type %T", v)
}
}
+364
View File
@@ -0,0 +1,364 @@
package modbus
import (
"context"
"encoding/binary"
"io"
"net"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
// mockServer is an in-process Modbus TCP slave for tests. It serves a small
// register/coil store and records writes. Only the function codes exercised by
// the data source are implemented.
type mockServer struct {
ln net.Listener
mu sync.Mutex
holding map[uint16]uint16
input map[uint16]uint16
coils map[uint16]bool
discrete map[uint16]bool
lastWrite []uint16 // registers from the most recent write
}
func newMockServer(t *testing.T) *mockServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s := &mockServer{
ln: ln,
holding: map[uint16]uint16{},
input: map[uint16]uint16{},
coils: map[uint16]bool{},
discrete: map[uint16]bool{},
}
go s.serve()
t.Cleanup(func() { ln.Close() })
return s
}
func (s *mockServer) addr() string { return s.ln.Addr().String() }
func (s *mockServer) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
go s.handle(conn)
}
}
func (s *mockServer) handle(conn net.Conn) {
defer conn.Close()
for {
head := make([]byte, 7)
if _, err := io.ReadFull(conn, head); err != nil {
return
}
tx := binary.BigEndian.Uint16(head[0:])
length := binary.BigEndian.Uint16(head[4:])
body := make([]byte, length-1)
if _, err := io.ReadFull(conn, body); err != nil {
return
}
resp := s.respond(body)
out := make([]byte, 7+len(resp))
binary.BigEndian.PutUint16(out[0:], tx)
binary.BigEndian.PutUint16(out[2:], 0)
binary.BigEndian.PutUint16(out[4:], uint16(1+len(resp)))
out[6] = head[6]
copy(out[7:], resp)
if _, err := conn.Write(out); err != nil {
return
}
}
}
func (s *mockServer) respond(pdu []byte) []byte {
s.mu.Lock()
defer s.mu.Unlock()
fc := pdu[0]
switch fc {
case fcReadHolding, fcReadInput:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
out := []byte{fc, byte(qty * 2)}
src := s.holding
if fc == fcReadInput {
src = s.input
}
for i := uint16(0); i < qty; i++ {
out = binary.BigEndian.AppendUint16(out, src[addr+i])
}
return out
case fcReadCoils, fcReadDiscrete:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
nbytes := (int(qty) + 7) / 8
out := []byte{fc, byte(nbytes)}
bits := make([]byte, nbytes)
src := s.coils
if fc == fcReadDiscrete {
src = s.discrete
}
for i := uint16(0); i < qty; i++ {
if src[addr+i] {
bits[i/8] |= 1 << (i % 8)
}
}
return append(out, bits...)
case fcWriteSingleReg:
addr := binary.BigEndian.Uint16(pdu[1:])
val := binary.BigEndian.Uint16(pdu[3:])
s.holding[addr] = val
s.lastWrite = []uint16{val}
return pdu // echo
case fcWriteSingleCoil:
addr := binary.BigEndian.Uint16(pdu[1:])
s.coils[addr] = binary.BigEndian.Uint16(pdu[3:]) == 0xFF00
return pdu
case fcWriteMultipleRegs:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
s.lastWrite = nil
for i := uint16(0); i < qty; i++ {
v := binary.BigEndian.Uint16(pdu[6+i*2:])
s.holding[addr+i] = v
s.lastWrite = append(s.lastWrite, v)
}
return append([]byte{fc}, pdu[1:5]...)
default:
return []byte{fc | 0x80, 0x01}
}
}
func (s *mockServer) setHolding(addr, val uint16) {
s.mu.Lock()
s.holding[addr] = val
s.mu.Unlock()
}
func (s *mockServer) setInput(addr, val uint16) {
s.mu.Lock()
s.input[addr] = val
s.mu.Unlock()
}
func (s *mockServer) setDiscrete(addr uint16, on bool) {
s.mu.Lock()
s.discrete[addr] = on
s.mu.Unlock()
}
func testConfig(addr string) Config {
return Config{
Enabled: true,
PollIntervalMs: 20,
Devices: []Device{{
Name: "dev",
Address: addr,
UnitID: 1,
Registers: []Register{
{Name: "temp", Kind: "holding", Address: 10, Encoding: "int16", Scale: 0.1, Unit: "C", Writable: true},
{Name: "count", Kind: "holding", Address: 20, Encoding: "uint16"},
{Name: "big", Kind: "input", Address: 30, Encoding: "uint32"},
{Name: "flag", Kind: "discrete", Address: 5},
{Name: "relay", Kind: "coil", Address: 6, Writable: true},
{Name: "sp", Kind: "holding", Address: 40, Encoding: "float32", Writable: true},
},
}},
}
}
func TestReadRegisters(t *testing.T) {
srv := newMockServer(t)
srv.setHolding(10, 235) // int16, scale 0.1 → 23.5
srv.setHolding(20, 7)
srv.setInput(30, 0)
srv.setInput(31, 1000) // uint32 big-word-first: low word at 31
srv.setDiscrete(5, true)
m, err := New(testConfig(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer m.Close()
temp, err := m.readSignal(m.signals["dev:temp"])
if err != nil {
t.Fatalf("read temp: %v", err)
}
if f, ok := temp.Data.(float64); !ok || f < 23.49 || f > 23.51 {
t.Errorf("temp = %v (%T), want 23.5", temp.Data, temp.Data)
}
count, err := m.readSignal(m.signals["dev:count"])
if err != nil {
t.Fatalf("read count: %v", err)
}
if v, ok := count.Data.(int64); !ok || v != 7 {
t.Errorf("count = %v (%T), want int64 7", count.Data, count.Data)
}
big, err := m.readSignal(m.signals["dev:big"])
if err != nil {
t.Fatalf("read big: %v", err)
}
if v, ok := big.Data.(int64); !ok || v != 1000 {
t.Errorf("big = %v (%T), want int64 1000", big.Data, big.Data)
}
flag, err := m.readSignal(m.signals["dev:flag"])
if err != nil {
t.Fatalf("read flag: %v", err)
}
if b, ok := flag.Data.(bool); !ok || !b {
t.Errorf("flag = %v, want true", flag.Data)
}
}
func TestWriteRoundTrip(t *testing.T) {
srv := newMockServer(t)
m, err := New(testConfig(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer m.Close()
ctx := context.Background()
// Scaled int16: writing 23.5 with scale 0.1 should store raw 235.
if err := m.Write(ctx, "dev:temp", 23.5); err != nil {
t.Fatalf("write temp: %v", err)
}
srv.mu.Lock()
raw := srv.holding[10]
srv.mu.Unlock()
if raw != 235 {
t.Errorf("holding[10] = %d, want 235", raw)
}
// Coil write.
if err := m.Write(ctx, "dev:relay", true); err != nil {
t.Fatalf("write relay: %v", err)
}
srv.mu.Lock()
on := srv.coils[6]
srv.mu.Unlock()
if !on {
t.Error("coil 6 not set")
}
// float32 multi-register write.
if err := m.Write(ctx, "dev:sp", 12.5); err != nil {
t.Fatalf("write sp: %v", err)
}
got, err := m.readSignal(m.signals["dev:sp"])
if err != nil {
t.Fatalf("read sp: %v", err)
}
if f, ok := got.Data.(float64); !ok || f < 12.49 || f > 12.51 {
t.Errorf("sp = %v, want 12.5", got.Data)
}
}
func TestWriteErrors(t *testing.T) {
srv := newMockServer(t)
m, _ := New(testConfig(srv.addr()))
defer m.Close()
ctx := context.Background()
if err := m.Write(ctx, "dev:missing", 1); err != datasource.ErrNotFound {
t.Errorf("missing write err = %v, want ErrNotFound", err)
}
// Input register is read-only.
if err := m.Write(ctx, "dev:big", 1); err != datasource.ErrNotWritable {
t.Errorf("input write err = %v, want ErrNotWritable", err)
}
}
func TestSubscribe(t *testing.T) {
srv := newMockServer(t)
srv.setHolding(20, 42)
m, _ := New(testConfig(srv.addr()))
defer m.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := m.Subscribe(ctx, "dev:count", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("quality = %v, want good", v.Quality)
}
if iv, ok := v.Data.(int64); !ok || iv != 42 {
t.Errorf("first value = %v, want 42", v.Data)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for first value")
}
}
func TestSubscribeBadQualityOnError(t *testing.T) {
// Point at a closed port so reads fail; expect QualityBad, not a hang.
cfg := testConfig("127.0.0.1:1") // port 1: connection refused
m, _ := New(cfg)
defer m.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 1)
stop, err := m.Subscribe(ctx, "dev:count", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityBad {
t.Errorf("quality = %v, want bad", v.Quality)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestNewValidation(t *testing.T) {
if _, err := New(Config{Devices: []Device{{Name: "", Address: "x"}}}); err == nil {
t.Error("expected error for missing device name")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
t.Error("expected error for duplicate device")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r", Encoding: "bogus"}}}}}); err == nil {
t.Error("expected error for bad encoding")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r"}, {Name: "r"}}}}}); err == nil {
t.Error("expected error for duplicate register")
}
}
func TestSubscribeUnknownSignal(t *testing.T) {
m, _ := New(testConfig("127.0.0.1:502"))
defer m.Close()
if _, err := m.Subscribe(context.Background(), "nope", nil); err != datasource.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
}
if _, err := m.GetMetadata(context.Background(), "nope"); err != datasource.ErrNotFound {
t.Errorf("meta err = %v, want ErrNotFound", err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package scpi
import "github.com/uopi/uopi/internal/datasource"
// Config is the [datasource.scpi] section. Each instrument is polled
// independently over its own TCP socket.
type Config struct {
Enabled bool `toml:"enabled"`
// PollIntervalMs is the default polling period for channels that do not
// override it. Zero → 1000 ms.
PollIntervalMs int `toml:"poll_interval_ms"`
Instruments []Instrument `toml:"instruments"`
}
// Instrument is one SCPI device reachable over a raw TCP socket. Address is
// "host:port"; the conventional SCPI-raw port 5025 is appended if absent.
type Instrument struct {
Name string `toml:"name"`
// Transport selects the link type. "raw" (default) is line-based SCPI over
// TCP. Reserved: "vxi11" (not yet implemented).
Transport string `toml:"transport"`
Address string `toml:"address"`
TimeoutMs int `toml:"timeout_ms"`
// Terminator is appended to every command. Empty → "\n".
Terminator string `toml:"terminator"`
Channels []Channel `toml:"channels"`
}
// Channel maps a SCPI query/command pair onto a signal named
// "instrument:channel".
type Channel struct {
Name string `toml:"name"`
// Query is the SCPI command whose response is the channel value,
// e.g. "MEAS:VOLT?". Required.
Query string `toml:"query"`
// WriteCmd is a printf-style template used by Write; "%v" is replaced with
// the value, e.g. "VOLT %v". Empty → channel is read-only.
WriteCmd string `toml:"write_cmd"`
// Type is the value type: "float" (default), "string", "int", or "bool".
Type string `toml:"type"`
Unit string `toml:"unit"`
Min float64 `toml:"min"`
Max float64 `toml:"max"`
PollIntervalMs int `toml:"poll_interval_ms"`
Description string `toml:"description"`
}
func (c Channel) dataType() datasource.DataType {
switch c.Type {
case "string":
return datasource.TypeString
case "int":
return datasource.TypeInt64
case "bool":
return datasource.TypeBool
default:
return datasource.TypeFloat64
}
}
func (c Channel) writable() bool { return c.WriteCmd != "" }
func (c Channel) metadata(instrument string) datasource.Metadata {
return datasource.Metadata{
Name: instrument + ":" + c.Name,
Type: c.dataType(),
Unit: c.Unit,
Description: c.Description,
DisplayLow: c.Min,
DisplayHigh: c.Max,
DriveLow: c.Min,
DriveHigh: c.Max,
Writable: c.writable(),
}
}
+237
View File
@@ -0,0 +1,237 @@
// Package scpi implements a SCPI instrument data source. Each configured
// instrument is reached over a raw TCP socket (line-based SCPI, the "SCPI raw" /
// port 5025 convention); a VXI-11 transport is reserved for later. Channels are
// exposed as signals named "instrument:channel" and polled at a configurable
// interval. Channels with a write_cmd template accept Write.
package scpi
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const defaultPollInterval = time.Second
type instrumentConn struct {
inst Instrument
tr transport
}
// Scpi is a datasource.DataSource backed by one or more SCPI instruments.
type Scpi struct {
pollInterval time.Duration
instruments map[string]*instrumentConn
signals map[string]signalRef // "instrument:channel" → ref
}
type signalRef struct {
instrument string
ch Channel
}
// New builds a SCPI source from config. It does not dial; connections are
// established lazily on first poll/write.
func New(cfg Config) (*Scpi, error) {
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
if poll <= 0 {
poll = defaultPollInterval
}
s := &Scpi{
pollInterval: poll,
instruments: make(map[string]*instrumentConn),
signals: make(map[string]signalRef),
}
for _, inst := range cfg.Instruments {
if inst.Name == "" || inst.Address == "" {
return nil, fmt.Errorf("scpi: instrument needs name and address")
}
if _, dup := s.instruments[inst.Name]; dup {
return nil, fmt.Errorf("scpi: duplicate instrument %q", inst.Name)
}
tr, err := newTransport(inst)
if err != nil {
return nil, err
}
ic := &instrumentConn{inst: inst, tr: tr}
for _, ch := range inst.Channels {
if ch.Name == "" || ch.Query == "" {
return nil, fmt.Errorf("scpi: instrument %q channel needs name and query", inst.Name)
}
key := inst.Name + ":" + ch.Name
if _, dup := s.signals[key]; dup {
return nil, fmt.Errorf("scpi: instrument %q duplicate channel %q", inst.Name, ch.Name)
}
s.signals[key] = signalRef{instrument: inst.Name, ch: ch}
}
s.instruments[inst.Name] = ic
}
return s, nil
}
// newTransport selects the transport implementation for an instrument.
func newTransport(inst Instrument) (transport, error) {
addr := inst.Address
switch strings.ToLower(inst.Transport) {
case "", "raw":
if !strings.Contains(addr, ":") {
addr += ":5025"
}
return newRawSocket(addr, time.Duration(inst.TimeoutMs)*time.Millisecond, inst.Terminator), nil
case "vxi11":
return nil, fmt.Errorf("scpi: vxi11 transport not yet implemented")
default:
return nil, fmt.Errorf("scpi: unknown transport %q", inst.Transport)
}
}
// Name implements datasource.DataSource.
func (s *Scpi) Name() string { return "scpi" }
// Connect is a no-op; connections are dialled lazily per instrument.
func (s *Scpi) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every configured channel.
func (s *Scpi) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
out := make([]datasource.Metadata, 0, len(s.signals))
for _, ref := range s.signals {
out = append(out, ref.ch.metadata(ref.instrument))
}
return out, nil
}
// GetMetadata returns metadata for one signal.
func (s *Scpi) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
ref, ok := s.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return ref.ch.metadata(ref.instrument), nil
}
// readSignal performs one synchronous query of a channel.
func (s *Scpi) readSignal(ref signalRef) (datasource.Value, error) {
ic := s.instruments[ref.instrument]
resp, err := ic.tr.query(ref.ch.Query)
if err != nil {
return datasource.Value{}, err
}
data, err := parseValue(ref.ch.dataType(), resp)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: time.Now(), Data: data, Quality: datasource.QualityGood}, nil
}
// Subscribe polls the channel at its configured interval (falling back to the
// source default) and pushes values into ch. A query error emits QualityBad and
// polling continues.
func (s *Scpi) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
ref, ok := s.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
interval := s.pollInterval
if ref.ch.PollIntervalMs > 0 {
interval = time.Duration(ref.ch.PollIntervalMs) * time.Millisecond
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
emit := func() {
v, err := s.readSignal(ref)
if err != nil {
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
}
select {
case ch <- v:
case <-ctx.Done():
}
}
emit()
for {
select {
case <-ticker.C:
emit()
case <-ctx.Done():
return
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write sends the channel's write_cmd template with the value substituted.
func (s *Scpi) Write(_ context.Context, signal string, value any) error {
ref, ok := s.signals[signal]
if !ok {
return datasource.ErrNotFound
}
if !ref.ch.writable() {
return datasource.ErrNotWritable
}
ic := s.instruments[ref.instrument]
cmd := formatWrite(ref.ch.WriteCmd, value)
return ic.tr.write(cmd)
}
// History is unavailable for SCPI instruments.
func (s *Scpi) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// Close tears down every instrument connection.
func (s *Scpi) Close() {
for _, ic := range s.instruments {
ic.tr.close()
}
}
// formatWrite substitutes value into the write template. A "%" in the template
// is treated as a printf verb; otherwise the value is appended after a space.
func formatWrite(tmpl string, value any) string {
if strings.Contains(tmpl, "%") {
return fmt.Sprintf(tmpl, value)
}
return fmt.Sprintf("%s %v", tmpl, value)
}
// parseValue converts a raw SCPI response string into the channel's data type.
func parseValue(t datasource.DataType, resp string) (any, error) {
resp = strings.TrimSpace(resp)
switch t {
case datasource.TypeString:
return resp, nil
case datasource.TypeInt64:
// Accept "12", "12.0", or scientific notation by going through float.
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse int %q: %w", resp, err)
}
return int64(f), nil
case datasource.TypeBool:
switch strings.ToUpper(resp) {
case "1", "ON", "TRUE":
return true, nil
case "0", "OFF", "FALSE":
return false, nil
}
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse bool %q: %w", resp, err)
}
return f != 0, nil
default:
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse float %q: %w", resp, err)
}
return f, nil
}
}
+267
View File
@@ -0,0 +1,267 @@
package scpi
import (
"bufio"
"context"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
// mockInstrument is an in-process line-based SCPI server. It answers queries
// from a fixed table and records commands that produce no response (writes).
type mockInstrument struct {
ln net.Listener
mu sync.Mutex
answers map[string]string // query → response
writes []string
}
func newMockInstrument(t *testing.T) *mockInstrument {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
m := &mockInstrument{ln: ln, answers: map[string]string{}}
go m.serve()
t.Cleanup(func() { ln.Close() })
return m
}
func (m *mockInstrument) addr() string { return m.ln.Addr().String() }
func (m *mockInstrument) setAnswer(q, a string) {
m.mu.Lock()
m.answers[q] = a
m.mu.Unlock()
}
func (m *mockInstrument) writeLog() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.writes...)
}
func (m *mockInstrument) serve() {
for {
conn, err := m.ln.Accept()
if err != nil {
return
}
go m.handle(conn)
}
}
func (m *mockInstrument) handle(conn net.Conn) {
defer conn.Close()
br := bufio.NewReader(conn)
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimRight(line, "\r\n")
m.mu.Lock()
if strings.HasSuffix(cmd, "?") {
resp, ok := m.answers[cmd]
if !ok {
resp = "0"
}
m.mu.Unlock()
conn.Write([]byte(resp + "\n"))
continue
}
m.writes = append(m.writes, cmd)
m.mu.Unlock()
}
}
func testCfg(addr string) Config {
return Config{
Enabled: true,
PollIntervalMs: 20,
Instruments: []Instrument{{
Name: "dmm",
Address: addr,
Channels: []Channel{
{Name: "volt", Query: "MEAS:VOLT?", WriteCmd: "VOLT %v", Type: "float", Unit: "V"},
{Name: "id", Query: "*IDN?", Type: "string"},
{Name: "n", Query: "COUNT?", Type: "int"},
{Name: "out", Query: "OUTP?", WriteCmd: "OUTP", Type: "bool"},
},
}},
}
}
func TestQueryTypes(t *testing.T) {
srv := newMockInstrument(t)
srv.setAnswer("MEAS:VOLT?", "12.34")
srv.setAnswer("*IDN?", "ACME,DMM,1,2.0")
srv.setAnswer("COUNT?", "7")
srv.setAnswer("OUTP?", "ON")
s, err := New(testCfg(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer s.Close()
v, err := s.readSignal(s.signals["dmm:volt"])
if err != nil {
t.Fatalf("read volt: %v", err)
}
if f, ok := v.Data.(float64); !ok || f < 12.33 || f > 12.35 {
t.Errorf("volt = %v (%T), want 12.34", v.Data, v.Data)
}
id, _ := s.readSignal(s.signals["dmm:id"])
if id.Data != "ACME,DMM,1,2.0" {
t.Errorf("id = %v", id.Data)
}
n, _ := s.readSignal(s.signals["dmm:n"])
if iv, ok := n.Data.(int64); !ok || iv != 7 {
t.Errorf("n = %v (%T), want 7", n.Data, n.Data)
}
out, _ := s.readSignal(s.signals["dmm:out"])
if b, ok := out.Data.(bool); !ok || !b {
t.Errorf("out = %v, want true", out.Data)
}
}
func TestWrite(t *testing.T) {
srv := newMockInstrument(t)
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx := context.Background()
if err := s.Write(ctx, "dmm:volt", 3.3); err != nil {
t.Fatalf("write volt: %v", err)
}
// bool write uses a template with no verb → "OUTP <val>".
if err := s.Write(ctx, "dmm:out", true); err != nil {
t.Fatalf("write out: %v", err)
}
// Give the server a moment to record both writes.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(srv.writeLog()) >= 2 {
break
}
time.Sleep(5 * time.Millisecond)
}
got := srv.writeLog()
if len(got) != 2 || got[0] != "VOLT 3.3" || got[1] != "OUTP true" {
t.Errorf("writes = %v, want [VOLT 3.3, OUTP true]", got)
}
}
func TestWriteErrors(t *testing.T) {
srv := newMockInstrument(t)
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx := context.Background()
if err := s.Write(ctx, "dmm:missing", 1); err != datasource.ErrNotFound {
t.Errorf("missing = %v, want ErrNotFound", err)
}
if err := s.Write(ctx, "dmm:id", 1); err != datasource.ErrNotWritable {
t.Errorf("read-only = %v, want ErrNotWritable", err)
}
}
func TestSubscribe(t *testing.T) {
srv := newMockInstrument(t)
srv.setAnswer("MEAS:VOLT?", "5.0")
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "dmm:volt", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("quality = %v", v.Quality)
}
if f, ok := v.Data.(float64); !ok || f != 5.0 {
t.Errorf("value = %v, want 5.0", v.Data)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestSubscribeBadQuality(t *testing.T) {
cfg := testCfg("127.0.0.1:1") // refused
s, _ := New(cfg)
defer s.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 1)
stop, _ := s.Subscribe(ctx, "dmm:volt", ch)
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityBad {
t.Errorf("quality = %v, want bad", v.Quality)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestValidation(t *testing.T) {
if _, err := New(Config{Instruments: []Instrument{{Name: "", Address: "x"}}}); err == nil {
t.Error("want error for missing name")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
t.Error("want error for duplicate instrument")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "vxi11"}}}); err == nil {
t.Error("want error for unimplemented vxi11 transport")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "bogus"}}}); err == nil {
t.Error("want error for unknown transport")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Channels: []Channel{{Name: "c"}}}}}); err == nil {
t.Error("want error for channel without query")
}
}
func TestFormatWrite(t *testing.T) {
if got := formatWrite("VOLT %v", 3.3); got != "VOLT 3.3" {
t.Errorf("verb template = %q", got)
}
if got := formatWrite("OUTP", true); got != "OUTP true" {
t.Errorf("plain template = %q", got)
}
}
func TestParseValue(t *testing.T) {
if v, _ := parseValue(datasource.TypeBool, "OFF"); v != false {
t.Errorf("OFF = %v, want false", v)
}
if v, _ := parseValue(datasource.TypeBool, "2.0"); v != true {
t.Errorf("2.0 bool = %v, want true", v)
}
if _, err := parseValue(datasource.TypeFloat64, "notnum"); err == nil {
t.Error("want parse error for non-numeric float")
}
}
+107
View File
@@ -0,0 +1,107 @@
package scpi
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"time"
)
// transport is the request/response channel to an instrument. The raw-socket
// implementation below speaks line-oriented SCPI over TCP (the common
// "SCPI raw" / port 5025 convention). The interface is kept narrow so a VXI-11
// (ONC-RPC) transport can be added later without touching the data source.
type transport interface {
// query sends cmd and returns the instrument's single-line response.
query(cmd string) (string, error)
// write sends cmd and does not wait for a response.
write(cmd string) error
close()
}
// rawSocket is a line-based SCPI transport over a single TCP connection. The
// connection is dialled lazily and dropped on any I/O error so the next call
// reconnects. Calls are serialised by mu because SCPI is request/response.
type rawSocket struct {
addr string
timeout time.Duration
terminator string
mu sync.Mutex
conn net.Conn
br *bufio.Reader
}
func newRawSocket(addr string, timeout time.Duration, terminator string) *rawSocket {
if timeout <= 0 {
timeout = 3 * time.Second
}
if terminator == "" {
terminator = "\n"
}
return &rawSocket{addr: addr, timeout: timeout, terminator: terminator}
}
func (s *rawSocket) dialLocked() error {
if s.conn != nil {
return nil
}
conn, err := net.DialTimeout("tcp", s.addr, s.timeout)
if err != nil {
return fmt.Errorf("scpi: dial %s: %w", s.addr, err)
}
s.conn = conn
s.br = bufio.NewReader(conn)
return nil
}
func (s *rawSocket) closeLocked() {
if s.conn != nil {
_ = s.conn.Close()
s.conn = nil
s.br = nil
}
}
func (s *rawSocket) close() {
s.mu.Lock()
s.closeLocked()
s.mu.Unlock()
}
func (s *rawSocket) sendLocked(cmd string) error {
_ = s.conn.SetDeadline(time.Now().Add(s.timeout))
if _, err := s.conn.Write([]byte(cmd + s.terminator)); err != nil {
s.closeLocked()
return fmt.Errorf("scpi: write: %w", err)
}
return nil
}
func (s *rawSocket) write(cmd string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.dialLocked(); err != nil {
return err
}
return s.sendLocked(cmd)
}
func (s *rawSocket) query(cmd string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.dialLocked(); err != nil {
return "", err
}
if err := s.sendLocked(cmd); err != nil {
return "", err
}
line, err := s.br.ReadString('\n')
if err != nil {
s.closeLocked()
return "", fmt.Errorf("scpi: read: %w", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
+51
View File
@@ -129,3 +129,54 @@ auto_sync_filter = "" # e.g. area=StorageRing or tags=production
[datasource.synthetic]
enabled = true
# Modbus TCP. Each device is polled over its own connection; registers become
# signals named "device:register". Disabled unless enabled = true and at least
# one device is declared.
[datasource.modbus]
enabled = false
poll_interval_ms = 1000 # default poll period for every register
# [[datasource.modbus.devices]]
# name = "plc1"
# address = "192.168.1.50:502" # ":502" appended if no port given
# unit_id = 1
# timeout_ms = 3000
#
# [[datasource.modbus.devices.registers]]
# name = "temperature"
# kind = "holding" # holding | input | coil | discrete
# address = 100
# encoding = "int16" # uint16|int16|uint32|int32|float32|float64
# word_order = "big" # big (default) | little, for multi-word encodings
# unit = "C"
# scale = 0.1 # value = raw*scale + offset (scale 0 → 1)
# offset = 0.0
# min = -50
# max = 200
# writable = false # input/discrete are always read-only
# description = "Process temperature"
# SCPI instruments over raw TCP sockets (line-based "SCPI raw", port 5025).
# Channels become signals named "instrument:channel". A vxi11 transport is
# reserved for a future release.
[datasource.scpi]
enabled = false
poll_interval_ms = 1000
# [[datasource.scpi.instruments]]
# name = "dmm1"
# transport = "raw" # raw (default); vxi11 reserved
# address = "192.168.1.60:5025" # ":5025" appended if no port given
# timeout_ms = 3000
# terminator = "\n"
#
# [[datasource.scpi.instruments.channels]]
# name = "voltage"
# query = "MEAS:VOLT?" # SCPI query whose response is the value
# write_cmd = "VOLT %v" # printf template; omit for read-only
# type = "float" # float (default) | int | string | bool
# unit = "V"
# min = 0
# max = 30
# description = "DC voltage"
+5 -3
View File
@@ -1,7 +1,7 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { initLocalState } from './lib/localstate';
import { logicEngine } from './lib/logic';
import { logicEngine, ensureArrayDecls } from './lib/logic';
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
import type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView';
@@ -119,9 +119,11 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
}
// Instantiate this panel's local state variables from their initial values.
// ensureArrayDecls auto-declares any array referenced by logic nodes that
// are not yet declared as state vars (backward-compat migration).
useEffect(() => {
initLocalState(iface?.statevars);
}, [iface?.id, iface?.statevars]);
initLocalState(ensureArrayDecls(iface?.logic, iface?.statevars ?? []));
}, [iface?.id, iface?.statevars, iface?.logic]);
// Activate panel logic for the live view; tear it down on unmount/panel switch.
useEffect(() => {
+6 -3
View File
@@ -3,6 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph, Folder } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate';
import { ensureArrayDecls } from './lib/logic';
import SignalTree from './SignalTree';
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
@@ -251,9 +252,11 @@ export default function EditMode({ initial, onDone }: Props) {
}, []);
const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
setIface(f => ({ ...f, statevars }));
setIface(f => {
initLocalState(ensureArrayDecls(f.logic, statevars));
return { ...f, statevars };
});
setDirty(true);
initLocalState(statevars);
}, []);
const handleLogicChange = useCallback((logic: LogicGraph) => {
@@ -264,7 +267,7 @@ export default function EditMode({ initial, onDone }: Props) {
// Instantiate local state variables when the edited panel loads/changes so
// they can be previewed live in the canvas just like real signals.
useEffect(() => {
initLocalState(iface.statevars);
initLocalState(ensureArrayDecls(iface.logic, iface.statevars ?? []));
}, [iface.id]);
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
+1 -1
View File
@@ -657,7 +657,7 @@ function SectionLogic() {
{[
{ type: 'Triggers', desc: 'Button press, threshold crossing, value change, timer/interval, panel loop, and On-open / On-close lifecycle.' },
{ type: 'Logic', desc: 'AND gate, If (then/else branches), and Loop (count or while).' },
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), and Accumulate / Export-CSV / Clear for in-memory data arrays.' },
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), Array push/set/remove/pop/clear on an array local variable, and Export-CSV.' },
{ type: 'Dialogs', desc: 'Info and Error pop-ups, and a Set-point prompt that asks the user for a number and writes it to a target.' },
].map(w => (
<div key={w.type} class="help-widget-row">
+136 -53
View File
@@ -73,9 +73,7 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
{ kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"data","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
{ kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.widget', label: 'Widget control', params: { widget: '', op: 'disable' } },
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
@@ -86,6 +84,11 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', expr: '' } },
{ kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '', target: '' } },
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '', target: '' } },
{ kind: 'action.array.push', label: 'Array push', params: { array: '', expr: '' } },
{ kind: 'action.array.set', label: 'Array set', params: { array: '', index: '', expr: '' } },
{ kind: 'action.array.remove', label: 'Array remove', params: { array: '', index: '' } },
{ kind: 'action.array.pop', label: 'Array pop', params: { array: '' } },
{ kind: 'action.array.clear', label: 'Array clear', params: { array: '' } },
];
const KIND_LABEL: Record<LogicNodeKind, string> = {
@@ -101,9 +104,7 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'flow.loop': 'Loop',
'action.write': 'Write',
'action.delay': 'Delay',
'action.accumulate': 'Accumulate',
'action.export': 'Export CSV',
'action.clear': 'Clear array',
'action.log': 'Log',
'action.widget': 'Widget control',
'action.dialog.info': 'Info dialog',
@@ -114,6 +115,11 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.config.write': 'Write config',
'action.config.create': 'Create config',
'action.config.snapshot': 'Snapshot config',
'action.array.push': 'Array push',
'action.array.set': 'Array set',
'action.array.remove': 'Array remove',
'action.array.pop': 'Array pop',
'action.array.clear': 'Array clear',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -565,12 +571,6 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const byId = new Map(nodes.map(n => [n.id, n]));
// Array names already referenced by accumulate/export/clear nodes, offered as
// autocomplete suggestions so the same array is reused across nodes.
const arrayNames = Array.from(new Set(
nodes.map(n => n.params.array).filter((a): a is string => !!a)
));
// Append a {ds:name} reference into a node's expression field.
function insertRef(id: string, field: string, ds: string, sig: string) {
const cur = (byId.get(id)?.params[field]) ?? '';
@@ -670,9 +670,6 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
{onStateVarsChange && (
<LocalVars statevars={statevars ?? []} onChange={onStateVarsChange} />
)}
<datalist id="flow-array-names">
{arrayNames.map(a => <option key={a} value={a} />)}
</datalist>
</div>
<div class="flow-canvas" ref={canvasRef}
@@ -1019,25 +1016,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</Fragment>
)}
{selected.kind === 'action.accumulate' && (
<Fragment>
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
{selected.kind === 'action.export' && (
<ExportEditor
columns={parseExportColumns(selected)}
arrayLocals={(statevars ?? []).filter(v => v.type === 'array').map(v => v.name)}
align={selected.params.align ?? 'common'}
filename={selected.params.filename ?? ''}
onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
@@ -1045,15 +1027,49 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
onFilename={(f) => patchParams(selected.id, { filename: f })} />
)}
{selected.kind === 'action.clear' && (
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set' ||
selected.kind === 'action.array.remove' || selected.kind === 'action.array.pop' ||
selected.kind === 'action.array.clear') && (() => {
const arrayLocals = (statevars ?? []).filter(v => v.type === 'array');
return (
<Fragment>
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
<p class="hint">Empties the named array (e.g. to start a fresh capture).</p>
<label>Array (local variable)</label>
<select class="prop-select" value={selected.params.array ?? ''}
onChange={(e) => patchParams(selected.id, { array: (e.target as HTMLSelectElement).value })}>
<option value=""> select array </option>
{arrayLocals.map(v => <option key={v.name} value={v.name}>{v.name}</option>)}
</select>
{arrayLocals.length === 0 && (
<p class="hint">No array locals declared yet add one in the Local vars panel.</p>
)}
</div>
{(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && (
<div class="wizard-field">
<label>Index / path (comma-separated)</label>
<input class="prop-input" value={selected.params.index ?? ''}
placeholder="e.g. 0 or 2,1"
onInput={(e) => patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} />
<p class="hint">Single integer for a flat array; comma-separated integers for a nested array.</p>
</div>
)}
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && (
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint={selected.kind === 'action.array.push'
? 'Value to append to the end of the array.'
: 'Value to store at the given index / path.'} />
)}
{(selected.kind === 'action.array.pop' || selected.kind === 'action.array.clear') && (
<p class="hint">{selected.kind === 'action.array.pop'
? 'Removes and returns the last element of the array.'
: 'Removes all elements from the array.'}</p>
)}
</Fragment>
);
})()}
{selected.kind === 'action.log' && (
<Fragment>
@@ -1210,9 +1226,9 @@ function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals
interface ExportColumn { array: string; label: string; }
// The columns configured on an action.export node. Reads the JSON `columns`
// param; falls back to the legacy single `array` param (so old panels migrate
// transparently the first time the node is edited).
// The columns configured on an action.export node, read from the JSON `columns`
// param. Legacy single-`array` nodes are migrated to this form on load (see
// xml.ts migrateLegacyArrayNode), so only the JSON form is read here.
function parseExportColumns(n: LogicNode): ExportColumn[] {
const raw = (n.params.columns ?? '').trim();
if (raw) {
@@ -1223,8 +1239,7 @@ function parseExportColumns(n: LogicNode): ExportColumn[] {
}
} catch {}
}
const a = (n.params.array ?? '').trim();
return [{ array: a, label: '' }];
return [{ array: '', label: '' }];
}
const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
@@ -1233,8 +1248,9 @@ const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
{ value: 'interpolate', label: 'Interpolate', hint: 'Union of all timestamps; missing cells linearly interpolated.' },
];
function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename }: {
function ExportEditor({ columns, arrayLocals, align, filename, onColumns, onAlign, onFilename }: {
columns: ExportColumn[];
arrayLocals: string[];
align: string;
filename: string;
onColumns: (cols: ExportColumn[]) => void;
@@ -1248,12 +1264,15 @@ function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename
return (
<Fragment>
<div class="wizard-field">
<label>Columns (arrays)</label>
<label>Columns (array local variables)</label>
{columns.map((c, i) => (
<div key={i} class="flow-row-edit">
<input class="prop-input" value={c.array} list="flow-array-names"
placeholder="array name"
onInput={(e) => patch(i, { array: (e.target as HTMLInputElement).value })} />
<select class="prop-select" value={c.array}
onChange={(e) => patch(i, { array: (e.target as HTMLSelectElement).value })}>
<option value=""> select array </option>
{c.array && !arrayLocals.includes(c.array) && <option value={c.array}>{c.array}</option>}
{arrayLocals.map(name => <option key={name} value={name}>{name}</option>)}
</select>
<input class="prop-input" value={c.label}
placeholder="column label (optional)"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
@@ -1263,6 +1282,9 @@ function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename
</div>
))}
<button class="panel-btn flow-row-add" onClick={() => onColumns([...columns, { array: '', label: '' }])}>+ Add column</button>
{arrayLocals.length === 0 && (
<p class="hint">No array locals declared yet add one in the Local vars panel.</p>
)}
</div>
{columns.length > 1 && (
<div class="wizard-field">
@@ -1397,13 +1419,16 @@ function nodeSummary(n: LogicNode): string {
: `while ${n.params.cond || '?'}`;
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`;
case 'action.export': {
const cols = parseExportColumns(n).filter(c => c.array);
const names = cols.map(c => c.label || c.array).join(', ');
return `export ${names || '?'} → csv`;
}
case 'action.clear': return `clear ${n.params.array || '?'}`;
case 'action.array.push': return `push ${n.params.expr || ''} ${n.params.array || '?'}`;
case 'action.array.set': return `${n.params.array || '?'}[${n.params.index || '?'}] = ${n.params.expr || ''}`;
case 'action.array.remove': return `remove ${n.params.array || '?'}[${n.params.index || '?'}]`;
case 'action.array.pop': return `pop ${n.params.array || '?'}`;
case 'action.array.clear': return `clear ${n.params.array || '?'}`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.widget': return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`;
@@ -1429,15 +1454,47 @@ function LocalVars({ statevars, onChange }: {
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [type, setType] = useState<'number' | 'bool' | 'string'>('number');
const [type, setType] = useState<'number' | 'bool' | 'string' | 'array'>('number');
const [initial, setInitial] = useState('0');
// array-specific fields
const [elem, setElem] = useState<'number' | 'bool' | 'array'>('number');
const [sizing, setSizing] = useState<'dynamic' | 'capped' | 'fixed'>('dynamic');
const [capacity, setCapacity] = useState('100');
const [jsonErr, setJsonErr] = useState('');
function handleInitialChange(v: string) {
setInitial(v);
if (type === 'array') {
try { JSON.parse(v); setJsonErr(''); }
catch (e: any) { setJsonErr(e.message); }
}
}
function handleTypeChange(t: 'number' | 'bool' | 'string' | 'array') {
setType(t);
setInitial(t === 'array' ? '[]' : t === 'bool' ? 'false' : t === 'string' ? '' : '0');
setJsonErr('');
}
function add() {
const n = name.trim();
if (!n) return;
onChange([...statevars.filter(v => v.name !== n), { name: n, type, initial }]);
if (type === 'array' && jsonErr) return;
const base: StateVar = { name: n, type, initial };
if (type === 'array') {
base.elem = elem;
base.sizing = sizing;
if (sizing !== 'dynamic') base.capacity = parseInt(capacity, 10) || 100;
}
onChange([...statevars.filter(v => v.name !== n), base]);
setName('');
setInitial('0');
setType('number');
setElem('number');
setSizing('dynamic');
setCapacity('100');
setJsonErr('');
setOpen(false);
}
return (
@@ -1446,7 +1503,7 @@ function LocalVars({ statevars, onChange }: {
{statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>}
{statevars.map(v => (
<div key={v.name} class="flow-localvar-row">
<span class="flow-localvar-name" title={`${v.type ?? 'number'} = ${v.initial}`}>{v.name}</span>
<span class="flow-localvar-name" title={`${v.type ?? 'number'}${v.type === 'array' ? `<${v.elem ?? 'number'}> ${v.sizing ?? 'dynamic'}` : ''} = ${v.initial}`}>{v.name}</span>
<button class="flow-node-del" title="Remove"
onClick={() => onChange(statevars.filter(x => x.name !== v.name))}></button>
</div>
@@ -1459,15 +1516,41 @@ function LocalVars({ statevars, onChange }: {
<input class="prop-input" value={name} placeholder="name"
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
<select class="prop-select" value={type}
onChange={(e) => setType((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string')}>
onChange={(e) => handleTypeChange((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string' | 'array')}>
<option value="number">number</option>
<option value="bool">bool</option>
<option value="string">string</option>
<option value="array">array</option>
</select>
{type === 'array' && (
<Fragment>
<select class="prop-select" value={elem}
onChange={(e) => setElem((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'array')}>
<option value="number">elem: number</option>
<option value="bool">elem: bool</option>
<option value="array">elem: array</option>
</select>
<select class="prop-select" value={sizing}
onChange={(e) => setSizing((e.target as HTMLSelectElement).value as 'dynamic' | 'capped' | 'fixed')}>
<option value="dynamic">sizing: dynamic</option>
<option value="capped">sizing: capped</option>
<option value="fixed">sizing: fixed</option>
</select>
{sizing !== 'dynamic' && (
<input class="prop-input" type="number" value={capacity} placeholder="capacity"
onInput={(e) => setCapacity((e.target as HTMLInputElement).value)} />
)}
<input class={`prop-input${jsonErr ? ' prop-input-error' : ''}`} value={initial} placeholder="initial (JSON)"
onInput={(e) => handleInitialChange((e.target as HTMLInputElement).value)} />
{jsonErr && <p class="wizard-error">{jsonErr}</p>}
</Fragment>
)}
{type !== 'array' && (
<input class="prop-input" value={initial} placeholder="initial"
onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
)}
<div class="flow-localvar-actions">
<button class="panel-btn" onClick={add}>Add</button>
<button class="panel-btn" onClick={add} disabled={!name.trim() || (type === 'array' && !!jsonErr)}>Add</button>
<button class="panel-btn" onClick={() => { setOpen(false); setName(''); }}>Cancel</button>
</div>
</div>
+72 -5
View File
@@ -117,17 +117,48 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
const [showAddLocal, setShowAddLocal] = useState(false);
const [localOpen, setLocalOpen] = useState(true);
const [lvName, setLvName] = useState('');
const [lvType, setLvType] = useState<'number' | 'bool' | 'string'>('number');
const [lvType, setLvType] = useState<'number' | 'bool' | 'string' | 'array'>('number');
const [lvInitial, setLvInitial] = useState('0');
// array-specific fields (mirror LogicEditor LocalVars)
const [lvElem, setLvElem] = useState<'number' | 'bool' | 'array'>('number');
const [lvSizing, setLvSizing] = useState<'dynamic' | 'capped' | 'fixed'>('dynamic');
const [lvCapacity, setLvCapacity] = useState('100');
const [lvJsonErr, setLvJsonErr] = useState('');
function handleLvTypeChange(t: 'number' | 'bool' | 'string' | 'array') {
setLvType(t);
setLvInitial(t === 'array' ? '[]' : t === 'bool' ? 'false' : t === 'string' ? '' : '0');
setLvJsonErr('');
}
function handleLvInitialChange(v: string) {
setLvInitial(v);
if (lvType === 'array') {
try { JSON.parse(v); setLvJsonErr(''); }
catch (e: any) { setLvJsonErr(e.message); }
}
}
function addLocal() {
const name = lvName.trim();
if (!name || !onStateVarsChange) return;
if (lvType === 'array' && lvJsonErr) return;
const base: StateVar = { name, type: lvType, initial: lvInitial };
if (lvType === 'array') {
base.elem = lvElem;
base.sizing = lvSizing;
if (lvSizing !== 'dynamic') base.capacity = parseInt(lvCapacity, 10) || 100;
}
const next = (statevars ?? []).filter(v => v.name !== name);
next.push({ name, type: lvType, initial: lvInitial });
next.push(base);
onStateVarsChange(next);
setLvName('');
setLvInitial('0');
setLvType('number');
setLvElem('number');
setLvSizing('dynamic');
setLvCapacity('100');
setLvJsonErr('');
setShowAddLocal(false);
}
@@ -324,20 +355,56 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
class="prop-select"
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
value={lvType}
onChange={(e) => setLvType((e.target as HTMLSelectElement).value as any)}
onChange={(e) => handleLvTypeChange((e.target as HTMLSelectElement).value as any)}
>
<option value="number">number</option>
<option value="bool">bool</option>
<option value="string">string</option>
<option value="array">array</option>
</select>
{lvType === 'array' && (
<Fragment>
<select
class="prop-select"
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
value={lvElem}
onChange={(e) => setLvElem((e.target as HTMLSelectElement).value as any)}
>
<option value="number">elem: number</option>
<option value="bool">elem: bool</option>
<option value="array">elem: array</option>
</select>
<select
class="prop-select"
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
value={lvSizing}
onChange={(e) => setLvSizing((e.target as HTMLSelectElement).value as any)}
>
<option value="dynamic">sizing: dynamic</option>
<option value="capped">sizing: capped</option>
<option value="fixed">sizing: fixed</option>
</select>
{lvSizing !== 'dynamic' && (
<input
class="signal-add-input"
type="number"
style="width: 5rem;"
placeholder="capacity"
value={lvCapacity}
onInput={(e) => setLvCapacity((e.target as HTMLInputElement).value)}
/>
)}
</Fragment>
)}
<input
class={`signal-add-input${lvJsonErr ? ' prop-input-error' : ''}`}
style="flex: 1;"
placeholder="initial"
placeholder={lvType === 'array' ? 'initial (JSON)' : 'initial'}
value={lvInitial}
onInput={(e) => setLvInitial((e.target as HTMLInputElement).value)}
onInput={(e) => handleLvInitialChange((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') addLocal(); }}
/>
{lvJsonErr && <p class="wizard-error" style="flex-basis: 100%; margin: 0;">{lvJsonErr}</p>}
<button class="icon-btn" title="Add" onClick={addLocal}></button>
<button class="icon-btn" title="Cancel" onClick={() => setShowAddLocal(false)}></button>
</div>
+54
View File
@@ -0,0 +1,54 @@
// 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;
}
}
+111 -30
View File
@@ -2,8 +2,9 @@
//
// 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 at evaluation time:
// {ds:name} a data-source signal value (the brace content is split on the
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
// bareIdent a panel-local state variable (data source 'local').
@@ -12,6 +13,8 @@
// and any nonzero value is truthy. The evaluator never touches the DOM or eval;
// it walks a parsed AST against a caller-supplied resolver.
import type { ArrVal } from './arraypolicy';
export interface RefLite { ds: string; name: string }
type Node =
@@ -21,12 +24,12 @@ type Node =
| { t: 'un'; op: string; a: Node }
| { t: 'bin'; op: string; a: Node; b: Node }
| { t: 'tern'; c: Node; a: Node; b: Node }
| { t: 'call'; fn: string; args: Node[] };
| { t: 'call'; fn: string; args: Node[] }
| { t: 'arr'; items: Node[] }
| { t: 'index'; a: Node; i: Node };
const FUNCS: Record<string, (a: number[]) => number> = {
const SCALAR_FUNCS: Record<string, (a: number[]) => number> = {
abs: a => Math.abs(a[0]),
min: a => Math.min(...a),
max: a => Math.max(...a),
sqrt: a => Math.sqrt(a[0]),
floor: a => Math.floor(a[0]),
ceil: a => Math.ceil(a[0]),
@@ -77,8 +80,8 @@ function tokenize(src: string): Tok[] {
// two-char operators
const pair = src.slice(i, i + 2);
if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; }
// single-char operators / punctuation
if ('+-*/%<>!()?:,'.includes(c)) { toks.push({ k: c }); i++; continue; }
// single-char operators / punctuation (including brackets)
if ('+-*/%<>!()?:,[]'.includes(c)) { toks.push({ k: c }); i++; continue; }
throw new Error(`unexpected character '${c}' in expression`);
}
return toks;
@@ -97,10 +100,20 @@ function parse(src: string): Node {
return t;
};
function primary(): Node {
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!;
@@ -130,6 +143,17 @@ function parse(src: string): Node {
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;
}
function unary(): Node {
const t = peek();
if (t && (t.k === '-' || t.k === '!')) { eat(); return { t: 'un', op: t.k, a: unary() }; }
@@ -186,34 +210,83 @@ function parseCached(src: string): Node {
// ── Evaluation ─────────────────────────────────────────────────────────────
export type Resolver = (ds: string, name: string) => number;
export type Resolver = (ds: string, name: string) => ArrVal;
function ev(n: Node, R: Resolver): number {
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<number>((s, x) => s + asNum(x), 0),
mean: a => { const r = asArr(a[0]); return r.length ? r.reduce<number>((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 'un': return n.op === '-' ? -ev(n.a, R) : (ev(n.a, R) === 0 ? 1 : 0);
case 'tern': return ev(n.c, R) !== 0 ? ev(n.a, R) : ev(n.b, R);
case 'index': {
const arr = asArr(ev(n.a, R));
return arr[idx(asNum(ev(n.i, R)), arr.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 fn = FUNCS[n.fn];
if (!fn) throw new Error(`unknown function '${n.fn}'`);
return fn(n.args.map(a => ev(a, R)));
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 = ev(n.a, R), b = ev(n.b, R);
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 + 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}'`);
@@ -222,10 +295,16 @@ function ev(n: Node, R: Resolver): number {
}
}
/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated. */
/** Evaluate an expression string; returns the full value (number or array). */
export function evalValue(src: string, resolve: Resolver): ArrVal {
return ev(parseCached(src), resolve);
}
/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated or the result is an array. */
export function evalExpr(src: string, resolve: Resolver): number {
try {
return ev(parseCached(src), resolve);
const v = ev(parseCached(src), resolve);
return typeof v === 'number' ? v : NaN;
} catch {
return NaN;
}
@@ -255,6 +334,8 @@ export function collectRefs(src: string): RefLite[] {
case 'bin': walk(n.a); walk(n.b); break;
case 'tern': walk(n.c); walk(n.a); walk(n.b); break;
case 'call': n.args.forEach(walk); break;
case 'arr': n.items.forEach(walk); break;
case 'index': walk(n.a); walk(n.i); break;
}
};
walk(root);
+15 -6
View File
@@ -19,19 +19,28 @@ export interface NodeDebugState {
/** Per-node debug state, keyed by node id. */
export type DebugSnapshot = Map<string, NodeDebugState>;
/** Compact string for a raw value — used in badges and tooltips. */
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})`;
}
if (typeof v === 'number') {
if (!isFinite(v)) return String(v);
if (Number.isInteger(v)) return String(v);
return String(+v.toFixed(4));
}
return String(v);
}
/** Round-trips a value into a compact label for the on-node badge. */
export function formatBadge(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return '!';
const v = s.value;
if (v == null) return '—';
if (Array.isArray(v)) return `[${v.length}]`;
if (typeof v === 'number') {
if (!isFinite(v)) return String(v);
if (Number.isInteger(v)) return String(v);
return v.toPrecision(4).replace(/\.?0+$/, '');
}
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (Array.isArray(v) || typeof v === 'number') return fmtBadge(v);
const str = String(v);
return str.length > 10 ? str.slice(0, 9) + '…' : str;
}
+18 -1
View File
@@ -11,9 +11,15 @@
import { writable, type Readable, type Writable } from './store';
import type { SignalValue, SignalMeta, StateVar } from './types';
import { parseInitialArray, applySizing, type ArrVal } from './arraypolicy';
const valueStores = new Map<string, Writable<SignalValue>>();
const metaStores = new Map<string, Writable<SignalMeta | null>>();
const decls = new Map<string, StateVar>();
export function declaredVar(name: string): StateVar | undefined {
return decls.get(name);
}
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
@@ -42,6 +48,8 @@ function coerce(v: StateVar): any {
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;
@@ -53,6 +61,7 @@ function coerce(v: StateVar): any {
// publishes metadata. Call this whenever a panel is loaded.
export function initLocalState(vars: StateVar[] | undefined): void {
for (const v of vars ?? []) {
decls.set(v.name, v);
valueW(v.name).set({
value: coerce(v),
quality: 'good',
@@ -64,6 +73,9 @@ export function initLocalState(vars: StateVar[] | undefined): void {
displayLow: v.low ?? 0,
displayHigh: v.high ?? 100,
writable: true,
elem: v.elem,
sizing: v.sizing,
capacity: v.capacity,
});
}
}
@@ -77,6 +89,11 @@ export function getLocalMetaStore(name: string): Readable<SignalMeta | null> {
}
// writeLocalState updates a local variable's live value in place.
// When the declared variable is an array type, the written value is passed
// through applySizing to enforce the declared sizing policy.
export function writeLocalState(name: string, value: any): void {
valueW(name).set({ value, quality: 'good', ts: new Date().toISOString() });
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() });
}
+136 -76
View File
@@ -12,8 +12,8 @@
// flow.loop — repeats the 'body' port (count / while, capped), then 'done'.
// action.write— evaluates `expr` and writes the result to `target`.
// action.delay— awaits `ms` before continuing.
// action.accumulate / export / clear — collect expression values into a named
// in-memory array and download it as CSV.
// action.array.* — push/set/remove/pop/clear on a named array local variable.
// action.export— download named array local variables as CSV (one col each).
// action.log — logs an expression value to the console.
//
// Expressions reference signals inline as {ds:name} and panel-local vars as
@@ -28,8 +28,10 @@ import { wsClient } from './ws';
import { getSignalStore } from './stores';
import { writable } from './store';
import { setWidgetDisabled, setWidgetHidden, setPlotPaused, clearPlot, resetWidgetCmds } from './widgetCommands';
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
import type { SignalRef, SignalValue, LogicGraph, LogicNode, StateVar } from './types';
import { evalExpr, evalBool, collectRefs, evalValue, type Resolver } from './expr';
import { getLocalValueStore, writeLocalState, declaredVar } from './localstate';
import { applySizing, type ArrVal } from './arraypolicy';
// A single row in a dialog: either an `input` (prompts the operator for a value
// that is later written to its target) or a `display` (a read-only live value
@@ -177,27 +179,34 @@ interface DialogFieldSpec {
expr?: string; // display: expression evaluated and shown
}
// Linearly interpolate a value at time `t` from timestamp-sorted samples. Returns
// null when `t` falls outside the samples' own time range (no extrapolation).
function interpAt(samples: Array<{ t: number; v: number }>, t: number): number | null {
const n = samples.length;
if (n === 0 || t < samples[0].t || t > samples[n - 1].t) return null;
for (let i = 0; i < n - 1; i++) {
const a = samples[i], b = samples[i + 1];
if (t >= a.t && t <= b.t) {
if (b.t === a.t) return a.v;
return a.v + (b.v - a.v) * (t - a.t) / (b.t - a.t);
}
}
return samples[n - 1].v;
}
// Escape a CSV cell: quote and double inner quotes when it contains a comma,
// quote, or newline.
function csvEsc(s: string): string {
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
// Read the current array value of a local state variable. Returns an empty
// array if the variable doesn't exist or holds a non-array value.
function curArray(name: string): ArrVal[] {
const store = getLocalValueStore(name) as { get?: () => { value: any } };
const sv = typeof store.get === 'function' ? store.get() : null;
const v = sv?.value;
return Array.isArray(v) ? (v as ArrVal[]) : [];
}
// Nested index assignment: sets arr[path[0]][path[1]]...[path[n-1]] = v.
// Negative indices are resolved relative to the current sub-array length.
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;
}
interface WireOut { to: string; port: string }
// One flow run, started by an activating trigger. `dt` is the seconds elapsed
// since that trigger last fired (0 on its first fire); `resolve` is an
@@ -220,9 +229,6 @@ export class LogicEngine {
private prevVal = new Map<string, any>();
// signal key → trigger node ids that react to it (threshold / change).
private watchers = new Map<string, string[]>();
// Named in-memory data arrays, filled by action.accumulate and dumped by
// action.export. Each sample keeps the wall-clock time it was recorded.
private arrays = new Map<string, Array<{ t: number; v: number }>>();
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
private lastFire = new Map<string, number>();
private cleanups: Array<() => void> = [];
@@ -349,7 +355,6 @@ export class LogicEngine {
this.prevBool = new Map();
this.prevVal = new Map();
this.watchers = new Map();
this.arrays = new Map();
this.lastFire = new Map();
this.debugStates = new Map();
// Restore every widget to its default state so reopening a panel (whose
@@ -393,8 +398,14 @@ export class LogicEngine {
case 'flow.if': collectRefs(node.params.cond ?? '').forEach(want); break;
case 'flow.loop': if ((node.params.mode ?? 'count') === 'while') collectRefs(node.params.cond ?? '').forEach(want); break;
case 'action.write':
case 'action.accumulate':
case 'action.array.push':
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
case 'action.array.set':
collectRefs(node.params.expr ?? '').forEach(want);
String(node.params.index ?? '').split(',').forEach(s => collectRefs(s.trim()).forEach(want));
break;
case 'action.array.remove':
collectRefs(node.params.index ?? '').forEach(want); break;
case 'action.config.apply':
case 'action.config.read':
case 'action.config.write': {
@@ -611,27 +622,71 @@ export class LogicEngine {
await this.follow(node.id, 'out', ctx);
return;
case 'action.accumulate': {
// ── Array action nodes ──────────────────────────────────────────────────
case 'action.array.push': {
const name = (node.params.array ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (name) {
const val = evalValue(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (name && !isNaN(val)) {
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
arr.push({ t: Date.now(), v: val });
writeLocalState(name, [...curArray(name), val]);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.set': {
const name = (node.params.array ?? '').trim();
if (name) {
const arr = curArray(name).slice();
const path = String(node.params.index ?? '').split(',')
.map(s => Math.trunc(evalExpr(s.trim(), ctx.resolve)));
const val = evalValue(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (path.length > 0 && path.every(i => !isNaN(i))) setPath(arr, path, val);
writeLocalState(name, arr);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.remove': {
const name = (node.params.array ?? '').trim();
if (name) {
const arr = curArray(name).slice();
const i = Math.trunc(evalExpr(node.params.index ?? '0', ctx.resolve));
const k = i < 0 ? arr.length + i : i;
if (k >= 0 && k < arr.length) arr.splice(k, 1);
this.markActive(node.id, k);
writeLocalState(name, arr);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.pop': {
const name = (node.params.array ?? '').trim();
if (name) {
const arr = curArray(name).slice();
arr.pop();
writeLocalState(name, arr);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.array.clear': {
const name = (node.params.array ?? '').trim();
if (name) {
const sv = declaredVar(name);
writeLocalState(name, sv ? applySizing([], sv) : []);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.export': {
if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.clear': {
const name = (node.params.array ?? '').trim();
if (name) this.arrays.delete(name);
if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.filename ?? '');
await this.follow(node.id, 'out', ctx);
return;
}
@@ -704,8 +759,9 @@ export class LogicEngine {
}
}
// The columns an action.export node should emit. Reads the JSON `columns`
// param (a list of { array, label }); falls back to the legacy single `array`.
// The columns an action.export node should emit, read from the JSON `columns`
// param (a list of { array, label }). Legacy single-`array` nodes are migrated
// to this form on load (see xml.ts migrateLegacyArrayNode).
private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
const raw = (node.params.columns ?? '').trim();
if (raw) {
@@ -718,47 +774,24 @@ export class LogicEngine {
}
} catch {}
}
const a = (node.params.array ?? '').trim();
return a ? [{ array: a, label: a }] : [];
return [];
}
// Dump one or more named arrays to a CSV the browser downloads. Each column is
// a captured array; rows are keyed by sample timestamp. `align` controls how
// arrays with differing timestamps are combined:
// common — only timestamps present in every array (intersection).
// any — the union of all timestamps; cells with no sample are blank.
// interpolate — the union of all timestamps; a missing cell is linearly
// interpolated between the array's surrounding samples (blank
// outside the array's own time range).
private exportArrays(cols: Array<{ array: string; label: string }>, align: string, filename: string): void {
// Dump one or more named array locals to a CSV the browser downloads.
// Rows are index-aligned: row r contains col[r] for each column; cells with
// no value at that index are blank. The header row uses the column's custom
// label (or its array name as fallback).
private exportArrays(cols: Array<{ array: string; label: string }>, filename: string): void {
if (cols.length === 0) return;
const series = cols.map((c, i) => ({
label: c.label || c.array || `col${i + 1}`,
samples: (this.arrays.get(c.array) ?? []).slice().sort((a, b) => a.t - b.t),
map: new Map<number, number>(),
}));
for (const s of series) for (const p of s.samples) s.map.set(p.t, p.v);
const tsSet = new Set<number>();
for (const s of series) for (const p of s.samples) tsSet.add(p.t);
let times = Array.from(tsSet).sort((a, b) => a - b);
if (align === 'common') times = times.filter(t => series.every(s => s.map.has(t)));
const cell = (s: typeof series[number], t: number): string => {
const exact = s.map.get(t);
if (exact !== undefined) return String(exact);
if (align === 'interpolate') {
const v = interpAt(s.samples, t);
return v === null ? '' : String(v);
const data = cols.map(c => curArray(c.array));
const rows = Math.max(0, ...data.map(d => d.length));
const header = cols.map((c, i) => csvEsc(c.label || c.array || `col${i + 1}`)).join(',');
const lines = [header];
for (let r = 0; r < rows; r++) {
lines.push(data.map(d => (r < d.length ? csvEsc(String(d[r])) : '')).join(','));
}
return ''; // 'any' (and 'common' never reaches here)
};
const header = ['timestamp_ms', 'iso', ...series.map(s => s.label)];
const rows = times.map(t => [String(t), new Date(t).toISOString(), ...series.map(s => cell(s, t))]);
const csv = [header, ...rows].map(r => r.map(csvEsc).join(',')).join('\n') + '\n';
const base = filename || series.map(s => s.label).join('_') || 'export';
const csv = lines.join('\n') + '\n';
const base = filename || cols.map(c => c.label || c.array).join('_') || 'export';
const safe = base.replace(/[^a-z0-9_.-]/gi, '_');
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
const blob = new Blob([csv], { type: 'text/csv' });
@@ -855,3 +888,30 @@ export class LogicEngine {
// Singleton instance, mirrored on wsClient's module-level pattern.
export const logicEngine = new LogicEngine();
// ensureArrayDecls scans a LogicGraph for array references in accumulate /
// clear / array.* / export nodes and auto-declares any that are not already
// present in `vars` as dynamic numeric array locals. Call this before
// initLocalState so that auto-declared arrays are initialised on panel load.
export function ensureArrayDecls(graph: LogicGraph | undefined, vars: StateVar[]): StateVar[] {
if (!graph) return vars;
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.startsWith('action.array.')) {
need((n.params.array ?? '').trim());
}
if (n.kind === 'action.export') {
try {
(JSON.parse(n.params.columns || '[]') as any[]).forEach(c => need(String(c?.array ?? '').trim()));
} catch {}
}
}
return out;
}
+21 -9
View File
@@ -28,6 +28,10 @@ export interface SignalMeta {
description?: string;
properties?: Record<string, string>;
tags?: string[];
// array-local extra fields (present when type === 'array'):
elem?: 'number' | 'bool' | 'array';
sizing?: 'dynamic' | 'capped' | 'fixed';
capacity?: number;
}
// A single widget in an interface
@@ -68,11 +72,15 @@ export type PlotLayout =
// via ds 'local'.
export interface StateVar {
name: string;
type?: 'number' | 'bool' | 'string'; // default 'number'
type?: 'number' | 'bool' | 'string' | 'array'; // default 'number'
initial: string; // initial value, stored as a string
unit?: string;
low?: number;
high?: number;
// array-only (present when type === 'array'):
elem?: 'number' | 'bool' | 'array';
sizing?: 'dynamic' | 'capped' | 'fixed';
capacity?: number;
}
// ── Panel logic (Node-REDstyle flow graph) ──────────────────────────────────
@@ -107,13 +115,14 @@ export interface StateVar {
// action.write — evaluate `expr` and write the result to `target`
// ("ds:name" or a bare local var) (params: target, expr).
// action.delay — pause `ms` before continuing downstream (params: ms).
// action.accumulate — evaluate `expr` and append the value (with a timestamp)
// to the named in-memory data array (params: array, expr).
// action.export — download one or more named arrays as a CSV file, one
// column per array (params: columns = JSON list of
// {array,label}, align 'common'|'any'|'interpolate',
// action.export — download one or more named array local variables as a
// CSV file, one column per array (params: columns = JSON
// list of {array,label}, align 'common'|'any'|'interpolate',
// filename). Legacy single-array nodes use `array`.
// action.clear — empty the named array (params: array).
// action.array.* — push/set/remove/pop/clear on a named array local variable
// (params: array, plus index/expr where relevant). The old
// action.accumulate / action.clear kinds are migrated to
// action.array.push / action.array.clear on load.
// action.log — evaluate `expr` and log it to the browser console for
// debugging a flow (params: expr, label).
// action.widget — drive a panel widget by id (params: widget, op). `op` is
@@ -148,14 +157,17 @@ export type LogicNodeKind =
| 'flow.loop'
| 'action.write'
| 'action.delay'
| 'action.accumulate'
| 'action.export'
| 'action.clear'
| 'action.log'
| 'action.widget'
| 'action.dialog.info'
| 'action.dialog.error'
| 'action.dialog.setpoint'
| 'action.array.push'
| 'action.array.set'
| 'action.array.remove'
| 'action.array.pop'
| 'action.array.clear'
| 'action.config.apply'
| 'action.config.read'
| 'action.config.write'
+42 -2
View File
@@ -73,6 +73,11 @@ export function serializeInterface(iface: Interface): string {
if (sv.unit) attrs.push(`unit="${xmlEsc(sv.unit)}"`);
if (sv.low !== undefined) attrs.push(`low="${sv.low}"`);
if (sv.high !== undefined) attrs.push(`high="${sv.high}"`);
if (sv.type === 'array') {
if (sv.elem) attrs.push(`elem="${sv.elem}"`);
if (sv.sizing) attrs.push(`sizing="${sv.sizing}"`);
if (sv.capacity !== undefined) attrs.push(`capacity="${sv.capacity}"`);
}
lines.push(` <statevar ${attrs.join(' ')}/>`);
}
if (iface.logic && (iface.logic.nodes.length > 0 || iface.logic.wires.length > 0)) {
@@ -123,6 +128,31 @@ function parseLayout(el: Element): PlotLayout {
return { type: 'split', dir, ratio: isNaN(ratio) ? 0.5 : ratio, a, b };
}
// Rewrite a legacy array node kind to its current equivalent, mutating `params`
// in place. The old action.accumulate / action.clear kinds (and the legacy
// single-`array` form of action.export) predate the typed array local-variable
// nodes; old panels are migrated transparently on load so the editor only ever
// sees the current kinds.
function migrateLegacyArrayNode(
kind: string,
params: Record<string, string>,
setKind: (k: LogicGraph['nodes'][number]['kind']) => void,
): void {
if (kind === 'action.accumulate') {
setKind('action.array.push'); // same params: { array, expr }
} else if (kind === 'action.clear') {
setKind('action.array.clear'); // same params: { array }
} else if (kind === 'action.export') {
// Legacy single-array export had only an `array` param; fold it into the
// current JSON `columns` form so the editor never sees the legacy field.
const a = (params.array ?? '').trim();
if (a && !(params.columns ?? '').trim()) {
params.columns = JSON.stringify([{ array: a, label: '' }]);
}
delete params.array;
}
}
/** Parse a <logic> element into a LogicGraph (nodes + wires). */
function parseLogic(logicEl: Element): LogicGraph {
const nodes: LogicGraph['nodes'] = [];
@@ -155,9 +185,11 @@ function parseLogic(logicEl: Element): LogicGraph {
const value = child.getAttribute('value');
if (key !== null && value !== null) params[key] = value;
}
let kind = (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'];
migrateLegacyArrayNode(kind, params, (k) => { kind = k; });
nodes.push({
id,
kind: (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'],
kind,
x: parseFloat(el.getAttribute('x') ?? '0'),
y: parseFloat(el.getAttribute('y') ?? '0'),
params,
@@ -213,13 +245,21 @@ export function parseInterface(xml: string): Interface {
const low = el.getAttribute('low');
const high = el.getAttribute('high');
const unit = el.getAttribute('unit');
const elem = el.getAttribute('elem');
const sizing = el.getAttribute('sizing');
const capacityAttr = el.getAttribute('capacity');
const svType: StateVar['type'] =
type === 'bool' || type === 'string' || type === 'array' ? type : 'number';
statevars.push({
name,
type: type === 'bool' || type === 'string' ? type : 'number',
type: svType,
initial: el.getAttribute('initial') ?? '',
...(unit ? { unit } : {}),
...(low !== null ? { low: parseFloat(low) } : {}),
...(high !== null ? { high: parseFloat(high) } : {}),
...(elem ? { elem: elem as StateVar['elem'] } : {}),
...(sizing ? { sizing: sizing as StateVar['sizing'] } : {}),
...(capacityAttr !== null ? { capacity: Number(capacityAttr) } : {}),
});
}
+49 -5
View File
@@ -1,7 +1,7 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore } from '../lib/stores';
import type { Widget, SignalValue } from '../lib/types';
import { getSignalStore, getMetaStore } from '../lib/stores';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
@@ -14,13 +14,16 @@ interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function MultiLed({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsub = getSignalStore(sigRef).subscribe(setSv);
return unsub;
const uv = getSignalStore(sigRef).subscribe(setSv);
const um = getMetaStore(sigRef).subscribe(setMeta);
return () => { uv(); um(); };
}, [sigRef?.ds, sigRef?.name]);
const sourceMode = widget.options['sourceMode'] ?? 'bitset';
const bits = parseInt(widget.options['bits'] ?? '8', 10);
const labelsRaw = widget.options['labels'] ?? '';
const colorsTrueRaw = widget.options['colorsTrue'] ?? '';
@@ -32,8 +35,49 @@ export default function MultiLed({ widget, onContextMenu }: Props) {
const quality = sv.quality;
const isUncertain = quality === 'uncertain' || quality === 'unknown';
const rawV = sv.value;
// ── Array source mode ──────────────────────────────────────────────────────
// When sourceMode === 'array' (or the bound value is already an array), render
// one LED per element. LED count tracks the live array length.
// When meta.elem === 'bool', the on/off labels from labelArr are used as-is.
const isArrayMode = sourceMode === 'array' || Array.isArray(rawV);
if (isArrayMode) {
const arr: any[] = Array.isArray(rawV) ? rawV : [];
const isBoolElem = meta?.elem === 'bool';
return (
<div
class="multiled-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{arr.map((elem, i) => {
const on = isBoolElem ? Boolean(elem) : (Number(elem) !== 0 && elem !== false && elem !== null);
const trueColor = colorsTrueArr[i] ?? colorsTrueArr[0] ?? '#22c55e';
const falseColor = colorsFalseArr[i] ?? colorsFalseArr[0] ?? '#ef4444';
const color = on ? trueColor : falseColor;
// Label: per-element override, then generic index
const elemLabel = labelArr[i] ?? String(i);
return (
<div key={i} class="bit-item">
<div
class={`led-circle${isUncertain ? ' blink' : ''}`}
style={`background:${color};box-shadow:0 0 6px ${color}88;`}
/>
<div class="bit-label">{elemLabel}</div>
</div>
);
})}
{arr.length === 0 && (
<div class="bit-label" style="color:#6b7280;padding:4px"></div>
)}
</div>
);
}
// ── Bitset source mode (default) ───────────────────────────────────────────
const intValue = rawV === null || rawV === undefined ? 0
: typeof rawV === 'number' ? Math.floor(rawV) : parseInt(String(rawV), 10);
+23 -6
View File
@@ -406,7 +406,13 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } }
: undefined;
switch (plotType) {
// If any signal has delivered an array value (stored in waveforms[]),
// render all signals as waveform traces regardless of the configured plotType.
// This handles local array vars and EPICS waveform PVs transparently.
const hasWaveformData = waveforms.some(w => w.length > 0);
const effectivePlotType = hasWaveformData ? 'waveform' : plotType;
switch (effectivePlotType) {
case 'histogram': {
// Distribution over all buffered samples (ring-buffer bounded).
const { labels, counts } = buildHistogram(buffers.map(b => b.values));
@@ -664,14 +670,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
if (plotType === 'waveform') {
// Array-valued sample: keep only the latest waveform and redraw.
// Array-valued signal (EPICS waveform or local array var): always route
// through the waveform sample path. Nested (2-D) arrays are skipped —
// they fall through to the "unsupported" ECharts placeholder.
if (Array.isArray(sv.value)) {
waveforms[i] = sv.value.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
} else {
const flat = sv.value as any[];
// Skip 2-D (nested) arrays — not renderable as a simple waveform.
if (flat.length > 0 && Array.isArray(flat[0])) {
if (echart) echart.setOption(echartsOption(), { notMerge: true });
return;
}
waveforms[i] = flat.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
if (echart) echart.setOption(echartsOption(), { notMerge: true });
return;
}
if (plotType === 'waveform') {
// Scalar sample while in waveform mode: treat as a single-element waveform.
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
waveforms[i] = isNaN(v) ? [] : [v];
}
if (echart) echart.setOption(echartsOption(), { notMerge: true });
} else if (plotType === 'timeseries') {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
+91
View File
@@ -81,6 +81,80 @@ function TableRow({ sig, label, cols, fmt, unitOverride }: RowProps) {
);
}
interface ArrayBodyProps {
sig: SignalRef;
cols: Col[];
fmt: string;
unitOverride: string;
// For 2-D arrays: map column positions to inner-array indices.
// e.g. "0,2" means col[0] → elem[0], col[1] → elem[2]
colIndices?: number[];
}
// Array source mode body: subscribe to a single signal whose value is an array
// and render one row per element. For 2-D (elem:'array') arrays, each row is
// the outer index and configured colIndices map columns to inner positions.
// Returns an array of <tr> elements (no wrapper needed — inserted into <tbody>).
function ArrayTableBody({ sig, cols, fmt, unitOverride, colIndices }: ArrayBodyProps) {
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
const uv = getSignalStore(sig).subscribe(setSv);
const um = getMetaStore(sig).subscribe(setMeta);
return () => { uv(); um(); };
}, [sig.ds, sig.name]);
const unit = unitOverride === 'none' ? '' : (unitOverride || meta?.unit || '');
const time = sv.ts ? new Date(sv.ts).toLocaleTimeString() : '';
const quality = sv.quality;
const rawArr: any[] = Array.isArray(sv.value) ? sv.value : [];
const is2D = meta?.elem === 'array';
if (rawArr.length === 0) {
return <tr><td class="tw-empty" colSpan={cols.length}></td></tr>;
}
// Return an array of <tr> elements — JSX arrays are valid react-mode children.
return rawArr.map((elem, idx) => {
const cells = cols.map((c, ci) => {
switch (c) {
case 'name':
return <td key={c} class="tw-name">{idx}</td>;
case 'value': {
let display: string;
if (is2D && Array.isArray(elem)) {
// 2-D: show the inner element at the configured column index mapping.
const innerIdx = colIndices ? (colIndices[ci] ?? ci) : ci;
const inner = (elem as any[])[innerIdx];
display = inner === undefined ? '—'
: typeof inner === 'number' ? formatValue(inner, fmt) : String(inner);
} else if (typeof elem === 'number') {
display = formatValue(elem, fmt);
} else if (elem === null || elem === undefined) {
display = '—';
} else {
display = String(elem);
}
return <td key={c} class="tw-value">{display}</td>;
}
case 'unit':
return <td key={c} class="tw-unit">{unit}</td>;
case 'quality':
return (
<td key={c} class="tw-quality">
<span class="quality-dot" style={`background:${qualityColor(quality)};`} title={`Quality: ${quality}`} />
</td>
);
case 'time':
return <td key={c} class="tw-time">{time}</td>;
}
});
return <tr key={idx}>{cells}</tr>;
}) as any;
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// A tabular multi-signal readout: each bound signal is one row, with a
@@ -95,6 +169,14 @@ export default function TableWidget({ widget, onContextMenu }: Props) {
const fmt = o['format'] ?? '';
const unitOverride = o['unit'] ?? '';
const labels = (o['labels'] ?? '').split(',');
// sourceMode:'array' — use first bound signal as an array; each element = one row.
const sourceMode = o['sourceMode'] ?? '';
const isArrayMode = sourceMode === 'array';
// colIndices: for 2-D arrays, map column positions to inner-array positions.
// Stored as a comma-separated list of integers, e.g. "0,2,4".
const colIndices = o['colIndices']
? o['colIndices'].split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n))
: undefined;
return (
<div
@@ -113,6 +195,15 @@ export default function TableWidget({ widget, onContextMenu }: Props) {
<tbody>
{widget.signals.length === 0 ? (
<tr><td class="tw-empty" colSpan={cols.length}>No signals drop signals here</td></tr>
) : isArrayMode && widget.signals[0] ? (
// Array source mode: first signal's array value → one row per element.
<ArrayTableBody
sig={widget.signals[0]}
cols={cols}
fmt={fmt}
unitOverride={unitOverride}
colIndices={colIndices}
/>
) : (
widget.signals.map((s, i) => (
<TableRow