Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
// 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 type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
|
||||
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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[]>();
|
||||
// Named in-memory data arrays, filled by action.accumulate and dumped by
|
||||
// action.export. Each sample keeps the wall-clock time it was recorded.
|
||||
private arrays = new Map<string, Array<{ t: number; v: number }>>();
|
||||
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
|
||||
private lastFire = new Map<string, number>();
|
||||
private cleanups: Array<() => void> = [];
|
||||
|
||||
// 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)));
|
||||
}
|
||||
|
||||
/** 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
|
||||
clear(): void {
|
||||
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.arrays = new Map();
|
||||
this.lastFire = new Map();
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// 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);
|
||||
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;
|
||||
|
||||
switch (node.kind) {
|
||||
case 'gate.and':
|
||||
if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
|
||||
case 'flow.if': {
|
||||
const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else';
|
||||
await this.follow(node.id, branch, 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);
|
||||
if (ref && !isNaN(val)) wsClient.write(ref, val);
|
||||
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;
|
||||
|
||||
case 'action.accumulate': {
|
||||
const name = (node.params.array ?? '').trim();
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
if (name && !isNaN(val)) {
|
||||
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
|
||||
arr.push({ t: Date.now(), v: val });
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.export': {
|
||||
this.exportArray((node.params.array ?? '').trim(), node.params.filename ?? '');
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.clear': {
|
||||
const name = (node.params.array ?? '').trim();
|
||||
if (name) this.arrays.delete(name);
|
||||
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();
|
||||
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Dump a named array to a CSV file the browser downloads (timestamp + value).
|
||||
private exportArray(name: string, filename: string): void {
|
||||
if (!name) return;
|
||||
const arr = this.arrays.get(name) ?? [];
|
||||
const rows = arr.map(p => `${p.t},${new Date(p.t).toISOString()},${p.v}`);
|
||||
const csv = 'timestamp_ms,iso,value\n' + rows.join('\n') + '\n';
|
||||
const safe = (filename || name).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);
|
||||
}
|
||||
|
||||
// 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();
|
||||
Reference in New Issue
Block a user