From 8f40b5539aa2ae390edbf59ae402967543ae1fc6 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 13:43:09 +0200 Subject: [PATCH] feat(live-values): --live-values-interval CLI flag Replaces the hardcoded 500ms poll with a configurable interval. Clamped to [100, 60000] -- below 100 the read_variable IPC round- trip-per-var dominates and the pump can't keep up; above 60000 a session shorter than the interval would never see any update. ServerConfig.liveValuesIntervalMs?: number plumbs through bin.ts (parseInt + clamp) into the LiveValuesPump constructor and the 'Live-values pump started (ms)' log line. Default unchanged at 500ms, so existing --live-values invocations behave identically. --- src/bin.ts | 16 ++++++++++-- src/server.ts | 4 +-- src/types.ts | 7 ++++- tests/unit/clamp-interval.test.ts | 43 +++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/unit/clamp-interval.test.ts diff --git a/src/bin.ts b/src/bin.ts index a71bdf1..c39b8e7 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -19,6 +19,16 @@ try { // ignore } +const LIVE_VALUES_INTERVAL_MIN_MS = 100; +const LIVE_VALUES_INTERVAL_MAX_MS = 60_000; +const LIVE_VALUES_INTERVAL_DEFAULT_MS = 500; + +function clampInterval(raw: string | undefined): number { + const parsed = parseInt(raw ?? String(LIVE_VALUES_INTERVAL_DEFAULT_MS), 10); + if (!Number.isFinite(parsed)) return LIVE_VALUES_INTERVAL_DEFAULT_MS; + return Math.max(LIVE_VALUES_INTERVAL_MIN_MS, Math.min(parsed, LIVE_VALUES_INTERVAL_MAX_MS)); +} + program .name('codesys-mcp-sp21-plus') .description('MCP server for CODESYS with persistent UI instance') @@ -48,7 +58,8 @@ program .option('--keep-alive', 'Keep CODESYS running after server stops', false) .option('--auto-mirror', 'Re-run mirror_export after every modifying tool so an external editor watching /mcp-mirror/ sees changes live', false) .option('--approve-edits', 'Gate modifying MCP tools behind a phobiCS-tui y/n diff prompt', false) - .option('--live-values', 'Pump runtime values for the selected POU into tui-live-values.json so phobiCS-tui can overlay them inline (500ms poll). Requires the runtime to be online; failures are silent.', false) + .option('--live-values', 'Pump runtime values for the selected POU into tui-live-values.json so phobiCS-tui can overlay them inline. Requires the runtime to be online; failures are silent.', false) + .option('--live-values-interval ', 'Poll interval for --live-values in ms. Default 500. Clamped to [100, 60000].', '500') .option('--timeout ', 'Default command timeout in ms', '60000') .option('--verbose', 'Enable verbose logging') .option('--debug', 'Enable debug logging (more verbose)') @@ -201,6 +212,7 @@ if (opts.sshVersion) { autoMirror: opts.autoMirror || false, approveEdits: opts.approveEdits || false, liveValues: opts.liveValues || false, + liveValuesIntervalMs: clampInterval(opts.liveValuesInterval), }; process.stderr.write(`Starting CODESYS MCP Server v${version}\n`); @@ -215,7 +227,7 @@ if (opts.sshVersion) { process.stderr.write(` Approve edits: ENABLED (modifying tools will prompt via phobiCS-tui)\n`); } if (config.liveValues) { - process.stderr.write(` Live values: ENABLED (poll 500ms; writes tui-live-values.json)\n`); + process.stderr.write(` Live values: ENABLED (poll ${config.liveValuesIntervalMs ?? 500}ms; writes tui-live-values.json)\n`); } startMcpServer(config).catch((err) => { diff --git a/src/server.ts b/src/server.ts index 9156c7f..bda70fc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3200,7 +3200,7 @@ export async function startMcpServer(config: ServerConfig): Promise { { stateFilePath: defaultStateFilePath(), liveValuesFilePath: defaultLiveValuesFilePath(), - intervalMs: 500, + intervalMs: config.liveValuesIntervalMs ?? 500, }, { readSelection, @@ -3225,7 +3225,7 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); liveValuesPump.start(); - serverLog.info('Live-values pump started (500ms)'); + serverLog.info(`Live-values pump started (${config.liveValuesIntervalMs ?? 500}ms)`); } // ─── Graceful Shutdown ─────────────────────────────────────────────── diff --git a/src/types.ts b/src/types.ts index f7fec8a..8c3cf10 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,9 +77,14 @@ export interface ServerConfig extends LauncherConfig { * If true, run a background pump that reads runtime values for the * variables of the user's currently-selected POU and writes them to * tui-live-values.json so the TUI Viewer can overlay them inline. - * Off by default. 500 ms interval (fixed in v0.3). + * Off by default. */ liveValues?: boolean; + /** + * Poll interval (ms) for the live-values pump. Default 500. + * Clamped to [100, 60000]; values outside the range are coerced. + */ + liveValuesIntervalMs?: number; } /** Script template parameters */ diff --git a/tests/unit/clamp-interval.test.ts b/tests/unit/clamp-interval.test.ts new file mode 100644 index 0000000..f932172 --- /dev/null +++ b/tests/unit/clamp-interval.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; + +// The clamp helper isn't exported (it's local to bin.ts which is the entry +// point), but its behaviour is the contract this test pins down: the +// production code's clamp must match these expectations or live-values +// poll cadence is wrong. + +const MIN = 100; +const MAX = 60_000; +const DEFAULT = 500; + +function clampInterval(raw: string | undefined): number { + const parsed = parseInt(raw ?? String(DEFAULT), 10); + if (!Number.isFinite(parsed)) return DEFAULT; + return Math.max(MIN, Math.min(parsed, MAX)); +} + +describe('clampInterval', () => { + it('returns default when raw is undefined', () => { + expect(clampInterval(undefined)).toBe(DEFAULT); + }); + + it('returns default when raw is unparseable', () => { + expect(clampInterval('not-a-number')).toBe(DEFAULT); + }); + + it('clamps below the minimum', () => { + expect(clampInterval('50')).toBe(MIN); + expect(clampInterval('0')).toBe(MIN); + expect(clampInterval('-100')).toBe(MIN); + }); + + it('clamps above the maximum', () => { + expect(clampInterval('1000000')).toBe(MAX); + }); + + it('passes through values inside the range', () => { + expect(clampInterval('100')).toBe(100); + expect(clampInterval('500')).toBe(500); + expect(clampInterval('1000')).toBe(1000); + expect(clampInterval('60000')).toBe(60_000); + }); +});