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:
parent
a1ac646bb9
commit
c81ce33a4b
3 changed files with 316 additions and 0 deletions
153
src/live-values-pump.ts
Normal file
153
src/live-values-pump.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
tests/unit/live-values-pump.test.ts
Normal file
106
tests/unit/live-values-pump.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
tests/unit/var-block-parse.test.ts
Normal file
57
tests/unit/var-block-parse.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue