// 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 . Cleared when the panel unmounts.
export const logicDialogs = writable([]);
// 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 {
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 {
try {
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
if (!ir.ok) return null;
const inst: { setId: string; setVersion?: number; values?: Record } = 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 {
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 {
try {
let values: Record = {};
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 {
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();
// Outgoing wires grouped by source node id.
private out = new Map();
// Incoming source node ids grouped by target node id (for gate evaluation).
private incoming = new Map();
// Live signal value cache (key "ds\0name"), kept fresh by subscriptions.
private live = new Map();
// Current truth of each level-style trigger (threshold), for AND-gates.
private levelState = new Map();
// Per-node previous values for edge/change detection.
private prevBool = new Map();
private prevVal = new Map();
// signal key → trigger node ids that react to it (threshold / change).
private watchers = new Map();
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
private lastFire = new Map();
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();
/** 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 {
const now = Date.now();
const out = new Map();
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();
// {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 {
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 {
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): Promise {
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;
}