// 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; }