0
0
Fork 0

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.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-29 09:55:18 +02:00
parent a1ac646bb9
commit c81ce33a4b
3 changed files with 316 additions and 0 deletions

153
src/live-values-pump.ts Normal file
View file

@ -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<string>;
/** Read a single PLC variable. Returns its current value as a string. */
readVariable?: (projectFilePath: string, variablePath: string) => Promise<string>;
/** 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<typeof setInterval> | null = null;
private busy = false;
constructor(private cfg: PumpConfig, private deps: Required<PumpDeps>) {}
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<void> {
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<string, LiveValueSnapshotIn> = {};
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;
}
}
}

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest';
import { LiveValuesPump } from '../../src/live-values-pump';
const okSelection = (overrides: Record<string, unknown> = {}) => ({
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();
});
});

View file

@ -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']);
});
});