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