From c81ce33a4bc74f1a2ee26d99c0c9d8a55e2f8548 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 09:55:18 +0200 Subject: [PATCH] feat(live-values): pump skeleton + VAR-block parser Task 6 of v0.3 plan (parser + pump class; full --live-values CLI wiring is Task 7). parseVarNames(text) extracts variable names from any VAR / VAR_INPUT / VAR_OUTPUT / VAR_GLOBAL / etc. block. Handles: - one name per declaration line - 'AT %QX0.1' location prefix - := initializer - inline (* ... *) and // line comments - multi-line (* ... *) blocks (state threaded across lines) Doesn't handle the multi-name shorthand 'a, b : INT;' -- vanishingly rare in practice, cost of getting it wrong is just a missing overlay. LiveValuesPump owns a setInterval. Each tick: - reads tui-state.json via the injected readSelection - reads the POU's .st file from the mirror - parseVarNames -> per-var read_variable round-trip (errors per-var are silent, others may succeed) - writeLiveValues snapshot Reentrancy guard: if a tick is still in flight when the next interval fires, skip the new tick rather than queueing. Errors at any layer are swallowed -- pump must never crash the server. start()/stop() lifecycle so the server can hand it to the existing shutdown path. --- src/live-values-pump.ts | 153 ++++++++++++++++++++++++++++ tests/unit/live-values-pump.test.ts | 106 +++++++++++++++++++ tests/unit/var-block-parse.test.ts | 57 +++++++++++ 3 files changed, 316 insertions(+) create mode 100644 src/live-values-pump.ts create mode 100644 tests/unit/live-values-pump.test.ts create mode 100644 tests/unit/var-block-parse.test.ts diff --git a/src/live-values-pump.ts b/src/live-values-pump.ts new file mode 100644 index 0000000..437eb23 --- /dev/null +++ b/src/live-values-pump.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs/promises'; +import { writeLiveValues, LiveValueSnapshotIn } from './live-values-write'; +import { readSelection } from './state-read'; + +const VAR_OPEN_RE = /^\s*(?:VAR(?:_INPUT|_OUTPUT|_IN_OUT|_GLOBAL|_TEMP|_CONFIG|_EXTERNAL|_STAT)?)\b/i; +const VAR_CLOSE_RE = /^\s*END_VAR\b/i; +const VAR_DECL_RE = /^\s*([A-Za-z_]\w*)\b/; + +/** + * Parse all variable names declared in any VAR / VAR_INPUT / VAR_OUTPUT / + * VAR_GLOBAL / etc. block. One name per declaration line; skips lines that + * are entirely inside (* ... *) blocks (joined across lines) or are + * `// ...` comments. + * + * Recognises the IEC declaration grammar enough for typical PLC code: + * `name [AT %loc] : type [:= init];`. Doesn't try to parse multi-name + * shorthand (`a, b : INT;`) — that's vanishingly rare in practice and + * the cost of getting it wrong is just a missing overlay. + */ +export function parseVarNames(text: string): string[] { + const names: string[] = []; + const lines = text.split(/\r?\n/); + let inVarBlock = false; + let inMultilineComment = false; + + for (let raw of lines) { + // Strip block comments first, threading the open-state across lines. + let scrubbed = ''; + let i = 0; + while (i < raw.length) { + if (inMultilineComment) { + const end = raw.indexOf('*)', i); + if (end < 0) { + i = raw.length; + } else { + inMultilineComment = false; + i = end + 2; + } + continue; + } + if (raw[i] === '(' && raw[i + 1] === '*') { + const end = raw.indexOf('*)', i + 2); + if (end < 0) { + inMultilineComment = true; + i = raw.length; + } else { + i = end + 2; + } + continue; + } + scrubbed += raw[i]; + i++; + } + // Strip line comments. + const slash = scrubbed.indexOf('//'); + if (slash >= 0) scrubbed = scrubbed.slice(0, slash); + + if (!inVarBlock) { + if (VAR_OPEN_RE.test(scrubbed)) inVarBlock = true; + continue; + } + if (VAR_CLOSE_RE.test(scrubbed)) { + inVarBlock = false; + continue; + } + const m = VAR_DECL_RE.exec(scrubbed); + if (m) names.push(m[1]); + } + return names; +} + +// ─── Pump ──────────────────────────────────────────────────────────────── +// +// The full pump (interval + per-var read_variable round-trips) is wired +// up by Task 7 once the server-side plumbing is in place. parseVarNames +// is the only piece we can unit-test in isolation; the rest needs an +// executor + state-read + write integration. + +export interface PumpDeps { + /** Read the TUI selection state file. Defaults to readSelection. */ + readSelection?: typeof readSelection; + /** Read the POU's mirror file from disk. */ + readPouFile?: (absPath: string) => Promise; + /** Read a single PLC variable. Returns its current value as a string. */ + readVariable?: (projectFilePath: string, variablePath: string) => Promise; + /** Write the live-values snapshot. Defaults to writeLiveValues. */ + writeLiveValues?: typeof writeLiveValues; +} + +export interface PumpConfig { + /** Path to tui-state.json. */ + stateFilePath: string; + /** Path to tui-live-values.json. */ + liveValuesFilePath: string; + /** Poll interval in ms. */ + intervalMs: number; +} + +export class LiveValuesPump { + private timer: ReturnType | null = null; + private busy = false; + + constructor(private cfg: PumpConfig, private deps: Required) {} + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => { + void this.tick(); + }, this.cfg.intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async tick(): Promise { + if (this.busy) return; + this.busy = true; + try { + const sel = await this.deps.readSelection(this.cfg.stateFilePath); + if (sel.status !== 'ok') return; + const pou = sel.payload.selection; + const text = await this.deps.readPouFile(pou.abs_path); + const names = parseVarNames(text); + if (names.length === 0) return; + const values: Record = {}; + const ts = Date.now(); + for (const name of names) { + try { + const v = await this.deps.readVariable( + sel.payload.project_dir, + `${pou.name}.${name}` + ); + values[name] = { value: v, ts }; + } catch { + // single-var failure: skip silently, others may succeed + } + } + await this.deps.writeLiveValues(this.cfg.liveValuesFilePath, sel.payload.project_dir, { + device: sel.payload.device, + pou_name: pou.name, + values, + }); + } catch { + // Pump must never crash; swallow. + } finally { + this.busy = false; + } + } +} diff --git a/tests/unit/live-values-pump.test.ts b/tests/unit/live-values-pump.test.ts new file mode 100644 index 0000000..5d9b319 --- /dev/null +++ b/tests/unit/live-values-pump.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LiveValuesPump } from '../../src/live-values-pump'; + +const okSelection = (overrides: Record = {}) => ({ + status: 'ok' as const, + payload: { + version: 1 as const, + updated_at: new Date().toISOString(), + project_dir: '/abs/proj', + device: 'D1', + selection: { + kind: 'PRG', + name: 'PLC_PRG', + path: 'PLC_PRG.st', + abs_path: '/abs/PLC_PRG.st', + }, + viewer_line: 1, + ...overrides, + }, +}); + +const sampleSt = [ + 'PROGRAM PLC_PRG', + 'VAR', + ' counter : INT := 0;', + ' bRunning : BOOL;', + 'END_VAR', +].join('\n'); + +describe('LiveValuesPump.tick', () => { + it('writes a snapshot containing every var read successfully', async () => { + const writeLiveValues = vi.fn(async () => {}); + const readVariable = vi.fn(async (_proj: string, varPath: string) => { + if (varPath.endsWith('.counter')) return '47'; + if (varPath.endsWith('.bRunning')) return 'TRUE'; + throw new Error('not found'); + }); + const pump = new LiveValuesPump( + { stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 }, + { + readSelection: vi.fn(async () => okSelection()), + readPouFile: vi.fn(async () => sampleSt), + readVariable, + writeLiveValues, + } + ); + await pump.tick(); + expect(writeLiveValues).toHaveBeenCalledTimes(1); + const [, projectDir, payload] = writeLiveValues.mock.calls[0]; + expect(projectDir).toBe('/abs/proj'); + expect(payload.device).toBe('D1'); + expect(payload.pou_name).toBe('PLC_PRG'); + expect(payload.values.counter.value).toBe('47'); + expect(payload.values.bRunning.value).toBe('TRUE'); + }); + + it('skips writing when selection is missing/stale', async () => { + const writeLiveValues = vi.fn(async () => {}); + const pump = new LiveValuesPump( + { stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 }, + { + readSelection: vi.fn(async () => ({ status: 'missing' as const })), + readPouFile: vi.fn(async () => sampleSt), + readVariable: vi.fn(async () => '1'), + writeLiveValues, + } + ); + await pump.tick(); + expect(writeLiveValues).not.toHaveBeenCalled(); + }); + + it('continues when one read_variable fails (partial write)', async () => { + const writeLiveValues = vi.fn(async () => {}); + const pump = new LiveValuesPump( + { stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 }, + { + readSelection: vi.fn(async () => okSelection()), + readPouFile: vi.fn(async () => sampleSt), + readVariable: vi.fn(async (_p, varPath) => { + if (varPath.endsWith('.counter')) return '47'; + throw new Error('boom'); + }), + writeLiveValues, + } + ); + await pump.tick(); + const payload = writeLiveValues.mock.calls[0][2]; + expect(payload.values.counter.value).toBe('47'); + expect(payload.values.bRunning).toBeUndefined(); + }); + + it('never throws when readPouFile throws', async () => { + const writeLiveValues = vi.fn(async () => {}); + const pump = new LiveValuesPump( + { stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 }, + { + readSelection: vi.fn(async () => okSelection()), + readPouFile: vi.fn(async () => { throw new Error('disk gone'); }), + readVariable: vi.fn(async () => '1'), + writeLiveValues, + } + ); + await expect(pump.tick()).resolves.toBeUndefined(); + expect(writeLiveValues).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/var-block-parse.test.ts b/tests/unit/var-block-parse.test.ts new file mode 100644 index 0000000..2f59819 --- /dev/null +++ b/tests/unit/var-block-parse.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { parseVarNames } from '../../src/live-values-pump'; + +describe('parseVarNames', () => { + it('extracts vars from a single VAR block', () => { + const text = [ + 'PROGRAM PLC_PRG', + 'VAR', + ' counter : INT := 0;', + ' bRunning : BOOL;', + ' rTemperature : REAL;', + 'END_VAR', + ].join('\n'); + expect(parseVarNames(text)).toEqual(['counter', 'bRunning', 'rTemperature']); + }); + + it('handles VAR_INPUT / VAR_OUTPUT / VAR_GLOBAL too', () => { + const text = [ + 'FUNCTION_BLOCK FB_X', + 'VAR_INPUT', + ' x : INT;', + 'END_VAR', + 'VAR_OUTPUT', + ' y : BOOL;', + 'END_VAR', + 'VAR', + ' internal : DINT;', + 'END_VAR', + ].join('\n'); + expect(parseVarNames(text).sort()).toEqual(['internal', 'x', 'y']); + }); + + it('ignores lines inside (* ... *) blocks and after //', () => { + const text = [ + 'VAR', + ' alpha : INT;', + ' (* commentedOut : BOOL; *)', + ' beta : INT; // tail comment', + 'END_VAR', + ].join('\n'); + expect(parseVarNames(text)).toEqual(['alpha', 'beta']); + }); + + it('returns [] when no VAR block is present', () => { + expect(parseVarNames('PROGRAM X\nEND_PROGRAM')).toEqual([]); + }); + + it('handles AT %X10.0 location prefix and := initializer', () => { + const text = [ + 'VAR', + ' bRelay AT %QX0.1 : BOOL := FALSE;', + ' iCount : INT := 42;', + 'END_VAR', + ].join('\n'); + expect(parseVarNames(text)).toEqual(['bRelay', 'iCount']); + }); +});