8f50bc2498
Add five action.array.push|set|remove|pop|clear node kinds; replace the
legacy {t,v} in-memory arrays store with array locals as single source of
truth; alias action.accumulate→push and action.clear→array.clear for
backward compat; rewrite action.export as index-aligned CSV with custom
header labels; add ensureArrayDecls() auto-declare migration wired via
Canvas.tsx so undeclared arrays referenced by logic nodes are initialised.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
942 lines
38 KiB
TypeScript
942 lines
38 KiB
TypeScript
// Panel logic engine.
|
|
//
|
|
// A panel may define a LogicGraph (see types.ts): a Node-RED-style flow of
|
|
// trigger, gate, control-flow and action nodes connected by wires. This
|
|
// singleton engine runs the graph client-side in view mode only: Canvas calls
|
|
// `load(iface.logic)` when a panel mounts and `clear()` when it unmounts.
|
|
//
|
|
// Triggers are flow entry points. When one activates, the engine follows the
|
|
// outgoing wires executing downstream nodes:
|
|
// gate.and — passes only when all incoming triggers are satisfied.
|
|
// flow.if — evaluates `cond`, continues on the 'then' or 'else' port.
|
|
// 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.log — logs an expression value to the console.
|
|
//
|
|
// Expressions reference signals inline as {ds:name} and panel-local vars as
|
|
// bare identifiers; their values are read from a live cache kept up to date by
|
|
// subscriptions started at load() for every referenced signal. Two built-in
|
|
// system signals are served synthetically (never subscribed): {sys:time} is the
|
|
// current epoch time in seconds and {sys:dt} is the seconds elapsed since the
|
|
// firing trigger last fired (useful in On change / Timer / Panel loop flows,
|
|
// e.g. to compute a rate).
|
|
|
|
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, 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
|
|
// shown to the operator). A dialog may carry any number of both.
|
|
export interface DialogField {
|
|
type: 'input' | 'display';
|
|
label: string;
|
|
// input: the pre-filled default text; display: the resolved value to show.
|
|
value: string;
|
|
}
|
|
|
|
// A pending user-interaction dialog requested by an action.dialog.* node. The
|
|
// engine pushes one to `logicDialogs` and awaits the user's response; the
|
|
// LogicDialogs component renders it and calls `resolve` with the entered values
|
|
// for the input fields (in field order), or null for Cancel. Info/error
|
|
// dialogs have no inputs and resolve with an empty array on OK.
|
|
export interface DialogRequest {
|
|
id: string;
|
|
kind: 'info' | 'error' | 'setpoint';
|
|
title: string;
|
|
message: string;
|
|
fields: DialogField[];
|
|
resolve: (entered: string[] | null) => void;
|
|
}
|
|
|
|
// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts.
|
|
export const logicDialogs = writable<DialogRequest[]>([]);
|
|
|
|
// Guards against runaway flows (cycles / pathological loops).
|
|
const MAX_STEPS = 100000;
|
|
const MAX_LOOP = 100000;
|
|
|
|
// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':').
|
|
function parseRef(target: string): SignalRef | null {
|
|
const t = target.trim();
|
|
if (!t) return null;
|
|
const i = t.indexOf(':');
|
|
if (i < 0) return { ds: 'local', name: t }; // bare name → panel-local var
|
|
return { ds: t.slice(0, i), name: t.slice(i + 1) };
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Reads a single parameter's value from a config instance, coercing it to a
|
|
// number. Returns null if the instance/set/param is missing or non-numeric.
|
|
async function readConfigParam(instanceId: string, key: string): Promise<number | null> {
|
|
try {
|
|
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
|
|
if (!ir.ok) return null;
|
|
const inst: { setId: string; setVersion?: number; values?: Record<string, any> } = await ir.json();
|
|
const sv = inst.setVersion && inst.setVersion > 0
|
|
? `/api/v1/config/sets/${encodeURIComponent(inst.setId)}/versions/${inst.setVersion}`
|
|
: `/api/v1/config/sets/${encodeURIComponent(inst.setId)}`;
|
|
const sr = await fetch(sv);
|
|
if (!sr.ok) return null;
|
|
const set: { parameters?: Array<{ key: string; default?: any }> } = await sr.json();
|
|
const param = (set.parameters ?? []).find(p => p.key === key);
|
|
if (!param) return null;
|
|
const raw = inst.values?.[key] ?? param.default;
|
|
const n = toNum(raw);
|
|
return isNaN(n) ? null : n;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Write a single numeric value into a config instance's parameter, creating a
|
|
// new revision server-side. Reads the current instance, overwrites values[key],
|
|
// and PUTs it back. Silently no-ops on any transport/parse error.
|
|
async function writeConfigParam(instanceId: string, key: string, val: number): Promise<void> {
|
|
try {
|
|
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
|
|
if (!ir.ok) return;
|
|
const inst: any = await ir.json();
|
|
inst.values = { ...(inst.values ?? {}), [key]: val };
|
|
await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(inst),
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
// Create a new config instance for `setId`, optionally seeding its values from
|
|
// an existing instance `fromId`. Returns the new instance id, or '' on failure.
|
|
async function createConfigInstance(setId: string, name: string, fromId: string): Promise<string> {
|
|
try {
|
|
let values: Record<string, any> = {};
|
|
if (fromId) {
|
|
const fr = await fetch(`/api/v1/config/instances/${encodeURIComponent(fromId)}`);
|
|
if (fr.ok) {
|
|
const src: any = await fr.json();
|
|
values = { ...(src.values ?? {}) };
|
|
}
|
|
}
|
|
const r = await fetch('/api/v1/config/instances', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, setId, values }),
|
|
});
|
|
if (!r.ok) return '';
|
|
const out: any = await r.json();
|
|
return String(out?.id ?? '');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Snapshot the current live values of every target signal of `setId` into a new
|
|
// config instance (optional label). Returns the new instance id, or '' on failure.
|
|
async function snapshotConfigSet(setId: string, name: string): Promise<string> {
|
|
try {
|
|
const r = await fetch(`/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
if (!r.ok) return '';
|
|
const out: any = await r.json();
|
|
return String(out?.instance?.id ?? '');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function toNum(v: any): number {
|
|
if (typeof v === 'number') return v;
|
|
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
const n = parseFloat(v);
|
|
return isNaN(n) ? NaN : n;
|
|
}
|
|
|
|
function refKey(ds: string, name: string): string {
|
|
return `${ds}\0${name}`;
|
|
}
|
|
|
|
// A dialog field as configured on a node (before its value is resolved).
|
|
interface DialogFieldSpec {
|
|
type: 'input' | 'display';
|
|
label: string;
|
|
target?: string; // input: "ds:name" (or bare local var) to write on OK
|
|
default?: string; // input: expression pre-filling the input
|
|
expr?: string; // display: expression evaluated and shown
|
|
}
|
|
|
|
// 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
|
|
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
|
|
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
|
|
|
|
export class LogicEngine {
|
|
private graph: LogicGraph = { nodes: [], wires: [] };
|
|
private byId = new Map<string, LogicNode>();
|
|
// Outgoing wires grouped by source node id.
|
|
private out = new Map<string, WireOut[]>();
|
|
// Incoming source node ids grouped by target node id (for gate evaluation).
|
|
private incoming = new Map<string, string[]>();
|
|
// Live signal value cache (key "ds\0name"), kept fresh by subscriptions.
|
|
private live = new Map<string, any>();
|
|
// Current truth of each level-style trigger (threshold), for AND-gates.
|
|
private levelState = new Map<string, boolean>();
|
|
// Per-node previous values for edge/change detection.
|
|
private prevBool = new Map<string, boolean>();
|
|
private prevVal = new Map<string, any>();
|
|
// signal key → trigger node ids that react to it (threshold / change).
|
|
private watchers = new Map<string, string[]>();
|
|
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
|
|
private lastFire = new Map<string, number>();
|
|
private cleanups: Array<() => void> = [];
|
|
|
|
// Dry-run / debug mode: when true the engine performs no external side effects
|
|
// (signal writes, config mutations, CSV exports, widget commands, dialogs) and
|
|
// instead records each node's last value/firing time so an editor overlay can
|
|
// visualise the flow without touching production. Used by a throwaway engine
|
|
// instance spun up by the Logic editor; the view-mode singleton leaves it off.
|
|
private dryRun = false;
|
|
private debugStates = new Map<string, { value: any; ts: number }>();
|
|
|
|
/** Enable/disable dry-run mode. Must be set before load(). */
|
|
setDryRun(on: boolean): void { this.dryRun = on; }
|
|
|
|
// Record that a node fired this tick, optionally capturing its computed value.
|
|
private markActive(id: string, value?: any): void {
|
|
if (!this.dryRun) return;
|
|
const prev = this.debugStates.get(id);
|
|
this.debugStates.set(id, { value: value !== undefined ? value : prev?.value, ts: Date.now() });
|
|
}
|
|
|
|
/** Per-node debug snapshot: a node is "active" if it fired within ~0.8 s. */
|
|
getDebug(): Map<string, { value: any; active: boolean }> {
|
|
const now = Date.now();
|
|
const out = new Map<string, { value: any; active: boolean }>();
|
|
for (const [id, s] of this.debugStates) out.set(id, { value: s.value, active: now - s.ts < 800 });
|
|
return out;
|
|
}
|
|
|
|
// Suppress signal writes in dry-run; otherwise forward to the live client.
|
|
private dryWrite(ref: SignalRef, val: any): void {
|
|
if (!this.dryRun) wsClient.write(ref, val);
|
|
}
|
|
|
|
// Resolve an expression reference. Two built-in system signals are served
|
|
// synthetically (never subscribed): {sys:time} = current epoch seconds,
|
|
// {sys:dt} = seconds since the firing trigger last fired. Everything else
|
|
// reads from the live signal cache.
|
|
private sysResolve(ds: string, name: string, dt: number): number {
|
|
if (ds === 'sys') {
|
|
if (name === 'time') return Date.now() / 1000;
|
|
if (name === 'dt') return dt;
|
|
return NaN;
|
|
}
|
|
return toNum(this.live.get(refKey(ds, name)));
|
|
}
|
|
|
|
// Resolve the config-instance id a node operates on. With instanceSource
|
|
// 'var' the id is read (as a raw string) from a panel-local variable / signal
|
|
// — this is how a config-selector widget feeds the apply/read/write nodes.
|
|
// Otherwise the fixed `instance` param (an id chosen at design time) is used.
|
|
private resolveInstanceId(node: LogicNode): string {
|
|
if ((node.params.instanceSource ?? 'fixed') === 'var') {
|
|
const r = parseRef(node.params.instanceVar ?? '');
|
|
if (!r) return '';
|
|
const v = this.live.get(refKey(r.ds, r.name));
|
|
return v == null ? '' : String(v).trim();
|
|
}
|
|
return (node.params.instance ?? '').trim();
|
|
}
|
|
|
|
/** Activate a panel's logic graph. Replaces any previously loaded graph. */
|
|
load(graph: LogicGraph | undefined): void {
|
|
this.clear();
|
|
this.graph = graph ?? { nodes: [], wires: [] };
|
|
|
|
this.byId = new Map(this.graph.nodes.map(n => [n.id, n]));
|
|
this.out = new Map();
|
|
this.incoming = new Map();
|
|
for (const w of this.graph.wires) {
|
|
(this.out.get(w.from) ?? this.out.set(w.from, []).get(w.from)!)
|
|
.push({ to: w.to, port: w.fromPort ?? 'out' });
|
|
(this.incoming.get(w.to) ?? this.incoming.set(w.to, []).get(w.to)!)
|
|
.push(w.from);
|
|
}
|
|
|
|
this.subscribeRefs();
|
|
|
|
for (const node of this.graph.nodes) {
|
|
switch (node.kind) {
|
|
case 'trigger.timer': {
|
|
const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
|
|
this.cleanups.push(() => clearInterval(id));
|
|
break;
|
|
}
|
|
case 'trigger.loop': {
|
|
this.activate(node.id); // run once on load
|
|
const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
|
|
this.cleanups.push(() => clearInterval(id));
|
|
break;
|
|
}
|
|
// button/threshold/change are driven imperatively / by subscriptions.
|
|
}
|
|
}
|
|
|
|
// Panel-open lifecycle: fire every "On open" trigger once now that the
|
|
// graph is wired and subscriptions are live.
|
|
for (const node of this.graph.nodes) {
|
|
if (node.kind === 'trigger.start') this.activate(node.id);
|
|
}
|
|
}
|
|
|
|
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
|
|
clear(): void {
|
|
// Panel-close lifecycle: fire every "On close" trigger before tearing down,
|
|
// while the live cache and wiring are still intact (synchronous writes run
|
|
// immediately; anything past an await is truncated as the graph resets).
|
|
for (const node of this.graph.nodes) {
|
|
if (node.kind === 'trigger.stop') this.activate(node.id);
|
|
}
|
|
// Dismiss any dialogs left open by the panel being closed (resolve null).
|
|
for (const d of logicDialogs.get()) { try { d.resolve(null); } catch {} }
|
|
logicDialogs.set([]);
|
|
|
|
for (const c of this.cleanups) { try { c(); } catch {} }
|
|
this.cleanups = [];
|
|
this.graph = { nodes: [], wires: [] };
|
|
this.byId = new Map();
|
|
this.out = new Map();
|
|
this.incoming = new Map();
|
|
this.live = new Map();
|
|
this.levelState = new Map();
|
|
this.prevBool = new Map();
|
|
this.prevVal = new Map();
|
|
this.watchers = new Map();
|
|
this.lastFire = new Map();
|
|
this.debugStates = new Map();
|
|
// Restore every widget to its default state so reopening a panel (whose
|
|
// widget ids are stable) does not inherit disabled/hidden/paused flags. The
|
|
// dry-run editor engine must not touch the real widget command stores.
|
|
if (!this.dryRun) resetWidgetCmds();
|
|
}
|
|
|
|
/** Fire every button-trigger node whose `name` param matches. */
|
|
runAction(name: string): void {
|
|
if (!name) return;
|
|
for (const node of this.graph.nodes) {
|
|
if (node.kind === 'trigger.button' && (node.params.name ?? '') === name) {
|
|
this.activate(node.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Subscriptions ──────────────────────────────────────────────────────────
|
|
|
|
// Subscribe to every signal referenced anywhere in the graph (explicit
|
|
// threshold/change signals + inline {ds:name}/local refs in expressions),
|
|
// feeding the live cache and waking threshold/change triggers.
|
|
private subscribeRefs(): void {
|
|
const refs = new Map<string, SignalRef>();
|
|
// {sys:*} signals are served synthetically by sysResolve — never subscribed.
|
|
const want = (r: SignalRef) => { if (r.ds === 'sys') return; if (r.name || r.ds === 'local') refs.set(refKey(r.ds, r.name), r); };
|
|
|
|
for (const node of this.graph.nodes) {
|
|
switch (node.kind) {
|
|
case 'trigger.threshold':
|
|
case 'trigger.change': {
|
|
const r = parseRef(node.params.signal ?? '');
|
|
if (r) {
|
|
want(r);
|
|
const key = refKey(r.ds, r.name);
|
|
(this.watchers.get(key) ?? this.watchers.set(key, []).get(key)!).push(node.id);
|
|
}
|
|
break;
|
|
}
|
|
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.log': collectRefs(node.params.expr ?? '').forEach(want); break;
|
|
case 'action.config.apply':
|
|
case 'action.config.read':
|
|
case 'action.config.write': {
|
|
// Dynamic-instance nodes read their target instance id from a panel
|
|
// var; subscribe it so the live cache holds the selected id.
|
|
if ((node.params.instanceSource ?? 'fixed') === 'var') {
|
|
const r = parseRef(node.params.instanceVar ?? '');
|
|
if (r) want(r);
|
|
}
|
|
if (node.kind === 'action.config.write') collectRefs(node.params.expr ?? '').forEach(want);
|
|
break;
|
|
}
|
|
case 'action.dialog.info':
|
|
case 'action.dialog.error':
|
|
case 'action.dialog.setpoint': {
|
|
this.messageRefs(node.params.message ?? '').forEach(want);
|
|
this.messageRefs(node.params.title ?? '').forEach(want);
|
|
for (const f of this.dialogFieldSpecs(node)) {
|
|
if (f.type === 'display') collectRefs(f.expr ?? '').forEach(want);
|
|
else collectRefs(f.default ?? '').forEach(want);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [key, ref] of refs) {
|
|
const unsub = getSignalStore(ref).subscribe((v: SignalValue) => {
|
|
this.live.set(key, v.value);
|
|
this.onSignal(key, v.value);
|
|
});
|
|
this.cleanups.push(unsub);
|
|
}
|
|
}
|
|
|
|
// Drive threshold (rising edge) and change triggers when a watched signal updates.
|
|
private onSignal(key: string, value: any): void {
|
|
for (const id of this.watchers.get(key) ?? []) {
|
|
const node = this.byId.get(id);
|
|
if (!node) continue;
|
|
if (node.kind === 'trigger.threshold') {
|
|
const cur = this.testThreshold(toNum(value), node.params.op ?? '>', toNum(node.params.value ?? '0'));
|
|
this.levelState.set(id, cur);
|
|
if (cur && !(this.prevBool.get(id) ?? false)) this.activate(id);
|
|
this.prevBool.set(id, cur);
|
|
} else if (node.kind === 'trigger.change') {
|
|
const had = this.prevVal.has(id);
|
|
const prev = this.prevVal.get(id);
|
|
this.prevVal.set(id, value);
|
|
if (had && value !== prev) this.activate(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
private testThreshold(val: number, op: string, cmp: number): boolean {
|
|
if (isNaN(val)) return false;
|
|
switch (op) {
|
|
case '>': return val > cmp;
|
|
case '<': return val < cmp;
|
|
case '>=': return val >= cmp;
|
|
case '<=': return val <= cmp;
|
|
case '==': return val === cmp;
|
|
case '!=': return val !== cmp;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
private intervalOf(node: LogicNode): number {
|
|
return Math.max(50, parseInt(node.params.interval ?? '1000', 10) || 1000);
|
|
}
|
|
|
|
// ── Execution ───────────────────────────────────────────────────────────────
|
|
|
|
/** Manually fire a trigger node (debug mode: double-click to force a flow
|
|
* run). No-op if the id isn't a trigger node. */
|
|
fireTrigger(nodeId: string): boolean {
|
|
const node = this.byId.get(nodeId);
|
|
if (!node || !node.kind.startsWith('trigger.')) return false;
|
|
this.activate(nodeId);
|
|
return true;
|
|
}
|
|
|
|
// A trigger activated: walk its outgoing 'out' wires.
|
|
private activate(triggerId: string): void {
|
|
const now = Date.now();
|
|
const last = this.lastFire.get(triggerId);
|
|
const dt = last === undefined ? 0 : (now - last) / 1000;
|
|
this.lastFire.set(triggerId, now);
|
|
this.markActive(triggerId);
|
|
const ctx: RunCtx = {
|
|
firedTrigger: triggerId,
|
|
steps: { n: 0 },
|
|
dt,
|
|
resolve: (ds, name) => this.sysResolve(ds, name, dt),
|
|
};
|
|
void this.follow(triggerId, 'out', ctx);
|
|
}
|
|
|
|
// Run every wire leaving `fromId` on output port `port`.
|
|
private async follow(fromId: string, port: string, ctx: RunCtx): Promise<void> {
|
|
for (const w of this.out.get(fromId) ?? []) {
|
|
if (w.port === port) await this.run(w.to, ctx);
|
|
}
|
|
}
|
|
|
|
// Execute one node, then continue downstream as appropriate.
|
|
private async run(nodeId: string, ctx: RunCtx): Promise<void> {
|
|
if (ctx.steps.n++ > MAX_STEPS) return;
|
|
const node = this.byId.get(nodeId);
|
|
if (!node) return;
|
|
this.markActive(node.id);
|
|
|
|
switch (node.kind) {
|
|
case 'gate.and': {
|
|
const ok = this.gateSatisfied(node.id, ctx.firedTrigger);
|
|
this.markActive(node.id, ok);
|
|
if (ok) await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'flow.if': {
|
|
const pass = evalBool(node.params.cond ?? '', ctx.resolve);
|
|
this.markActive(node.id, pass);
|
|
await this.follow(node.id, pass ? 'then' : 'else', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'flow.loop': {
|
|
const mode = node.params.mode ?? 'count';
|
|
if (mode === 'count') {
|
|
const n = Math.min(MAX_LOOP, Math.max(0, Math.floor(toNum(node.params.count ?? '0')) || 0));
|
|
for (let i = 0; i < n && ctx.steps.n <= MAX_STEPS; i++) await this.follow(node.id, 'body', ctx);
|
|
} else {
|
|
let i = 0;
|
|
while (i < MAX_LOOP && ctx.steps.n <= MAX_STEPS && evalBool(node.params.cond ?? '', ctx.resolve)) {
|
|
await this.follow(node.id, 'body', ctx);
|
|
i++;
|
|
}
|
|
}
|
|
await this.follow(node.id, 'done', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.write': {
|
|
const ref = parseRef(node.params.target ?? '');
|
|
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
|
this.markActive(node.id, val);
|
|
if (ref && !isNaN(val)) this.dryWrite(ref, val);
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.config.apply': {
|
|
const id = this.resolveInstanceId(node);
|
|
if (id && !this.dryRun) {
|
|
try {
|
|
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
|
|
} catch {}
|
|
}
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.config.read': {
|
|
const id = this.resolveInstanceId(node);
|
|
const key = (node.params.key ?? '').trim();
|
|
const ref = parseRef(node.params.target ?? '');
|
|
if (id && key && ref && !this.dryRun) {
|
|
const val = await readConfigParam(id, key);
|
|
if (val !== null && !isNaN(val)) this.dryWrite(ref, val);
|
|
}
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.config.write': {
|
|
const id = this.resolveInstanceId(node);
|
|
const key = (node.params.key ?? '').trim();
|
|
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
|
if (id && key && !isNaN(val) && !this.dryRun) {
|
|
await writeConfigParam(id, key, val);
|
|
}
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.config.create': {
|
|
const setId = (node.params.set ?? '').trim();
|
|
const name = (node.params.name ?? '').trim() || 'auto';
|
|
const fromId = (node.params.from ?? '').trim();
|
|
const target = parseRef(node.params.target ?? '');
|
|
if (setId && !this.dryRun) {
|
|
const newId = await createConfigInstance(setId, name, fromId);
|
|
if (newId && target) this.dryWrite(target, newId);
|
|
}
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.config.snapshot': {
|
|
const setId = (node.params.set ?? '').trim();
|
|
const name = (node.params.name ?? '').trim();
|
|
const target = parseRef(node.params.target ?? '');
|
|
if (setId && !this.dryRun) {
|
|
const newId = await snapshotConfigSet(setId, name);
|
|
if (newId && target) this.dryWrite(target, newId);
|
|
}
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.delay':
|
|
await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0));
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
|
|
// ── Array action nodes ──────────────────────────────────────────────────
|
|
|
|
case 'action.array.push': {
|
|
const name = (node.params.array ?? '').trim();
|
|
if (name) {
|
|
const val = evalValue(node.params.expr ?? '', ctx.resolve);
|
|
this.markActive(node.id, 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;
|
|
}
|
|
|
|
// ── Legacy array nodes (aliased to new implementations) ─────────────────
|
|
|
|
case 'action.accumulate': {
|
|
// Legacy alias → action.array.push (backs into array locals).
|
|
const name = (node.params.array ?? '').trim();
|
|
const val = evalValue(node.params.expr ?? '', ctx.resolve);
|
|
this.markActive(node.id, val);
|
|
if (name) writeLocalState(name, [...curArray(name), val]);
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.export': {
|
|
if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.filename ?? '');
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.clear': {
|
|
// Legacy alias → action.array.clear (backed by array locals).
|
|
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.log': {
|
|
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
|
const label = (node.params.label ?? '').trim();
|
|
this.markActive(node.id, val);
|
|
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.widget': {
|
|
const wid = (node.params.widget ?? '').trim();
|
|
if (wid && !this.dryRun) {
|
|
switch (node.params.op ?? '') {
|
|
case 'enable': setWidgetDisabled(wid, false); break;
|
|
case 'disable': setWidgetDisabled(wid, true); break;
|
|
case 'show': setWidgetHidden(wid, false); break;
|
|
case 'hide': setWidgetHidden(wid, true); break;
|
|
case 'pauseplot': setPlotPaused(wid, true); break;
|
|
case 'resumeplot': setPlotPaused(wid, false); break;
|
|
case 'clearplot': clearPlot(wid); break;
|
|
}
|
|
}
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.dialog.info':
|
|
case 'action.dialog.error': {
|
|
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
|
|
const specs = this.dialogFieldSpecs(node);
|
|
await this.showDialog({
|
|
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
|
|
title: this.interpolate(node.params.title ?? '', ctx.resolve),
|
|
message: this.interpolate(node.params.message ?? '', ctx.resolve),
|
|
fields: this.dialogFieldViews(specs, ctx),
|
|
});
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
case 'action.dialog.setpoint': {
|
|
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
|
|
const specs = this.dialogFieldSpecs(node);
|
|
const result = await this.showDialog({
|
|
kind: 'setpoint',
|
|
title: this.interpolate(node.params.title ?? '', ctx.resolve),
|
|
message: this.interpolate(node.params.message ?? '', ctx.resolve),
|
|
fields: this.dialogFieldViews(specs, ctx),
|
|
});
|
|
// Cancel (null) aborts the flow; OK writes every input value and continues.
|
|
if (result === null) return;
|
|
let i = 0;
|
|
for (const spec of specs) {
|
|
if (spec.type !== 'input') continue;
|
|
const ref = parseRef(spec.target ?? '');
|
|
const num = parseFloat(result[i++] ?? '');
|
|
if (ref && !isNaN(num)) wsClient.write(ref, num);
|
|
}
|
|
await this.follow(node.id, 'out', ctx);
|
|
return;
|
|
}
|
|
|
|
default:
|
|
// A trigger reached mid-walk (unusual): just pass through.
|
|
await this.follow(node.id, 'out', ctx);
|
|
}
|
|
}
|
|
|
|
// 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`.
|
|
private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
|
|
const raw = (node.params.columns ?? '').trim();
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed
|
|
.map((c: any) => ({ array: String(c?.array ?? '').trim(), label: String(c?.label ?? '').trim() }))
|
|
.filter(c => c.array);
|
|
}
|
|
} catch {}
|
|
}
|
|
const a = (node.params.array ?? '').trim();
|
|
return a ? [{ array: a, label: a }] : [];
|
|
}
|
|
|
|
// 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 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(','));
|
|
}
|
|
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' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = fname;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ── Dialogs (user interaction) ───────────────────────────────────────────────
|
|
|
|
// The field specs of a dialog node. Reads the JSON `fields` param (a list of
|
|
// { type:'input'|'display', label, target?, default?, expr? }); for a legacy
|
|
// set-point node with no `fields` it falls back to the single target/default.
|
|
private dialogFieldSpecs(node: LogicNode): DialogFieldSpec[] {
|
|
const raw = (node.params.fields ?? '').trim();
|
|
if (raw) {
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.map((f: any) => ({
|
|
type: f?.type === 'display' ? 'display' : 'input',
|
|
label: String(f?.label ?? ''),
|
|
target: String(f?.target ?? ''),
|
|
default: String(f?.default ?? ''),
|
|
expr: String(f?.expr ?? ''),
|
|
}));
|
|
}
|
|
} catch {}
|
|
}
|
|
if (node.kind === 'action.dialog.setpoint') {
|
|
return [{ type: 'input', label: '', target: node.params.target ?? '', default: node.params.default ?? '', expr: '' }];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Resolve a node's field specs into displayable views: display fields get their
|
|
// expression evaluated to text; input fields get their default expression
|
|
// evaluated to a pre-fill string ('' when no default is set).
|
|
private dialogFieldViews(specs: DialogFieldSpec[], ctx: RunCtx): DialogField[] {
|
|
return specs.map(s => {
|
|
if (s.type === 'display') {
|
|
const v = evalExpr(s.expr ?? '', ctx.resolve);
|
|
return { type: 'display' as const, label: s.label, value: isNaN(v) ? '?' : String(v) };
|
|
}
|
|
const def = (s.default ?? '').trim() ? String(evalExpr(s.default ?? '', ctx.resolve)) : '';
|
|
return { type: 'input' as const, label: s.label, value: def };
|
|
});
|
|
}
|
|
|
|
// Push a dialog request to the shared store and resolve when the user responds
|
|
// (the entered input values on OK, null on Cancel).
|
|
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string[] | null> {
|
|
return new Promise((resolve) => {
|
|
const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
|
|
const full: DialogRequest = {
|
|
...req,
|
|
id,
|
|
resolve: (r) => { logicDialogs.update(list => list.filter(d => d.id !== id)); resolve(r); },
|
|
};
|
|
logicDialogs.update(list => [...list, full]);
|
|
});
|
|
}
|
|
|
|
// Substitute {ds:name} (and {sys:*}) tokens in dialog text with live values.
|
|
private interpolate(text: string, resolve: Resolver): string {
|
|
return (text ?? '').replace(/\{([^}]*)\}/g, (_m, inner) => {
|
|
const v = evalExpr('{' + inner + '}', resolve);
|
|
return isNaN(v) ? '?' : String(v);
|
|
});
|
|
}
|
|
|
|
// Signals referenced by {…} tokens in dialog message/title text.
|
|
private messageRefs(text: string): SignalRef[] {
|
|
const out: SignalRef[] = [];
|
|
for (const m of (text ?? '').matchAll(/\{([^}]*)\}/g)) {
|
|
const r = parseRef(m[1]);
|
|
if (r) out.push(r);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// An AND-gate fires when every incoming trigger is currently satisfied: the
|
|
// activating trigger counts as satisfied, and level triggers (threshold) use
|
|
// their current truth. Momentary triggers that are not firing now are false.
|
|
private gateSatisfied(gateId: string, firedTrigger: string): boolean {
|
|
const inputs = this.incoming.get(gateId) ?? [];
|
|
if (inputs.length === 0) return false;
|
|
return inputs.every(src => src === firedTrigger || this.levelState.get(src) === true);
|
|
}
|
|
}
|
|
|
|
// 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 === 'action.accumulate' ||
|
|
n.kind === 'action.clear' ||
|
|
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 {}
|
|
// Also handle legacy single `array` param.
|
|
const a = (n.params.array ?? '').trim();
|
|
if (a) need(a);
|
|
}
|
|
}
|
|
return out;
|
|
}
|