Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 519c1f2df4 | |||
| 3ddffc14d7 | |||
| e76865d132 | |||
| 5012511306 | |||
| 44c8f98e01 | |||
| 05b06d64a4 | |||
| cb4e81beb2 | |||
| 9d9e538a90 | |||
| 9d48292976 | |||
| 91661485ae | |||
| 603574f86f | |||
| b82e8852b3 | |||
| 8f50bc2498 | |||
| 062bb44dba | |||
| f776de378f | |||
| 5578bceea2 | |||
| 2f40a9d1a0 | |||
| 3b8c6540e1 | |||
| 774e28453b | |||
| e4e67ee0c2 | |||
| cf9da3df0a |
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
+362
-54
@@ -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 {
|
||||
func mustNum(v Value) float64 {
|
||||
f, err := asNum(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
func mustArr(v Value) []Value {
|
||||
a, err := asArr(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (n numNode) eval(R Resolver) Value { return n.v }
|
||||
func (n sigNode) eval(R Resolver) Value { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) Value { return R("local", n.name) }
|
||||
func (n arrNode) eval(R Resolver) Value {
|
||||
out := make([]Value, len(n.items))
|
||||
for i, it := range n.items {
|
||||
out[i] = it.eval(R)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (n indexNode) eval(R Resolver) Value {
|
||||
arr := mustArr(n.a.eval(R))
|
||||
k, err := idxResolve(mustNum(n.i.eval(R)), len(arr))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return arr[k]
|
||||
}
|
||||
func (n unNode) eval(R Resolver) Value {
|
||||
if n.op == "-" {
|
||||
return -n.a.eval(R)
|
||||
return -mustNum(n.a.eval(R))
|
||||
}
|
||||
if n.a.eval(R) == 0 {
|
||||
return 1
|
||||
if mustNum(n.a.eval(R)) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
return 0
|
||||
return 0.0
|
||||
}
|
||||
func (n ternNode) eval(R Resolver) float64 {
|
||||
if n.c.eval(R) != 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)
|
||||
}
|
||||
func (n binNode) eval(R Resolver) float64 {
|
||||
a, b := n.a.eval(R), n.b.eval(R)
|
||||
}
|
||||
nums := make([]Value, len(args))
|
||||
copy(nums, args)
|
||||
return reduceMinMax(n.fn, nums)
|
||||
}
|
||||
if af, ok := arrFuncs[n.fn]; ok {
|
||||
return af(args)
|
||||
}
|
||||
if sf, ok := scalarFuncs[n.fn]; ok {
|
||||
nums := make([]float64, len(args))
|
||||
for i, a := range args {
|
||||
nums[i] = mustNum(a)
|
||||
}
|
||||
return sf(nums)
|
||||
}
|
||||
panic(fmt.Errorf("unknown function %q", n.fn))
|
||||
}
|
||||
func (n binNode) eval(R Resolver) Value {
|
||||
a, b := mustNum(n.a.eval(R)), mustNum(n.b.eval(R))
|
||||
switch n.op {
|
||||
case "+":
|
||||
return a + b
|
||||
@@ -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)
|
||||
}
|
||||
func maxSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(-1)
|
||||
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))
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Max(m, x)
|
||||
if len(a) > 2 {
|
||||
e = clampIdx(int(mustNum(a[2])), len(r))
|
||||
}
|
||||
return m
|
||||
if s > e {
|
||||
s = e
|
||||
}
|
||||
func sign(x float64) int {
|
||||
out := make([]Value, 0, e-s)
|
||||
out = append(out, r[s:e]...)
|
||||
return out
|
||||
},
|
||||
"concat": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), mustArr(a[1])...) },
|
||||
"reverse": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...); reverse(r); return r },
|
||||
"sort": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
sortNum(r)
|
||||
return r
|
||||
},
|
||||
"scale": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
k := mustNum(a[1])
|
||||
out := make([]Value, len(r))
|
||||
for i, x := range r {
|
||||
out[i] = mustNum(x) * k
|
||||
}
|
||||
return out
|
||||
},
|
||||
"add": func(a []Value) Value {
|
||||
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x + y })
|
||||
},
|
||||
"sub": func(a []Value) Value {
|
||||
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x - y })
|
||||
},
|
||||
"push": func(a []Value) Value {
|
||||
return append(append([]Value{}, mustArr(a[0])...), a[1])
|
||||
},
|
||||
"set": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
k, err := idxResolve(mustNum(a[1]), len(r))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
r[k] = a[2]
|
||||
return r
|
||||
},
|
||||
"insert": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
k := int(mustNum(a[1]))
|
||||
if k < 0 {
|
||||
k = 0
|
||||
}
|
||||
if k > len(r) {
|
||||
k = len(r)
|
||||
}
|
||||
r = append(r, nil)
|
||||
copy(r[k+1:], r[k:])
|
||||
r[k] = a[2]
|
||||
return r
|
||||
},
|
||||
"remove": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
k, err := idxResolve(mustNum(a[1]), len(r))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return append(r[:k], r[k+1:]...)
|
||||
},
|
||||
"pop": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
if len(r) == 0 {
|
||||
return []Value{}
|
||||
}
|
||||
return append([]Value{}, r[:len(r)-1]...)
|
||||
},
|
||||
"shift": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
if len(r) == 0 {
|
||||
return []Value{}
|
||||
}
|
||||
return append([]Value{}, r[1:]...)
|
||||
},
|
||||
"indexOf": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
for i, x := range r {
|
||||
if valEq(x, a[1]) {
|
||||
return float64(i)
|
||||
}
|
||||
}
|
||||
return -1.0
|
||||
},
|
||||
"contains": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
for _, x := range r {
|
||||
if valEq(x, a[1]) {
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
},
|
||||
"fill": func(a []Value) Value {
|
||||
n := int(mustNum(a[0]))
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
out := make([]Value, n)
|
||||
for i := range out {
|
||||
out[i] = a[1]
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
|
||||
func signOf(x float64) int {
|
||||
switch {
|
||||
case x > 0:
|
||||
return 1
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package controllogic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStoreRoundTripStateVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewStore(dir) // NewStore takes the storage DIRECTORY
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := Graph{
|
||||
ID: "g1",
|
||||
Name: "with-vars",
|
||||
StateVars: []StateVar{
|
||||
{Name: "count", Type: "number", Initial: "0"},
|
||||
{Name: "buf", Type: "array", Initial: "[1,2]", Elem: "number", Sizing: "capped", Capacity: 5},
|
||||
},
|
||||
}
|
||||
if err := st.Save(g); err != nil { // Save returns only error
|
||||
t.Fatal(err)
|
||||
}
|
||||
st2, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st2.Get("g1") // Get returns (Graph, error); ErrNotFound if absent
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.StateVars) != 2 || got.StateVars[1].Name != "buf" || got.StateVars[1].Capacity != 5 {
|
||||
t.Fatalf("statevars not round-tripped: %#v", got.StateVars)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Value model for control-logic locals/expressions. A Value is either a scalar
|
||||
// (float64; booleans are 1/0) or an array ([]Value). This is the Go port of
|
||||
// web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from
|
||||
// web/src/lib/expr.ts. Pure, dependency-free.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Value is a scalar (float64) or an array ([]Value).
|
||||
type Value = any
|
||||
|
||||
// ARRAY_MAX is the global hard cap on dynamic array length (drops oldest).
|
||||
const ARRAY_MAX = 1_000_000
|
||||
|
||||
// StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar.
|
||||
type StateVar struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"` // number|bool|string|array (default number)
|
||||
Initial string `json:"initial"` // initial value, stored as a string
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Low float64 `json:"low,omitempty"`
|
||||
High float64 `json:"high,omitempty"`
|
||||
Elem string `json:"elem,omitempty"` // array-only: number|bool|array
|
||||
Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed
|
||||
Capacity int `json:"capacity,omitempty"` // array-only
|
||||
}
|
||||
|
||||
func asNum(v Value) (float64, error) {
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("expected a number, got an array")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func asArr(v Value) ([]Value, error) {
|
||||
a, ok := v.([]Value)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected an array, got a number")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// idxResolve resolves a possibly-negative index against length; range-checked.
|
||||
func idxResolve(i float64, length int) (int, error) {
|
||||
k := int(i) // truncates toward zero, matching Math.trunc
|
||||
if k < 0 {
|
||||
k = length + k
|
||||
}
|
||||
if k < 0 || k >= length {
|
||||
return 0, fmt.Errorf("index %v out of range (len %d)", i, length)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64,
|
||||
// bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays).
|
||||
func normalizeValue(v any) Value {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case float32:
|
||||
return float64(t)
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case bool:
|
||||
if t {
|
||||
return 1.0
|
||||
}
|
||||
return 0.0
|
||||
case []interface{}:
|
||||
out := make([]Value, len(t))
|
||||
for i, e := range t {
|
||||
out[i] = normalizeValue(e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
func zeroFill(n int) []Value {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
out := make([]Value, n)
|
||||
for i := range out {
|
||||
out[i] = 0.0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseInitialArray returns the starting contents of an array local. Mirrors
|
||||
// arraypolicy.ts parseInitialArray.
|
||||
func parseInitialArray(sv StateVar) []Value {
|
||||
cap := sv.Capacity
|
||||
raw := strings.TrimSpace(sv.Initial)
|
||||
var parsed []Value
|
||||
if raw != "" {
|
||||
var j interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &j); err == nil {
|
||||
if arr, ok := j.([]interface{}); ok {
|
||||
parsed = normalizeValue(arr).([]Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sv.Sizing == "fixed" {
|
||||
if parsed == nil {
|
||||
return zeroFill(cap)
|
||||
}
|
||||
out := make([]Value, 0, cap)
|
||||
for i := 0; i < len(parsed) && i < cap; i++ {
|
||||
out = append(out, parsed[i])
|
||||
}
|
||||
for len(out) < cap {
|
||||
out = append(out, 0.0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if parsed == nil {
|
||||
return []Value{}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts.
|
||||
func applySizing(arr []Value, sv StateVar) []Value {
|
||||
cap := sv.Capacity
|
||||
switch sv.Sizing {
|
||||
case "fixed":
|
||||
out := make([]Value, 0, cap)
|
||||
for i := 0; i < len(arr) && i < cap; i++ {
|
||||
out = append(out, arr[i])
|
||||
}
|
||||
for len(out) < cap {
|
||||
out = append(out, 0.0)
|
||||
}
|
||||
return out
|
||||
case "capped":
|
||||
if len(arr) > cap {
|
||||
return arr[len(arr)-cap:]
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
if len(arr) > ARRAY_MAX {
|
||||
return arr[len(arr)-ARRAY_MAX:]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAsNumAsArr(t *testing.T) {
|
||||
if n, err := asNum(3.0); err != nil || n != 3 {
|
||||
t.Fatalf("asNum(3)=%v,%v", n, err)
|
||||
}
|
||||
if _, err := asNum([]Value{1.0}); err == nil {
|
||||
t.Fatal("asNum(array) should error")
|
||||
}
|
||||
if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 {
|
||||
t.Fatalf("asArr=%v,%v", a, err)
|
||||
}
|
||||
if _, err := asArr(3.0); err == nil {
|
||||
t.Fatal("asArr(number) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdxResolve(t *testing.T) {
|
||||
if k, err := idxResolve(-1, 3); err != nil || k != 2 {
|
||||
t.Fatalf("idx(-1,3)=%v,%v", k, err)
|
||||
}
|
||||
if _, err := idxResolve(3, 3); err == nil {
|
||||
t.Fatal("idx(3,3) should be out of range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeValue(t *testing.T) {
|
||||
got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}})
|
||||
want := []Value{1.0, 1.0, []Value{2.0}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("normalize=%#v want %#v", got, want)
|
||||
}
|
||||
if normalizeValue(5) != Value(5.0) {
|
||||
t.Fatalf("normalize(int) = %#v", normalizeValue(5))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInitialArray(t *testing.T) {
|
||||
fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"})
|
||||
if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) {
|
||||
t.Fatalf("fixed init = %#v", fixed)
|
||||
}
|
||||
dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"})
|
||||
if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) {
|
||||
t.Fatalf("dynamic init = %#v", dyn)
|
||||
}
|
||||
empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""})
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("empty init = %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySizing(t *testing.T) {
|
||||
capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2})
|
||||
if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) {
|
||||
t.Fatalf("capped = %#v", capped)
|
||||
}
|
||||
fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3})
|
||||
if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) {
|
||||
t.Fatalf("fixed = %#v", fixed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Modbus function codes.
|
||||
const (
|
||||
fcReadCoils = 0x01
|
||||
fcReadDiscrete = 0x02
|
||||
fcReadHolding = 0x03
|
||||
fcReadInput = 0x04
|
||||
fcWriteSingleCoil = 0x05
|
||||
fcWriteSingleReg = 0x06
|
||||
fcWriteMultipleRegs = 0x10
|
||||
)
|
||||
|
||||
// client is a minimal Modbus TCP master for a single device address. Requests
|
||||
// are serialised by mu (Modbus TCP is request/response and the connection is
|
||||
// shared by all of a device's polled registers). The connection is dialled
|
||||
// lazily and dropped on any I/O error so the next request reconnects.
|
||||
type client struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
txID uint16
|
||||
}
|
||||
|
||||
func newClient(addr string, timeout time.Duration) *client {
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
return &client{addr: addr, timeout: timeout}
|
||||
}
|
||||
|
||||
func (c *client) close() {
|
||||
c.mu.Lock()
|
||||
c.closeLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *client) closeLocked() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// request sends a PDU to unitID and returns the response PDU (function code +
|
||||
// data). It dials on demand and tears the connection down on error.
|
||||
func (c *client) request(unitID byte, pdu []byte) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
conn, err := net.DialTimeout("tcp", c.addr, c.timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("modbus: dial %s: %w", c.addr, err)
|
||||
}
|
||||
c.conn = conn
|
||||
}
|
||||
|
||||
resp, err := c.transact(unitID, pdu)
|
||||
if err != nil {
|
||||
c.closeLocked()
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// transact performs one MBAP-framed exchange. Caller holds mu.
|
||||
func (c *client) transact(unitID byte, pdu []byte) ([]byte, error) {
|
||||
c.txID++
|
||||
tx := c.txID
|
||||
|
||||
frame := make([]byte, 7+len(pdu))
|
||||
binary.BigEndian.PutUint16(frame[0:], tx) // transaction id
|
||||
binary.BigEndian.PutUint16(frame[2:], 0) // protocol id (0 = Modbus)
|
||||
binary.BigEndian.PutUint16(frame[4:], uint16(1+len(pdu))) // length: unit id + PDU
|
||||
frame[6] = unitID
|
||||
copy(frame[7:], pdu)
|
||||
|
||||
_ = c.conn.SetDeadline(time.Now().Add(c.timeout))
|
||||
if _, err := c.conn.Write(frame); err != nil {
|
||||
return nil, fmt.Errorf("modbus: write: %w", err)
|
||||
}
|
||||
|
||||
head := make([]byte, 7)
|
||||
if _, err := io.ReadFull(c.conn, head); err != nil {
|
||||
return nil, fmt.Errorf("modbus: read header: %w", err)
|
||||
}
|
||||
if binary.BigEndian.Uint16(head[0:]) != tx {
|
||||
return nil, fmt.Errorf("modbus: transaction id mismatch")
|
||||
}
|
||||
length := binary.BigEndian.Uint16(head[4:])
|
||||
if length < 2 { // unit id + at least a function code
|
||||
return nil, fmt.Errorf("modbus: short frame length %d", length)
|
||||
}
|
||||
body := make([]byte, length-1) // header already consumed the unit id
|
||||
if _, err := io.ReadFull(c.conn, body); err != nil {
|
||||
return nil, fmt.Errorf("modbus: read body: %w", err)
|
||||
}
|
||||
|
||||
fc := body[0]
|
||||
if fc&0x80 != 0 { // exception response
|
||||
var ex byte
|
||||
if len(body) >= 2 {
|
||||
ex = body[1]
|
||||
}
|
||||
return nil, fmt.Errorf("modbus: exception 0x%02x (%s)", ex, exceptionText(ex))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// readRegisters reads `quantity` 16-bit registers via fc (holding or input).
|
||||
func (c *client) readRegisters(unitID, fc byte, addr, quantity uint16) ([]uint16, error) {
|
||||
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
|
||||
resp, err := c.request(unitID, pdu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) < 2 {
|
||||
return nil, fmt.Errorf("modbus: short register response")
|
||||
}
|
||||
byteCount := int(resp[1])
|
||||
if byteCount != int(quantity)*2 || len(resp) < 2+byteCount {
|
||||
return nil, fmt.Errorf("modbus: register byte count %d (want %d)", byteCount, quantity*2)
|
||||
}
|
||||
regs := make([]uint16, quantity)
|
||||
for i := range regs {
|
||||
regs[i] = binary.BigEndian.Uint16(resp[2+i*2:])
|
||||
}
|
||||
return regs, nil
|
||||
}
|
||||
|
||||
// readBits reads `quantity` bits via fc (coils or discrete inputs).
|
||||
func (c *client) readBits(unitID, fc byte, addr, quantity uint16) ([]bool, error) {
|
||||
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
|
||||
resp, err := c.request(unitID, pdu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) < 2 {
|
||||
return nil, fmt.Errorf("modbus: short bit response")
|
||||
}
|
||||
byteCount := int(resp[1])
|
||||
if len(resp) < 2+byteCount {
|
||||
return nil, fmt.Errorf("modbus: bit byte count %d exceeds frame", byteCount)
|
||||
}
|
||||
bits := make([]bool, quantity)
|
||||
for i := range bits {
|
||||
idx := 2 + i/8
|
||||
if idx >= len(resp) {
|
||||
break
|
||||
}
|
||||
bits[i] = resp[idx]&(1<<(uint(i)%8)) != 0
|
||||
}
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
func (c *client) writeSingleRegister(unitID byte, addr, value uint16) error {
|
||||
pdu := []byte{fcWriteSingleReg, byte(addr >> 8), byte(addr), byte(value >> 8), byte(value)}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) writeMultipleRegisters(unitID byte, addr uint16, values []uint16) error {
|
||||
pdu := make([]byte, 6+len(values)*2)
|
||||
pdu[0] = fcWriteMultipleRegs
|
||||
binary.BigEndian.PutUint16(pdu[1:], addr)
|
||||
binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
|
||||
pdu[5] = byte(len(values) * 2)
|
||||
for i, v := range values {
|
||||
binary.BigEndian.PutUint16(pdu[6+i*2:], v)
|
||||
}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) writeSingleCoil(unitID byte, addr uint16, on bool) error {
|
||||
var v uint16
|
||||
if on {
|
||||
v = 0xFF00
|
||||
}
|
||||
pdu := []byte{fcWriteSingleCoil, byte(addr >> 8), byte(addr), byte(v >> 8), byte(v)}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func exceptionText(code byte) string {
|
||||
switch code {
|
||||
case 0x01:
|
||||
return "illegal function"
|
||||
case 0x02:
|
||||
return "illegal data address"
|
||||
case 0x03:
|
||||
return "illegal data value"
|
||||
case 0x04:
|
||||
return "server device failure"
|
||||
case 0x05:
|
||||
return "acknowledge"
|
||||
case 0x06:
|
||||
return "server device busy"
|
||||
case 0x0B:
|
||||
return "gateway target failed to respond"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// Config is the [datasource.modbus] section. Devices share no connection state;
|
||||
// each is polled independently over its own TCP socket.
|
||||
type Config struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// PollIntervalMs is the default polling period for every register that does
|
||||
// not override it. Zero → 1000 ms.
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Devices []Device `toml:"devices"`
|
||||
}
|
||||
|
||||
// Device is one Modbus TCP slave. Address is "host:port" (default port 502 is
|
||||
// appended if absent). UnitID is the Modbus unit/slave identifier (0–255).
|
||||
type Device struct {
|
||||
Name string `toml:"name"`
|
||||
Address string `toml:"address"`
|
||||
UnitID uint8 `toml:"unit_id"`
|
||||
TimeoutMs int `toml:"timeout_ms"`
|
||||
Registers []Register `toml:"registers"`
|
||||
}
|
||||
|
||||
// Register describes one logical signal mapped onto a Modbus address.
|
||||
//
|
||||
// - Kind selects the address space / function code:
|
||||
// "holding" (FC03/06/10), "input" (FC04, read-only),
|
||||
// "coil" (FC01/05, bool), "discrete" (FC02, read-only bool).
|
||||
// - Encoding selects how holding/input words are decoded:
|
||||
// "uint16", "int16", "uint32", "int32", "float32", "float64".
|
||||
// Ignored for coil/discrete (always bool).
|
||||
// - WordOrder is "big" (default, high word first) or "little" for the
|
||||
// multi-word encodings.
|
||||
// - Scale/Offset transform the raw numeric value: value*Scale + Offset.
|
||||
// Scale 0 is treated as 1.
|
||||
type Register struct {
|
||||
Name string `toml:"name"`
|
||||
Kind string `toml:"kind"`
|
||||
Address uint16 `toml:"address"`
|
||||
Encoding string `toml:"encoding"`
|
||||
WordOrder string `toml:"word_order"`
|
||||
Unit string `toml:"unit"`
|
||||
Scale float64 `toml:"scale"`
|
||||
Offset float64 `toml:"offset"`
|
||||
Min float64 `toml:"min"`
|
||||
Max float64 `toml:"max"`
|
||||
Writable bool `toml:"writable"`
|
||||
Description string `toml:"description"`
|
||||
}
|
||||
|
||||
// register kinds.
|
||||
const (
|
||||
kindHolding = "holding"
|
||||
kindInput = "input"
|
||||
kindCoil = "coil"
|
||||
kindDiscrete = "discrete"
|
||||
)
|
||||
|
||||
// isBool reports whether the register addresses a single-bit space.
|
||||
func (r Register) isBool() bool {
|
||||
return r.kind() == kindCoil || r.kind() == kindDiscrete
|
||||
}
|
||||
|
||||
func (r Register) kind() string {
|
||||
if r.Kind == "" {
|
||||
return kindHolding
|
||||
}
|
||||
return strings.ToLower(r.Kind)
|
||||
}
|
||||
|
||||
func (r Register) encoding() string {
|
||||
if r.Encoding == "" {
|
||||
return "uint16"
|
||||
}
|
||||
return strings.ToLower(r.Encoding)
|
||||
}
|
||||
|
||||
func (r Register) littleWordOrder() bool {
|
||||
return strings.ToLower(r.WordOrder) == "little"
|
||||
}
|
||||
|
||||
func (r Register) scale() float64 {
|
||||
if r.Scale == 0 {
|
||||
return 1
|
||||
}
|
||||
return r.Scale
|
||||
}
|
||||
|
||||
// wordCount returns the number of 16-bit registers the encoding occupies.
|
||||
func (r Register) wordCount() (int, error) {
|
||||
switch r.encoding() {
|
||||
case "uint16", "int16":
|
||||
return 1, nil
|
||||
case "uint32", "int32", "float32":
|
||||
return 2, nil
|
||||
case "float64":
|
||||
return 4, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// dataType maps the register to a datasource value type.
|
||||
func (r Register) dataType() datasource.DataType {
|
||||
if r.isBool() {
|
||||
return datasource.TypeBool
|
||||
}
|
||||
// Integer encodings with no fractional scaling stay integers; anything
|
||||
// scaled, offset, or float-encoded becomes a float64.
|
||||
switch r.encoding() {
|
||||
case "float32", "float64":
|
||||
return datasource.TypeFloat64
|
||||
default:
|
||||
if r.scale() == 1 && r.Offset == 0 {
|
||||
return datasource.TypeInt64
|
||||
}
|
||||
return datasource.TypeFloat64
|
||||
}
|
||||
}
|
||||
|
||||
// writable reports whether writes are permitted. Input registers and discrete
|
||||
// inputs are read-only regardless of the Writable flag.
|
||||
func (r Register) writable() bool {
|
||||
switch r.kind() {
|
||||
case kindInput, kindDiscrete:
|
||||
return false
|
||||
default:
|
||||
return r.Writable
|
||||
}
|
||||
}
|
||||
|
||||
// metadata builds the datasource.Metadata for this register under signal name
|
||||
// "device:register".
|
||||
func (r Register) metadata(device string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: device + ":" + r.Name,
|
||||
Type: r.dataType(),
|
||||
Unit: r.Unit,
|
||||
Description: r.Description,
|
||||
DisplayLow: r.Min,
|
||||
DisplayHigh: r.Max,
|
||||
DriveLow: r.Min,
|
||||
DriveHigh: r.Max,
|
||||
Writable: r.writable(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// orderWords returns the registers in big-word-first order, reversing them when
|
||||
// the register declares little word order. The slice is copied so the caller's
|
||||
// data is left untouched.
|
||||
func (r Register) orderWords(words []uint16) []uint16 {
|
||||
if !r.littleWordOrder() {
|
||||
return words
|
||||
}
|
||||
out := make([]uint16, len(words))
|
||||
for i, w := range words {
|
||||
out[len(words)-1-i] = w
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decode converts the raw registers into the register's numeric value and
|
||||
// applies scale/offset. The result type is int64 or float64 per dataType.
|
||||
func (r Register) decode(words []uint16) (any, error) {
|
||||
w := r.orderWords(words)
|
||||
var raw float64
|
||||
var rawInt int64
|
||||
switch r.encoding() {
|
||||
case "uint16":
|
||||
rawInt = int64(w[0])
|
||||
raw = float64(w[0])
|
||||
case "int16":
|
||||
rawInt = int64(int16(w[0]))
|
||||
raw = float64(int16(w[0]))
|
||||
case "uint32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
rawInt = int64(u)
|
||||
raw = float64(u)
|
||||
case "int32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
rawInt = int64(int32(u))
|
||||
raw = float64(int32(u))
|
||||
case "float32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
raw = float64(math.Float32frombits(u))
|
||||
case "float64":
|
||||
u := uint64(w[0])<<48 | uint64(w[1])<<32 | uint64(w[2])<<16 | uint64(w[3])
|
||||
raw = math.Float64frombits(u)
|
||||
default:
|
||||
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
|
||||
// Integer fast-path: no scaling/offset, integer encoding.
|
||||
if r.scale() == 1 && r.Offset == 0 {
|
||||
switch r.encoding() {
|
||||
case "uint16", "int16", "uint32", "int32":
|
||||
return rawInt, nil
|
||||
}
|
||||
}
|
||||
return raw*r.scale() + r.Offset, nil
|
||||
}
|
||||
|
||||
// encode converts a value destined for a Write back into raw registers,
|
||||
// inverting scale/offset. Only the holding-register encodings are writable.
|
||||
func (r Register) encode(value float64) ([]uint16, error) {
|
||||
v := (value - r.Offset) / r.scale()
|
||||
var words []uint16
|
||||
switch r.encoding() {
|
||||
case "uint16":
|
||||
words = []uint16{uint16(int64(math.Round(v)))}
|
||||
case "int16":
|
||||
words = []uint16{uint16(int16(int64(math.Round(v))))}
|
||||
case "uint32":
|
||||
u := uint32(int64(math.Round(v)))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "int32":
|
||||
u := uint32(int32(int64(math.Round(v))))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "float32":
|
||||
u := math.Float32bits(float32(v))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "float64":
|
||||
u := math.Float64bits(v)
|
||||
words = []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
|
||||
default:
|
||||
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
return r.orderWords(words), nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
func TestDecodeEncodings(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
reg Register
|
||||
words []uint16
|
||||
want any
|
||||
}{
|
||||
{"uint16", Register{Encoding: "uint16"}, []uint16{42}, int64(42)},
|
||||
{"int16 neg", Register{Encoding: "int16"}, []uint16{0xFFFF}, int64(-1)},
|
||||
{"uint32", Register{Encoding: "uint32"}, []uint16{0x0001, 0x0000}, int64(65536)},
|
||||
{"int32 neg", Register{Encoding: "int32"}, []uint16{0xFFFF, 0xFFFF}, int64(-1)},
|
||||
{"scaled", Register{Encoding: "int16", Scale: 0.1}, []uint16{235}, 23.5},
|
||||
{"float32", Register{Encoding: "float32"}, f32Words(3.5), 3.5},
|
||||
{"float64", Register{Encoding: "float64"}, f64Words(2.25), 2.25},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := tc.reg.decode(tc.words)
|
||||
if err != nil {
|
||||
t.Errorf("%s: decode error %v", tc.name, err)
|
||||
continue
|
||||
}
|
||||
switch w := tc.want.(type) {
|
||||
case int64:
|
||||
if got != w {
|
||||
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
|
||||
}
|
||||
case float64:
|
||||
gf, ok := got.(float64)
|
||||
if !ok || math.Abs(gf-w) > 1e-6 {
|
||||
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordOrder(t *testing.T) {
|
||||
big := Register{Encoding: "uint32", WordOrder: "big"}
|
||||
little := Register{Encoding: "uint32", WordOrder: "little"}
|
||||
words := []uint16{0x0001, 0x0002} // big: 0x00010002, little reverses to 0x00020001
|
||||
gb, _ := big.decode(words)
|
||||
gl, _ := little.decode(words)
|
||||
if gb != int64(0x00010002) {
|
||||
t.Errorf("big = %v, want %d", gb, 0x00010002)
|
||||
}
|
||||
if gl != int64(0x00020001) {
|
||||
t.Errorf("little = %v, want %d", gl, 0x00020001)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRoundTrip(t *testing.T) {
|
||||
for _, enc := range []string{"uint16", "int16", "uint32", "int32", "float32", "float64"} {
|
||||
reg := Register{Encoding: enc}
|
||||
words, err := reg.encode(123)
|
||||
if err != nil {
|
||||
t.Fatalf("%s encode: %v", enc, err)
|
||||
}
|
||||
got, err := reg.decode(words)
|
||||
if err != nil {
|
||||
t.Fatalf("%s decode: %v", enc, err)
|
||||
}
|
||||
var f float64
|
||||
switch v := got.(type) {
|
||||
case int64:
|
||||
f = float64(v)
|
||||
case float64:
|
||||
f = v
|
||||
}
|
||||
if math.Abs(f-123) > 1e-3 {
|
||||
t.Errorf("%s round-trip = %v, want 123", enc, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTypeAndWritable(t *testing.T) {
|
||||
if got := (Register{Kind: "coil"}).dataType(); got != datasource.TypeBool {
|
||||
t.Errorf("coil type = %v, want bool", got)
|
||||
}
|
||||
if got := (Register{Encoding: "float32"}).dataType(); got != datasource.TypeFloat64 {
|
||||
t.Errorf("float type = %v, want float64", got)
|
||||
}
|
||||
if got := (Register{Encoding: "uint16", Scale: 0.5}).dataType(); got != datasource.TypeFloat64 {
|
||||
t.Errorf("scaled int type = %v, want float64", got)
|
||||
}
|
||||
if got := (Register{Encoding: "uint16"}).dataType(); got != datasource.TypeInt64 {
|
||||
t.Errorf("plain int type = %v, want int64", got)
|
||||
}
|
||||
if (Register{Kind: "input", Writable: true}).writable() {
|
||||
t.Error("input register must be read-only")
|
||||
}
|
||||
if (Register{Kind: "discrete", Writable: true}).writable() {
|
||||
t.Error("discrete input must be read-only")
|
||||
}
|
||||
if !(Register{Kind: "holding", Writable: true}).writable() {
|
||||
t.Error("writable holding should be writable")
|
||||
}
|
||||
}
|
||||
|
||||
func f32Words(v float32) []uint16 {
|
||||
u := math.Float32bits(v)
|
||||
return []uint16{uint16(u >> 16), uint16(u)}
|
||||
}
|
||||
|
||||
func f64Words(v float64) []uint16 {
|
||||
u := math.Float64bits(v)
|
||||
return []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package modbus implements a Modbus TCP data source. Each configured device is
|
||||
// polled over its own TCP connection; registers are exposed as signals named
|
||||
// "device:register". Reads use the holding/input/coil/discrete function codes;
|
||||
// writable holding registers and coils accept Write.
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const defaultPollInterval = time.Second
|
||||
|
||||
// deviceClient bundles a parsed device with its wire client and register lookup.
|
||||
type deviceClient struct {
|
||||
dev Device
|
||||
cli *client
|
||||
byName map[string]Register
|
||||
}
|
||||
|
||||
// Modbus is a datasource.DataSource backed by one or more Modbus TCP devices.
|
||||
type Modbus struct {
|
||||
pollInterval time.Duration
|
||||
devices map[string]*deviceClient // device name → client
|
||||
signals map[string]signalRef // "device:register" → ref
|
||||
}
|
||||
|
||||
type signalRef struct {
|
||||
device string
|
||||
reg Register
|
||||
}
|
||||
|
||||
// New builds a Modbus source from config. It does not dial; connections are
|
||||
// established lazily on first poll/write.
|
||||
func New(cfg Config) (*Modbus, error) {
|
||||
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
|
||||
if poll <= 0 {
|
||||
poll = defaultPollInterval
|
||||
}
|
||||
m := &Modbus{
|
||||
pollInterval: poll,
|
||||
devices: make(map[string]*deviceClient),
|
||||
signals: make(map[string]signalRef),
|
||||
}
|
||||
for _, dev := range cfg.Devices {
|
||||
if dev.Name == "" || dev.Address == "" {
|
||||
return nil, fmt.Errorf("modbus: device needs name and address")
|
||||
}
|
||||
if _, dup := m.devices[dev.Name]; dup {
|
||||
return nil, fmt.Errorf("modbus: duplicate device %q", dev.Name)
|
||||
}
|
||||
addr := dev.Address
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr += ":502"
|
||||
}
|
||||
dc := &deviceClient{
|
||||
dev: dev,
|
||||
cli: newClient(addr, time.Duration(dev.TimeoutMs)*time.Millisecond),
|
||||
byName: make(map[string]Register),
|
||||
}
|
||||
for _, reg := range dev.Registers {
|
||||
if reg.Name == "" {
|
||||
return nil, fmt.Errorf("modbus: device %q has a register with no name", dev.Name)
|
||||
}
|
||||
if _, err := reg.wordCount(); !reg.isBool() && err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, dup := dc.byName[reg.Name]; dup {
|
||||
return nil, fmt.Errorf("modbus: device %q duplicate register %q", dev.Name, reg.Name)
|
||||
}
|
||||
dc.byName[reg.Name] = reg
|
||||
m.signals[dev.Name+":"+reg.Name] = signalRef{device: dev.Name, reg: reg}
|
||||
}
|
||||
m.devices[dev.Name] = dc
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (m *Modbus) Name() string { return "modbus" }
|
||||
|
||||
// Connect is a no-op; TCP connections are dialled lazily per device.
|
||||
func (m *Modbus) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every configured register.
|
||||
func (m *Modbus) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
out := make([]datasource.Metadata, 0, len(m.signals))
|
||||
for _, ref := range m.signals {
|
||||
out = append(out, ref.reg.metadata(ref.device))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for one signal.
|
||||
func (m *Modbus) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return ref.reg.metadata(ref.device), nil
|
||||
}
|
||||
|
||||
// readSignal performs one synchronous read of a register's current value.
|
||||
func (m *Modbus) readSignal(ref signalRef) (datasource.Value, error) {
|
||||
dc := m.devices[ref.device]
|
||||
reg := ref.reg
|
||||
now := time.Now()
|
||||
|
||||
if reg.isBool() {
|
||||
fc := byte(fcReadCoils)
|
||||
if reg.kind() == kindDiscrete {
|
||||
fc = fcReadDiscrete
|
||||
}
|
||||
bits, err := dc.cli.readBits(dc.dev.UnitID, fc, reg.Address, 1)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: now, Data: bits[0], Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
count, err := reg.wordCount()
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
fc := byte(fcReadHolding)
|
||||
if reg.kind() == kindInput {
|
||||
fc = fcReadInput
|
||||
}
|
||||
words, err := dc.cli.readRegisters(dc.dev.UnitID, fc, reg.Address, uint16(count))
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
data, err := reg.decode(words)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: now, Data: data, Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
// Subscribe polls the register at the configured interval and pushes values
|
||||
// into ch. On a read error a QualityBad value is emitted and polling continues.
|
||||
func (m *Modbus) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(m.pollInterval)
|
||||
defer ticker.Stop()
|
||||
emit := func() {
|
||||
v, err := m.readSignal(ref)
|
||||
if err != nil {
|
||||
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
emit() // first reading without waiting a full interval
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
emit()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// Write sets a writable holding register or coil.
|
||||
func (m *Modbus) Write(_ context.Context, signal string, value any) error {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
reg := ref.reg
|
||||
if !reg.writable() {
|
||||
return datasource.ErrNotWritable
|
||||
}
|
||||
dc := m.devices[ref.device]
|
||||
|
||||
if reg.isBool() {
|
||||
on, err := toBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dc.cli.writeSingleCoil(dc.dev.UnitID, reg.Address, on)
|
||||
}
|
||||
|
||||
f, err := toFloat(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
words, err := reg.encode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(words) == 1 {
|
||||
return dc.cli.writeSingleRegister(dc.dev.UnitID, reg.Address, words[0])
|
||||
}
|
||||
return dc.cli.writeMultipleRegisters(dc.dev.UnitID, reg.Address, words)
|
||||
}
|
||||
|
||||
// History is unavailable for Modbus devices.
|
||||
func (m *Modbus) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
// Close tears down every device connection.
|
||||
func (m *Modbus) Close() {
|
||||
for _, dc := range m.devices {
|
||||
dc.cli.close()
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, error) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, nil
|
||||
case float32:
|
||||
return float64(x), nil
|
||||
case int:
|
||||
return float64(x), nil
|
||||
case int64:
|
||||
return float64(x), nil
|
||||
case bool:
|
||||
if x {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("modbus: cannot write value of type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func toBool(v any) (bool, error) {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, nil
|
||||
case float64:
|
||||
return x != 0, nil
|
||||
case int:
|
||||
return x != 0, nil
|
||||
case int64:
|
||||
return x != 0, nil
|
||||
default:
|
||||
return false, fmt.Errorf("modbus: cannot write bool value of type %T", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// mockServer is an in-process Modbus TCP slave for tests. It serves a small
|
||||
// register/coil store and records writes. Only the function codes exercised by
|
||||
// the data source are implemented.
|
||||
type mockServer struct {
|
||||
ln net.Listener
|
||||
|
||||
mu sync.Mutex
|
||||
holding map[uint16]uint16
|
||||
input map[uint16]uint16
|
||||
coils map[uint16]bool
|
||||
discrete map[uint16]bool
|
||||
lastWrite []uint16 // registers from the most recent write
|
||||
}
|
||||
|
||||
func newMockServer(t *testing.T) *mockServer {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
s := &mockServer{
|
||||
ln: ln,
|
||||
holding: map[uint16]uint16{},
|
||||
input: map[uint16]uint16{},
|
||||
coils: map[uint16]bool{},
|
||||
discrete: map[uint16]bool{},
|
||||
}
|
||||
go s.serve()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *mockServer) addr() string { return s.ln.Addr().String() }
|
||||
|
||||
func (s *mockServer) serve() {
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockServer) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
for {
|
||||
head := make([]byte, 7)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
return
|
||||
}
|
||||
tx := binary.BigEndian.Uint16(head[0:])
|
||||
length := binary.BigEndian.Uint16(head[4:])
|
||||
body := make([]byte, length-1)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return
|
||||
}
|
||||
resp := s.respond(body)
|
||||
out := make([]byte, 7+len(resp))
|
||||
binary.BigEndian.PutUint16(out[0:], tx)
|
||||
binary.BigEndian.PutUint16(out[2:], 0)
|
||||
binary.BigEndian.PutUint16(out[4:], uint16(1+len(resp)))
|
||||
out[6] = head[6]
|
||||
copy(out[7:], resp)
|
||||
if _, err := conn.Write(out); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockServer) respond(pdu []byte) []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
fc := pdu[0]
|
||||
switch fc {
|
||||
case fcReadHolding, fcReadInput:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
qty := binary.BigEndian.Uint16(pdu[3:])
|
||||
out := []byte{fc, byte(qty * 2)}
|
||||
src := s.holding
|
||||
if fc == fcReadInput {
|
||||
src = s.input
|
||||
}
|
||||
for i := uint16(0); i < qty; i++ {
|
||||
out = binary.BigEndian.AppendUint16(out, src[addr+i])
|
||||
}
|
||||
return out
|
||||
case fcReadCoils, fcReadDiscrete:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
qty := binary.BigEndian.Uint16(pdu[3:])
|
||||
nbytes := (int(qty) + 7) / 8
|
||||
out := []byte{fc, byte(nbytes)}
|
||||
bits := make([]byte, nbytes)
|
||||
src := s.coils
|
||||
if fc == fcReadDiscrete {
|
||||
src = s.discrete
|
||||
}
|
||||
for i := uint16(0); i < qty; i++ {
|
||||
if src[addr+i] {
|
||||
bits[i/8] |= 1 << (i % 8)
|
||||
}
|
||||
}
|
||||
return append(out, bits...)
|
||||
case fcWriteSingleReg:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
val := binary.BigEndian.Uint16(pdu[3:])
|
||||
s.holding[addr] = val
|
||||
s.lastWrite = []uint16{val}
|
||||
return pdu // echo
|
||||
case fcWriteSingleCoil:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
s.coils[addr] = binary.BigEndian.Uint16(pdu[3:]) == 0xFF00
|
||||
return pdu
|
||||
case fcWriteMultipleRegs:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
qty := binary.BigEndian.Uint16(pdu[3:])
|
||||
s.lastWrite = nil
|
||||
for i := uint16(0); i < qty; i++ {
|
||||
v := binary.BigEndian.Uint16(pdu[6+i*2:])
|
||||
s.holding[addr+i] = v
|
||||
s.lastWrite = append(s.lastWrite, v)
|
||||
}
|
||||
return append([]byte{fc}, pdu[1:5]...)
|
||||
default:
|
||||
return []byte{fc | 0x80, 0x01}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockServer) setHolding(addr, val uint16) {
|
||||
s.mu.Lock()
|
||||
s.holding[addr] = val
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *mockServer) setInput(addr, val uint16) {
|
||||
s.mu.Lock()
|
||||
s.input[addr] = val
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *mockServer) setDiscrete(addr uint16, on bool) {
|
||||
s.mu.Lock()
|
||||
s.discrete[addr] = on
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func testConfig(addr string) Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
PollIntervalMs: 20,
|
||||
Devices: []Device{{
|
||||
Name: "dev",
|
||||
Address: addr,
|
||||
UnitID: 1,
|
||||
Registers: []Register{
|
||||
{Name: "temp", Kind: "holding", Address: 10, Encoding: "int16", Scale: 0.1, Unit: "C", Writable: true},
|
||||
{Name: "count", Kind: "holding", Address: 20, Encoding: "uint16"},
|
||||
{Name: "big", Kind: "input", Address: 30, Encoding: "uint32"},
|
||||
{Name: "flag", Kind: "discrete", Address: 5},
|
||||
{Name: "relay", Kind: "coil", Address: 6, Writable: true},
|
||||
{Name: "sp", Kind: "holding", Address: 40, Encoding: "float32", Writable: true},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRegisters(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
srv.setHolding(10, 235) // int16, scale 0.1 → 23.5
|
||||
srv.setHolding(20, 7)
|
||||
srv.setInput(30, 0)
|
||||
srv.setInput(31, 1000) // uint32 big-word-first: low word at 31
|
||||
srv.setDiscrete(5, true)
|
||||
|
||||
m, err := New(testConfig(srv.addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
temp, err := m.readSignal(m.signals["dev:temp"])
|
||||
if err != nil {
|
||||
t.Fatalf("read temp: %v", err)
|
||||
}
|
||||
if f, ok := temp.Data.(float64); !ok || f < 23.49 || f > 23.51 {
|
||||
t.Errorf("temp = %v (%T), want 23.5", temp.Data, temp.Data)
|
||||
}
|
||||
|
||||
count, err := m.readSignal(m.signals["dev:count"])
|
||||
if err != nil {
|
||||
t.Fatalf("read count: %v", err)
|
||||
}
|
||||
if v, ok := count.Data.(int64); !ok || v != 7 {
|
||||
t.Errorf("count = %v (%T), want int64 7", count.Data, count.Data)
|
||||
}
|
||||
|
||||
big, err := m.readSignal(m.signals["dev:big"])
|
||||
if err != nil {
|
||||
t.Fatalf("read big: %v", err)
|
||||
}
|
||||
if v, ok := big.Data.(int64); !ok || v != 1000 {
|
||||
t.Errorf("big = %v (%T), want int64 1000", big.Data, big.Data)
|
||||
}
|
||||
|
||||
flag, err := m.readSignal(m.signals["dev:flag"])
|
||||
if err != nil {
|
||||
t.Fatalf("read flag: %v", err)
|
||||
}
|
||||
if b, ok := flag.Data.(bool); !ok || !b {
|
||||
t.Errorf("flag = %v, want true", flag.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRoundTrip(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
m, err := New(testConfig(srv.addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// Scaled int16: writing 23.5 with scale 0.1 should store raw 235.
|
||||
if err := m.Write(ctx, "dev:temp", 23.5); err != nil {
|
||||
t.Fatalf("write temp: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
raw := srv.holding[10]
|
||||
srv.mu.Unlock()
|
||||
if raw != 235 {
|
||||
t.Errorf("holding[10] = %d, want 235", raw)
|
||||
}
|
||||
|
||||
// Coil write.
|
||||
if err := m.Write(ctx, "dev:relay", true); err != nil {
|
||||
t.Fatalf("write relay: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
on := srv.coils[6]
|
||||
srv.mu.Unlock()
|
||||
if !on {
|
||||
t.Error("coil 6 not set")
|
||||
}
|
||||
|
||||
// float32 multi-register write.
|
||||
if err := m.Write(ctx, "dev:sp", 12.5); err != nil {
|
||||
t.Fatalf("write sp: %v", err)
|
||||
}
|
||||
got, err := m.readSignal(m.signals["dev:sp"])
|
||||
if err != nil {
|
||||
t.Fatalf("read sp: %v", err)
|
||||
}
|
||||
if f, ok := got.Data.(float64); !ok || f < 12.49 || f > 12.51 {
|
||||
t.Errorf("sp = %v, want 12.5", got.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrors(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
m, _ := New(testConfig(srv.addr()))
|
||||
defer m.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := m.Write(ctx, "dev:missing", 1); err != datasource.ErrNotFound {
|
||||
t.Errorf("missing write err = %v, want ErrNotFound", err)
|
||||
}
|
||||
// Input register is read-only.
|
||||
if err := m.Write(ctx, "dev:big", 1); err != datasource.ErrNotWritable {
|
||||
t.Errorf("input write err = %v, want ErrNotWritable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
srv.setHolding(20, 42)
|
||||
m, _ := New(testConfig(srv.addr()))
|
||||
defer m.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := m.Subscribe(ctx, "dev:count", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityGood {
|
||||
t.Errorf("quality = %v, want good", v.Quality)
|
||||
}
|
||||
if iv, ok := v.Data.(int64); !ok || iv != 42 {
|
||||
t.Errorf("first value = %v, want 42", v.Data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for first value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeBadQualityOnError(t *testing.T) {
|
||||
// Point at a closed port so reads fail; expect QualityBad, not a hang.
|
||||
cfg := testConfig("127.0.0.1:1") // port 1: connection refused
|
||||
m, _ := New(cfg)
|
||||
defer m.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 1)
|
||||
stop, err := m.Subscribe(ctx, "dev:count", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityBad {
|
||||
t.Errorf("quality = %v, want bad", v.Quality)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidation(t *testing.T) {
|
||||
if _, err := New(Config{Devices: []Device{{Name: "", Address: "x"}}}); err == nil {
|
||||
t.Error("expected error for missing device name")
|
||||
}
|
||||
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
|
||||
t.Error("expected error for duplicate device")
|
||||
}
|
||||
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r", Encoding: "bogus"}}}}}); err == nil {
|
||||
t.Error("expected error for bad encoding")
|
||||
}
|
||||
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r"}, {Name: "r"}}}}}); err == nil {
|
||||
t.Error("expected error for duplicate register")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeUnknownSignal(t *testing.T) {
|
||||
m, _ := New(testConfig("127.0.0.1:502"))
|
||||
defer m.Close()
|
||||
if _, err := m.Subscribe(context.Background(), "nope", nil); err != datasource.ErrNotFound {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := m.GetMetadata(context.Background(), "nope"); err != datasource.ErrNotFound {
|
||||
t.Errorf("meta err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package scpi
|
||||
|
||||
import "github.com/uopi/uopi/internal/datasource"
|
||||
|
||||
// Config is the [datasource.scpi] section. Each instrument is polled
|
||||
// independently over its own TCP socket.
|
||||
type Config struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// PollIntervalMs is the default polling period for channels that do not
|
||||
// override it. Zero → 1000 ms.
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Instruments []Instrument `toml:"instruments"`
|
||||
}
|
||||
|
||||
// Instrument is one SCPI device reachable over a raw TCP socket. Address is
|
||||
// "host:port"; the conventional SCPI-raw port 5025 is appended if absent.
|
||||
type Instrument struct {
|
||||
Name string `toml:"name"`
|
||||
// Transport selects the link type. "raw" (default) is line-based SCPI over
|
||||
// TCP. Reserved: "vxi11" (not yet implemented).
|
||||
Transport string `toml:"transport"`
|
||||
Address string `toml:"address"`
|
||||
TimeoutMs int `toml:"timeout_ms"`
|
||||
// Terminator is appended to every command. Empty → "\n".
|
||||
Terminator string `toml:"terminator"`
|
||||
Channels []Channel `toml:"channels"`
|
||||
}
|
||||
|
||||
// Channel maps a SCPI query/command pair onto a signal named
|
||||
// "instrument:channel".
|
||||
type Channel struct {
|
||||
Name string `toml:"name"`
|
||||
// Query is the SCPI command whose response is the channel value,
|
||||
// e.g. "MEAS:VOLT?". Required.
|
||||
Query string `toml:"query"`
|
||||
// WriteCmd is a printf-style template used by Write; "%v" is replaced with
|
||||
// the value, e.g. "VOLT %v". Empty → channel is read-only.
|
||||
WriteCmd string `toml:"write_cmd"`
|
||||
// Type is the value type: "float" (default), "string", "int", or "bool".
|
||||
Type string `toml:"type"`
|
||||
Unit string `toml:"unit"`
|
||||
Min float64 `toml:"min"`
|
||||
Max float64 `toml:"max"`
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Description string `toml:"description"`
|
||||
}
|
||||
|
||||
func (c Channel) dataType() datasource.DataType {
|
||||
switch c.Type {
|
||||
case "string":
|
||||
return datasource.TypeString
|
||||
case "int":
|
||||
return datasource.TypeInt64
|
||||
case "bool":
|
||||
return datasource.TypeBool
|
||||
default:
|
||||
return datasource.TypeFloat64
|
||||
}
|
||||
}
|
||||
|
||||
func (c Channel) writable() bool { return c.WriteCmd != "" }
|
||||
|
||||
func (c Channel) metadata(instrument string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: instrument + ":" + c.Name,
|
||||
Type: c.dataType(),
|
||||
Unit: c.Unit,
|
||||
Description: c.Description,
|
||||
DisplayLow: c.Min,
|
||||
DisplayHigh: c.Max,
|
||||
DriveLow: c.Min,
|
||||
DriveHigh: c.Max,
|
||||
Writable: c.writable(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package scpi implements a SCPI instrument data source. Each configured
|
||||
// instrument is reached over a raw TCP socket (line-based SCPI, the "SCPI raw" /
|
||||
// port 5025 convention); a VXI-11 transport is reserved for later. Channels are
|
||||
// exposed as signals named "instrument:channel" and polled at a configurable
|
||||
// interval. Channels with a write_cmd template accept Write.
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const defaultPollInterval = time.Second
|
||||
|
||||
type instrumentConn struct {
|
||||
inst Instrument
|
||||
tr transport
|
||||
}
|
||||
|
||||
// Scpi is a datasource.DataSource backed by one or more SCPI instruments.
|
||||
type Scpi struct {
|
||||
pollInterval time.Duration
|
||||
instruments map[string]*instrumentConn
|
||||
signals map[string]signalRef // "instrument:channel" → ref
|
||||
}
|
||||
|
||||
type signalRef struct {
|
||||
instrument string
|
||||
ch Channel
|
||||
}
|
||||
|
||||
// New builds a SCPI source from config. It does not dial; connections are
|
||||
// established lazily on first poll/write.
|
||||
func New(cfg Config) (*Scpi, error) {
|
||||
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
|
||||
if poll <= 0 {
|
||||
poll = defaultPollInterval
|
||||
}
|
||||
s := &Scpi{
|
||||
pollInterval: poll,
|
||||
instruments: make(map[string]*instrumentConn),
|
||||
signals: make(map[string]signalRef),
|
||||
}
|
||||
for _, inst := range cfg.Instruments {
|
||||
if inst.Name == "" || inst.Address == "" {
|
||||
return nil, fmt.Errorf("scpi: instrument needs name and address")
|
||||
}
|
||||
if _, dup := s.instruments[inst.Name]; dup {
|
||||
return nil, fmt.Errorf("scpi: duplicate instrument %q", inst.Name)
|
||||
}
|
||||
tr, err := newTransport(inst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ic := &instrumentConn{inst: inst, tr: tr}
|
||||
for _, ch := range inst.Channels {
|
||||
if ch.Name == "" || ch.Query == "" {
|
||||
return nil, fmt.Errorf("scpi: instrument %q channel needs name and query", inst.Name)
|
||||
}
|
||||
key := inst.Name + ":" + ch.Name
|
||||
if _, dup := s.signals[key]; dup {
|
||||
return nil, fmt.Errorf("scpi: instrument %q duplicate channel %q", inst.Name, ch.Name)
|
||||
}
|
||||
s.signals[key] = signalRef{instrument: inst.Name, ch: ch}
|
||||
}
|
||||
s.instruments[inst.Name] = ic
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// newTransport selects the transport implementation for an instrument.
|
||||
func newTransport(inst Instrument) (transport, error) {
|
||||
addr := inst.Address
|
||||
switch strings.ToLower(inst.Transport) {
|
||||
case "", "raw":
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr += ":5025"
|
||||
}
|
||||
return newRawSocket(addr, time.Duration(inst.TimeoutMs)*time.Millisecond, inst.Terminator), nil
|
||||
case "vxi11":
|
||||
return nil, fmt.Errorf("scpi: vxi11 transport not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("scpi: unknown transport %q", inst.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (s *Scpi) Name() string { return "scpi" }
|
||||
|
||||
// Connect is a no-op; connections are dialled lazily per instrument.
|
||||
func (s *Scpi) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every configured channel.
|
||||
func (s *Scpi) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, ref := range s.signals {
|
||||
out = append(out, ref.ch.metadata(ref.instrument))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for one signal.
|
||||
func (s *Scpi) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return ref.ch.metadata(ref.instrument), nil
|
||||
}
|
||||
|
||||
// readSignal performs one synchronous query of a channel.
|
||||
func (s *Scpi) readSignal(ref signalRef) (datasource.Value, error) {
|
||||
ic := s.instruments[ref.instrument]
|
||||
resp, err := ic.tr.query(ref.ch.Query)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
data, err := parseValue(ref.ch.dataType(), resp)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: time.Now(), Data: data, Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
// Subscribe polls the channel at its configured interval (falling back to the
|
||||
// source default) and pushes values into ch. A query error emits QualityBad and
|
||||
// polling continues.
|
||||
func (s *Scpi) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
interval := s.pollInterval
|
||||
if ref.ch.PollIntervalMs > 0 {
|
||||
interval = time.Duration(ref.ch.PollIntervalMs) * time.Millisecond
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
emit := func() {
|
||||
v, err := s.readSignal(ref)
|
||||
if err != nil {
|
||||
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
emit()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
emit()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// Write sends the channel's write_cmd template with the value substituted.
|
||||
func (s *Scpi) Write(_ context.Context, signal string, value any) error {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
if !ref.ch.writable() {
|
||||
return datasource.ErrNotWritable
|
||||
}
|
||||
ic := s.instruments[ref.instrument]
|
||||
cmd := formatWrite(ref.ch.WriteCmd, value)
|
||||
return ic.tr.write(cmd)
|
||||
}
|
||||
|
||||
// History is unavailable for SCPI instruments.
|
||||
func (s *Scpi) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
// Close tears down every instrument connection.
|
||||
func (s *Scpi) Close() {
|
||||
for _, ic := range s.instruments {
|
||||
ic.tr.close()
|
||||
}
|
||||
}
|
||||
|
||||
// formatWrite substitutes value into the write template. A "%" in the template
|
||||
// is treated as a printf verb; otherwise the value is appended after a space.
|
||||
func formatWrite(tmpl string, value any) string {
|
||||
if strings.Contains(tmpl, "%") {
|
||||
return fmt.Sprintf(tmpl, value)
|
||||
}
|
||||
return fmt.Sprintf("%s %v", tmpl, value)
|
||||
}
|
||||
|
||||
// parseValue converts a raw SCPI response string into the channel's data type.
|
||||
func parseValue(t datasource.DataType, resp string) (any, error) {
|
||||
resp = strings.TrimSpace(resp)
|
||||
switch t {
|
||||
case datasource.TypeString:
|
||||
return resp, nil
|
||||
case datasource.TypeInt64:
|
||||
// Accept "12", "12.0", or scientific notation by going through float.
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse int %q: %w", resp, err)
|
||||
}
|
||||
return int64(f), nil
|
||||
case datasource.TypeBool:
|
||||
switch strings.ToUpper(resp) {
|
||||
case "1", "ON", "TRUE":
|
||||
return true, nil
|
||||
case "0", "OFF", "FALSE":
|
||||
return false, nil
|
||||
}
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse bool %q: %w", resp, err)
|
||||
}
|
||||
return f != 0, nil
|
||||
default:
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse float %q: %w", resp, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// mockInstrument is an in-process line-based SCPI server. It answers queries
|
||||
// from a fixed table and records commands that produce no response (writes).
|
||||
type mockInstrument struct {
|
||||
ln net.Listener
|
||||
|
||||
mu sync.Mutex
|
||||
answers map[string]string // query → response
|
||||
writes []string
|
||||
}
|
||||
|
||||
func newMockInstrument(t *testing.T) *mockInstrument {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
m := &mockInstrument{ln: ln, answers: map[string]string{}}
|
||||
go m.serve()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockInstrument) addr() string { return m.ln.Addr().String() }
|
||||
|
||||
func (m *mockInstrument) setAnswer(q, a string) {
|
||||
m.mu.Lock()
|
||||
m.answers[q] = a
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *mockInstrument) writeLog() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.writes...)
|
||||
}
|
||||
|
||||
func (m *mockInstrument) serve() {
|
||||
for {
|
||||
conn, err := m.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go m.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockInstrument) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
br := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimRight(line, "\r\n")
|
||||
m.mu.Lock()
|
||||
if strings.HasSuffix(cmd, "?") {
|
||||
resp, ok := m.answers[cmd]
|
||||
if !ok {
|
||||
resp = "0"
|
||||
}
|
||||
m.mu.Unlock()
|
||||
conn.Write([]byte(resp + "\n"))
|
||||
continue
|
||||
}
|
||||
m.writes = append(m.writes, cmd)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func testCfg(addr string) Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
PollIntervalMs: 20,
|
||||
Instruments: []Instrument{{
|
||||
Name: "dmm",
|
||||
Address: addr,
|
||||
Channels: []Channel{
|
||||
{Name: "volt", Query: "MEAS:VOLT?", WriteCmd: "VOLT %v", Type: "float", Unit: "V"},
|
||||
{Name: "id", Query: "*IDN?", Type: "string"},
|
||||
{Name: "n", Query: "COUNT?", Type: "int"},
|
||||
{Name: "out", Query: "OUTP?", WriteCmd: "OUTP", Type: "bool"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTypes(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
srv.setAnswer("MEAS:VOLT?", "12.34")
|
||||
srv.setAnswer("*IDN?", "ACME,DMM,1,2.0")
|
||||
srv.setAnswer("COUNT?", "7")
|
||||
srv.setAnswer("OUTP?", "ON")
|
||||
|
||||
s, err := New(testCfg(srv.addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
v, err := s.readSignal(s.signals["dmm:volt"])
|
||||
if err != nil {
|
||||
t.Fatalf("read volt: %v", err)
|
||||
}
|
||||
if f, ok := v.Data.(float64); !ok || f < 12.33 || f > 12.35 {
|
||||
t.Errorf("volt = %v (%T), want 12.34", v.Data, v.Data)
|
||||
}
|
||||
|
||||
id, _ := s.readSignal(s.signals["dmm:id"])
|
||||
if id.Data != "ACME,DMM,1,2.0" {
|
||||
t.Errorf("id = %v", id.Data)
|
||||
}
|
||||
|
||||
n, _ := s.readSignal(s.signals["dmm:n"])
|
||||
if iv, ok := n.Data.(int64); !ok || iv != 7 {
|
||||
t.Errorf("n = %v (%T), want 7", n.Data, n.Data)
|
||||
}
|
||||
|
||||
out, _ := s.readSignal(s.signals["dmm:out"])
|
||||
if b, ok := out.Data.(bool); !ok || !b {
|
||||
t.Errorf("out = %v, want true", out.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "dmm:volt", 3.3); err != nil {
|
||||
t.Fatalf("write volt: %v", err)
|
||||
}
|
||||
// bool write uses a template with no verb → "OUTP <val>".
|
||||
if err := s.Write(ctx, "dmm:out", true); err != nil {
|
||||
t.Fatalf("write out: %v", err)
|
||||
}
|
||||
|
||||
// Give the server a moment to record both writes.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(srv.writeLog()) >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
got := srv.writeLog()
|
||||
if len(got) != 2 || got[0] != "VOLT 3.3" || got[1] != "OUTP true" {
|
||||
t.Errorf("writes = %v, want [VOLT 3.3, OUTP true]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrors(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "dmm:missing", 1); err != datasource.ErrNotFound {
|
||||
t.Errorf("missing = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := s.Write(ctx, "dmm:id", 1); err != datasource.ErrNotWritable {
|
||||
t.Errorf("read-only = %v, want ErrNotWritable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
srv.setAnswer("MEAS:VOLT?", "5.0")
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "dmm:volt", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityGood {
|
||||
t.Errorf("quality = %v", v.Quality)
|
||||
}
|
||||
if f, ok := v.Data.(float64); !ok || f != 5.0 {
|
||||
t.Errorf("value = %v, want 5.0", v.Data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeBadQuality(t *testing.T) {
|
||||
cfg := testCfg("127.0.0.1:1") // refused
|
||||
s, _ := New(cfg)
|
||||
defer s.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 1)
|
||||
stop, _ := s.Subscribe(ctx, "dmm:volt", ch)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityBad {
|
||||
t.Errorf("quality = %v, want bad", v.Quality)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "", Address: "x"}}}); err == nil {
|
||||
t.Error("want error for missing name")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
|
||||
t.Error("want error for duplicate instrument")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "vxi11"}}}); err == nil {
|
||||
t.Error("want error for unimplemented vxi11 transport")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "bogus"}}}); err == nil {
|
||||
t.Error("want error for unknown transport")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Channels: []Channel{{Name: "c"}}}}}); err == nil {
|
||||
t.Error("want error for channel without query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWrite(t *testing.T) {
|
||||
if got := formatWrite("VOLT %v", 3.3); got != "VOLT 3.3" {
|
||||
t.Errorf("verb template = %q", got)
|
||||
}
|
||||
if got := formatWrite("OUTP", true); got != "OUTP true" {
|
||||
t.Errorf("plain template = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseValue(t *testing.T) {
|
||||
if v, _ := parseValue(datasource.TypeBool, "OFF"); v != false {
|
||||
t.Errorf("OFF = %v, want false", v)
|
||||
}
|
||||
if v, _ := parseValue(datasource.TypeBool, "2.0"); v != true {
|
||||
t.Errorf("2.0 bool = %v, want true", v)
|
||||
}
|
||||
if _, err := parseValue(datasource.TypeFloat64, "notnum"); err == nil {
|
||||
t.Error("want parse error for non-numeric float")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// transport is the request/response channel to an instrument. The raw-socket
|
||||
// implementation below speaks line-oriented SCPI over TCP (the common
|
||||
// "SCPI raw" / port 5025 convention). The interface is kept narrow so a VXI-11
|
||||
// (ONC-RPC) transport can be added later without touching the data source.
|
||||
type transport interface {
|
||||
// query sends cmd and returns the instrument's single-line response.
|
||||
query(cmd string) (string, error)
|
||||
// write sends cmd and does not wait for a response.
|
||||
write(cmd string) error
|
||||
close()
|
||||
}
|
||||
|
||||
// rawSocket is a line-based SCPI transport over a single TCP connection. The
|
||||
// connection is dialled lazily and dropped on any I/O error so the next call
|
||||
// reconnects. Calls are serialised by mu because SCPI is request/response.
|
||||
type rawSocket struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
terminator string
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
br *bufio.Reader
|
||||
}
|
||||
|
||||
func newRawSocket(addr string, timeout time.Duration, terminator string) *rawSocket {
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
if terminator == "" {
|
||||
terminator = "\n"
|
||||
}
|
||||
return &rawSocket{addr: addr, timeout: timeout, terminator: terminator}
|
||||
}
|
||||
|
||||
func (s *rawSocket) dialLocked() error {
|
||||
if s.conn != nil {
|
||||
return nil
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", s.addr, s.timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scpi: dial %s: %w", s.addr, err)
|
||||
}
|
||||
s.conn = conn
|
||||
s.br = bufio.NewReader(conn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rawSocket) closeLocked() {
|
||||
if s.conn != nil {
|
||||
_ = s.conn.Close()
|
||||
s.conn = nil
|
||||
s.br = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rawSocket) close() {
|
||||
s.mu.Lock()
|
||||
s.closeLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *rawSocket) sendLocked(cmd string) error {
|
||||
_ = s.conn.SetDeadline(time.Now().Add(s.timeout))
|
||||
if _, err := s.conn.Write([]byte(cmd + s.terminator)); err != nil {
|
||||
s.closeLocked()
|
||||
return fmt.Errorf("scpi: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rawSocket) write(cmd string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.dialLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendLocked(cmd)
|
||||
}
|
||||
|
||||
func (s *rawSocket) query(cmd string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.dialLocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.sendLocked(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := s.br.ReadString('\n')
|
||||
if err != nil {
|
||||
s.closeLocked()
|
||||
return "", fmt.Errorf("scpi: read: %w", err)
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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-RED–style 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
@@ -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) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user