Major changes: logic add to panel, local variables, panel histor, users management...

This commit is contained in:
Martino Ferrari
2026-06-18 17:37:04 +02:00
parent 71430bc3b0
commit aba394b84d
54 changed files with 6104 additions and 1166 deletions
+375
View File
@@ -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();