@aryacode/dsh-engineering 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Validation for one-shot controller transactions.
3
+ *
4
+ * Nothing here opens a socket. Every argument is resolved against the open
5
+ * export first, so a mistyped tag, an alias, an array or a type-mismatched write
6
+ * is refused before the controller is contacted.
7
+ *
8
+ * @module dsh-engineering-l5x/engine/cip-request
9
+ */
10
+ import { isIPv4 } from 'node:net';
11
+ import { EngineeringError } from "../session.js";
12
+ import { aliasesOf } from "./configuration.js";
13
+ import { findReferences } from "./analysis.js";
14
+ import { controllerElementOf } from "../model/parse.js";
15
+ import { elementChildren, firstChild } from "../model/xml.js";
16
+ /** Scalar types this surface can read and write. */
17
+ const SCALARS = ['BOOL', 'INT', 'DINT', 'REAL'];
18
+ /** Integer ranges enforced before a write leaves the machine. */
19
+ const RANGES = { INT: [-32768, 32767], DINT: [-2147483648, 2147483647] };
20
+ const isScalar = (value) => typeof value === 'string' && SCALARS.includes(value);
21
+ /**
22
+ * Resolve the controller address and slot both read and write share.
23
+ * @param input - Tool arguments.
24
+ * @param config - Resolved deployment limits.
25
+ * @returns The validated target.
26
+ */
27
+ export function controllerTarget(input, config) {
28
+ if (!config.enabled)
29
+ throw new EngineeringError('DIRECT_POLLING_DISABLED', 'Enable directPollingEnabled to use SDK-independent reads');
30
+ const ip = input.ip_address;
31
+ if (typeof ip !== 'string' || !isIPv4(ip) || (config.allowedAddresses.length > 0 && !config.allowedAddresses.includes(ip)))
32
+ throw new EngineeringError('INVALID_CONTROLLER', 'Select an explicit IPv4 address allowed by this profile');
33
+ const slot = input.slot ?? 0;
34
+ if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 0 || slot > 31)
35
+ throw new EngineeringError('INVALID_POLL', 'slot must be an integer between 0 and 31');
36
+ return { ip, slot, timeout: config.requestTimeoutMs };
37
+ }
38
+ /**
39
+ * Resolve declared scalar tags for a batch read, then bound the batch.
40
+ * @param project - Fresh export snapshot.
41
+ * @param input - Tool arguments carrying `tags`.
42
+ * @param config - Resolved deployment limits.
43
+ * @param maxBatch - Largest accepted batch.
44
+ * @returns Worker request and operand evidence.
45
+ */
46
+ export function batchReadRequest(project, input, config, maxBatch) {
47
+ const target = controllerTarget(input, config);
48
+ const raw = typeof input.tags_json === 'string' ? JSON.parse(input.tags_json) : null;
49
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > maxBatch)
50
+ throw new EngineeringError('INVALID_POLL', `tags must contain 1 through ${String(maxBatch)} declared scalar tags`);
51
+ const tags = raw.map((entry) => resolveScalar(project, entry));
52
+ const seen = new Set();
53
+ for (const tag of tags) {
54
+ const key = `${tag.program ?? 'controller'}::${tag.name}`.toLowerCase();
55
+ if (seen.has(key))
56
+ throw new EngineeringError('INVALID_POLL', 'Duplicate tag in the batch');
57
+ seen.add(key);
58
+ }
59
+ return {
60
+ request: { op: 'read', ...target, tags },
61
+ evidence: tags.map(tag => ({
62
+ tag: tag.name,
63
+ scope: tag.program ?? 'controller',
64
+ references: findReferences(project, tag.name)
65
+ .filter(reference => reference.resolved?.toLowerCase() === `${tag.program ?? 'controller'}::${tag.name}`.toLowerCase()),
66
+ reference_completeness: 'static_partial',
67
+ })),
68
+ };
69
+ }
70
+ /**
71
+ * Resolve one writable scalar and check the value against its declared type.
72
+ * @param project - Fresh export snapshot.
73
+ * @param input - Tool arguments carrying `name`, optional `program`, and `value`.
74
+ * @param config - Resolved deployment limits.
75
+ * @returns The validated worker request.
76
+ */
77
+ export function writableTagRequest(project, input, config) {
78
+ const target = controllerTarget(input, config);
79
+ const name = input.name;
80
+ const program = input.program;
81
+ const tag = resolveScalar(project, { name, ...(program === undefined ? {} : { scope: program }) });
82
+ const value = input.value;
83
+ if (tag.data_type === 'BOOL') {
84
+ if (typeof value !== 'boolean')
85
+ throw new EngineeringError('INVALID_WRITE', `${tag.name} is BOOL; value must be true or false`, 'Pass value: true or value: false.');
86
+ }
87
+ else if (typeof value !== 'number' || !Number.isFinite(value)) {
88
+ throw new EngineeringError('INVALID_WRITE', `${tag.name} is ${tag.data_type}; value must be a finite number`, 'Pass a number for INT, DINT or REAL.');
89
+ }
90
+ else if (tag.data_type !== 'REAL') {
91
+ const [min, max] = RANGES[tag.data_type];
92
+ if (!Number.isInteger(value) || value < min || value > max)
93
+ throw new EngineeringError('INVALID_WRITE', `${tag.name} is ${tag.data_type}; value must be an integer between ${String(min)} and ${String(max)}`, 'Use whole numbers inside the declared range.');
94
+ }
95
+ return { request: { op: 'write', ...target, tag, value } };
96
+ }
97
+ /**
98
+ * Bound a discovery window.
99
+ * @param input - Tool arguments carrying optional `duration_seconds`.
100
+ * @param config - Resolved deployment limits.
101
+ * @returns Worker request with the window in milliseconds.
102
+ */
103
+ export function discoverRequest(input, config) {
104
+ if (!config.enabled)
105
+ throw new EngineeringError('DIRECT_POLLING_DISABLED', 'Enable directPollingEnabled to use controller discovery');
106
+ const seconds = input.duration_seconds ?? 3;
107
+ if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < 1 || seconds > 15)
108
+ throw new EngineeringError('INVALID_POLL', 'duration_seconds must be an integer between 1 and 15');
109
+ return { request: { op: 'discover', duration: seconds * 1000, timeout: config.requestTimeoutMs } };
110
+ }
111
+ /** Resolve one declared scalar (never an alias, array or unknown tag). */
112
+ function resolveScalar(project, entry) {
113
+ if (!entry || typeof entry !== 'object' || !('name' in entry) || typeof entry.name !== 'string'
114
+ || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(entry.name)
115
+ || !Object.keys(entry).every(key => key === 'name' || key === 'scope'))
116
+ throw new EngineeringError('UNSUPPORTED_WRITE_TAG', 'Each tag needs an exact scalar name and optional scope only');
117
+ const scope = 'scope' in entry ? entry.scope : 'controller';
118
+ if (typeof scope !== 'string' || (scope !== 'controller' && !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(scope)))
119
+ throw new EngineeringError('INVALID_POLL', 'scope must be controller or an exact program name');
120
+ const declared = project.document.tags.find(tag => tag.name === entry.name && tag.scope === scope);
121
+ const controller = controllerElementOf(project.document);
122
+ const programs = firstChild(controller, 'Programs');
123
+ const container = scope === 'controller' ? controller : programs === undefined ? undefined
124
+ : elementChildren(programs, 'Program').find(program => program.attrs.Name === scope);
125
+ const tagContainer = container === undefined ? undefined : firstChild(container, 'Tags');
126
+ const declaration = tagContainer === undefined ? undefined : elementChildren(tagContainer, 'Tag').find(tag => tag.attrs.Name === entry.name);
127
+ const dimensions = declaration?.attrs.Dimensions ?? declaration?.attrs.Dimension ?? '';
128
+ if (!declared || aliasesOf(project.document).has(`${scope}::${declared.name}`)
129
+ || !isScalar(declared.dataType)
130
+ || (dimensions !== '' && dimensions !== '0')
131
+ || declaration?.attrs.ExternalAccess === 'None')
132
+ throw new EngineeringError('UNSUPPORTED_WRITE_TAG', `${String(entry.name)} is not a writable declared scalar BOOL, INT, DINT or REAL`, 'Reads accept the same tags; writes additionally need ExternalAccess and no array dimensions.');
133
+ return { name: declared.name, ...(scope === 'controller' ? {} : { program: scope }), data_type: declared.dataType };
134
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * One-shot EtherNet/IP transactions: batch reads, identity/clock, writes and
3
+ * discovery, each in its own worker so a hung socket cannot hold the host.
4
+ *
5
+ * Reads are the normal case. Writes are only ever reached through a tool that
6
+ * has already asked a human for this exact operation, and every write result is
7
+ * the readback the controller returned, never an assumption that it worked.
8
+ *
9
+ * @module dsh-engineering-l5x/engine/cip-transaction
10
+ */
11
+ /** One observed value from a batch read. */
12
+ export interface CipValue {
13
+ tag: string;
14
+ data_type: string;
15
+ actual_data_type?: string;
16
+ value: boolean | number | null;
17
+ quality: 'good' | 'bad';
18
+ observed_at: string;
19
+ error?: string;
20
+ }
21
+ /** Controller identity as CIP reports it. */
22
+ export interface CipIdentity {
23
+ product_name: string;
24
+ serial_number: number;
25
+ revision: string;
26
+ status: number;
27
+ }
28
+ /** Result of the batch read. */
29
+ export interface CipReadResult {
30
+ controller: CipIdentity;
31
+ request_count: number;
32
+ observed_at: string;
33
+ values: CipValue[];
34
+ }
35
+ /** Result of the clock read. */
36
+ export interface CipClockResult {
37
+ controller: CipIdentity;
38
+ supported: boolean;
39
+ controller_time?: string;
40
+ host_time: string;
41
+ reason?: string;
42
+ }
43
+ /** Result of one tag write. */
44
+ export interface CipWriteResult {
45
+ tag: string;
46
+ declared_data_type: string;
47
+ actual_data_type?: string;
48
+ previous_value: boolean | number | null;
49
+ requested_value: boolean | number;
50
+ readback: boolean | number | null;
51
+ verified: boolean;
52
+ written_at: string;
53
+ }
54
+ /** Result of a wall-clock write. */
55
+ export interface CipWriteClockResult {
56
+ requested_time: string;
57
+ readback: string;
58
+ verified: boolean;
59
+ }
60
+ /** One discovered device. */
61
+ export interface CipDevice {
62
+ ip_address: string;
63
+ product_name: string;
64
+ revision: string;
65
+ serial_number: string;
66
+ vendor_id: number;
67
+ device_type: number;
68
+ status: number;
69
+ }
70
+ /** Result of discovery. */
71
+ export interface CipDiscoverResult {
72
+ seconds: number;
73
+ devices: CipDevice[];
74
+ }
75
+ /** Validate a batch-read result. */
76
+ export declare function decodeReadResult(value: unknown): CipReadResult;
77
+ /** Validate a clock result. */
78
+ export declare function decodeClockResult(value: unknown): CipClockResult;
79
+ /** Validate a write result. */
80
+ export declare function decodeWriteResult(value: unknown): CipWriteResult;
81
+ /** Validate a wall-clock write result. */
82
+ export declare function decodeWriteClockResult(value: unknown): CipWriteClockResult;
83
+ /** Validate a discovery result. */
84
+ export declare function decodeDiscoverResult(value: unknown): CipDiscoverResult;
85
+ /** Bounds shared with the polling engine. */
86
+ export interface TransactionLimits {
87
+ /** Per-request driver timeout, in milliseconds. */
88
+ timeout: number;
89
+ /** Most simultaneous transactions. */
90
+ maxConcurrent: number;
91
+ }
92
+ /**
93
+ * Run one worker transaction and enforce its deadline.
94
+ *
95
+ * The worker emits exactly one message: `{ ok: true, value }` or
96
+ * `{ ok: false, message }`. Anything else, or a worker that outlives the
97
+ * deadline, fails the call rather than returning a partial answer.
98
+ */
99
+ export declare class CipTransactions {
100
+ private readonly workerPath;
101
+ private readonly limits;
102
+ private active;
103
+ private disposed;
104
+ constructor(workerPath: string, limits: TransactionLimits);
105
+ /**
106
+ * Run one operation.
107
+ * @param spec - Worker request, already validated by the caller.
108
+ * @param signal - Optional lifetime, aborted by the calling turn.
109
+ * @param extraMs - Deadline beyond the driver timeout (discovery needs its window).
110
+ * @returns The raw `value` of the worker's reply.
111
+ */
112
+ run(spec: Readonly<Record<string, unknown>>, signal?: AbortSignal, extraMs?: number): Promise<unknown>;
113
+ /** Refuse new work and let running transactions finish. */
114
+ dispose(): Promise<void>;
115
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * One-shot EtherNet/IP transactions: batch reads, identity/clock, writes and
3
+ * discovery, each in its own worker so a hung socket cannot hold the host.
4
+ *
5
+ * Reads are the normal case. Writes are only ever reached through a tool that
6
+ * has already asked a human for this exact operation, and every write result is
7
+ * the readback the controller returned, never an assumption that it worked.
8
+ *
9
+ * @module dsh-engineering-l5x/engine/cip-transaction
10
+ */
11
+ import { Worker } from 'node:worker_threads';
12
+ import { EngineeringError } from "../session.js";
13
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ const isText = (value) => typeof value === 'string';
15
+ const isInteger = (value) => typeof value === 'number' && Number.isInteger(value);
16
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
17
+ const isScalar = (value) => typeof value === 'boolean' || isFiniteNumber(value);
18
+ /** Narrow one identity record. */
19
+ function identityOf(value) {
20
+ if (!isRecord(value) || !isText(value.product_name) || !isInteger(value.serial_number)
21
+ || !isText(value.revision) || !isFiniteNumber(value.status))
22
+ throw new Error('Invalid controller identity in CIP result');
23
+ return { product_name: value.product_name, serial_number: value.serial_number, revision: value.revision, status: value.status };
24
+ }
25
+ /** Narrow one observed value. */
26
+ function valueOf(value) {
27
+ if (!isRecord(value) || !isText(value.tag) || !isText(value.data_type) || !isText(value.observed_at)
28
+ || !(value.value === null || isScalar(value.value)) || (value.quality !== 'good' && value.quality !== 'bad'))
29
+ throw new Error('Invalid CIP read value');
30
+ return {
31
+ tag: value.tag,
32
+ data_type: value.data_type,
33
+ ...(isText(value.actual_data_type) ? { actual_data_type: value.actual_data_type } : {}),
34
+ value: value.value,
35
+ quality: value.quality,
36
+ observed_at: value.observed_at,
37
+ ...(isText(value.error) ? { error: value.error.slice(0, 1000) } : {}),
38
+ };
39
+ }
40
+ /** Validate a batch-read result. */
41
+ export function decodeReadResult(value) {
42
+ if (!isRecord(value) || !isInteger(value.request_count) || !isText(value.observed_at)
43
+ || !Array.isArray(value.values) || value.values.length > 256)
44
+ throw new Error('Invalid CIP read result');
45
+ return {
46
+ controller: identityOf(value.controller),
47
+ request_count: value.request_count,
48
+ observed_at: value.observed_at,
49
+ values: value.values.map(valueOf),
50
+ };
51
+ }
52
+ /** Validate a clock result. */
53
+ export function decodeClockResult(value) {
54
+ if (!isRecord(value) || typeof value.supported !== 'boolean' || !isText(value.host_time))
55
+ throw new Error('Invalid CIP clock result');
56
+ return {
57
+ controller: identityOf(value.controller),
58
+ supported: value.supported,
59
+ host_time: value.host_time,
60
+ ...(isText(value.controller_time) ? { controller_time: value.controller_time } : {}),
61
+ ...(isText(value.reason) ? { reason: value.reason.slice(0, 500) } : {}),
62
+ };
63
+ }
64
+ /** Validate a write result. */
65
+ export function decodeWriteResult(value) {
66
+ if (!isRecord(value) || !isText(value.tag) || !isText(value.declared_data_type) || !isText(value.written_at)
67
+ || !(value.previous_value === null || isScalar(value.previous_value))
68
+ || !isScalar(value.requested_value) || !(value.readback === null || isScalar(value.readback))
69
+ || typeof value.verified !== 'boolean')
70
+ throw new Error('Invalid CIP write result');
71
+ return {
72
+ tag: value.tag,
73
+ declared_data_type: value.declared_data_type,
74
+ ...(isText(value.actual_data_type) ? { actual_data_type: value.actual_data_type } : {}),
75
+ previous_value: value.previous_value,
76
+ requested_value: value.requested_value,
77
+ readback: value.readback,
78
+ verified: value.verified,
79
+ written_at: value.written_at,
80
+ };
81
+ }
82
+ /** Validate a wall-clock write result. */
83
+ export function decodeWriteClockResult(value) {
84
+ if (!isRecord(value) || !isText(value.requested_time) || !isText(value.readback) || typeof value.verified !== 'boolean')
85
+ throw new Error('Invalid CIP clock write result');
86
+ return { requested_time: value.requested_time, readback: value.readback, verified: value.verified };
87
+ }
88
+ /** Validate a discovery result. */
89
+ export function decodeDiscoverResult(value) {
90
+ if (!isRecord(value) || !isFiniteNumber(value.seconds) || !Array.isArray(value.devices) || value.devices.length > 512)
91
+ throw new Error('Invalid CIP discovery result');
92
+ return {
93
+ seconds: value.seconds,
94
+ devices: value.devices.map((device) => {
95
+ if (!isRecord(device) || !isText(device.ip_address) || !isText(device.product_name) || !isText(device.revision)
96
+ || !isText(device.serial_number) || !isFiniteNumber(device.vendor_id) || !isFiniteNumber(device.device_type)
97
+ || !isFiniteNumber(device.status))
98
+ throw new Error('Invalid discovered device');
99
+ return {
100
+ ip_address: device.ip_address,
101
+ product_name: device.product_name,
102
+ revision: device.revision,
103
+ serial_number: device.serial_number,
104
+ vendor_id: device.vendor_id,
105
+ device_type: device.device_type,
106
+ status: device.status,
107
+ };
108
+ }),
109
+ };
110
+ }
111
+ /**
112
+ * Run one worker transaction and enforce its deadline.
113
+ *
114
+ * The worker emits exactly one message: `{ ok: true, value }` or
115
+ * `{ ok: false, message }`. Anything else, or a worker that outlives the
116
+ * deadline, fails the call rather than returning a partial answer.
117
+ */
118
+ export class CipTransactions {
119
+ workerPath;
120
+ limits;
121
+ active = 0;
122
+ disposed = false;
123
+ constructor(workerPath, limits) {
124
+ this.workerPath = workerPath;
125
+ this.limits = limits;
126
+ }
127
+ /**
128
+ * Run one operation.
129
+ * @param spec - Worker request, already validated by the caller.
130
+ * @param signal - Optional lifetime, aborted by the calling turn.
131
+ * @param extraMs - Deadline beyond the driver timeout (discovery needs its window).
132
+ * @returns The raw `value` of the worker's reply.
133
+ */
134
+ async run(spec, signal, extraMs = 0) {
135
+ if (this.disposed)
136
+ throw new EngineeringError('CONTROLLER_BUSY', 'The controller bridge is shutting down');
137
+ if (this.active >= this.limits.maxConcurrent)
138
+ throw new EngineeringError('CONTROLLER_BUSY', 'Controller transaction capacity is occupied');
139
+ signal?.throwIfAborted();
140
+ this.active += 1;
141
+ const worker = new Worker(this.workerPath, { workerData: spec, env: {}, stdout: true, stderr: true });
142
+ worker.stdout.resume();
143
+ worker.stderr.resume();
144
+ const deadline = this.limits.timeout + extraMs;
145
+ const timer = setTimeout(() => { void worker.terminate(); }, deadline);
146
+ const onAbort = () => { void worker.terminate(); };
147
+ signal?.addEventListener('abort', onAbort, { once: true });
148
+ try {
149
+ return await new Promise((resolve, reject) => {
150
+ let settled = false;
151
+ const settle = (fn) => { if (!settled) {
152
+ settled = true;
153
+ fn();
154
+ } };
155
+ worker.on('message', (message) => {
156
+ if (!isRecord(message))
157
+ return settle(() => reject(new Error('Malformed controller reply')));
158
+ if (message.ok === true)
159
+ return settle(() => resolve(message.value));
160
+ settle(() => reject(new Error(isText(message.message) ? message.message : 'Controller transaction failed')));
161
+ });
162
+ worker.once('error', error => settle(() => reject(error)));
163
+ worker.once('exit', code => {
164
+ settle(() => reject(new Error(`Controller transaction ended without a reply (exit ${String(code)})`)));
165
+ });
166
+ });
167
+ }
168
+ catch (error) {
169
+ if (signal?.aborted)
170
+ throw new EngineeringError('CONTROLLER_CANCELLED', 'The controller transaction was cancelled');
171
+ throw error instanceof EngineeringError ? error : new EngineeringError('CONTROLLER_UNREACHABLE', error instanceof Error ? error.message : 'Controller transaction failed', 'Check the controller address and slot, that the port is reachable, and retry.');
172
+ }
173
+ finally {
174
+ clearTimeout(timer);
175
+ signal?.removeEventListener('abort', onAbort);
176
+ this.active -= 1;
177
+ void worker.terminate();
178
+ }
179
+ }
180
+ /** Refuse new work and let running transactions finish. */
181
+ async dispose() {
182
+ this.disposed = true;
183
+ this.active = 0;
184
+ }
185
+ }
@@ -32,6 +32,10 @@ export interface Config {
32
32
  maxConcurrent?: number;
33
33
  /** Optional IPv4 allowlist; empty permits any explicit address. */
34
34
  allowedAddresses?: string[];
35
+ /** Permit one-shot writes (tag and wall clock). Each write still needs human approval. Defaults to false. */
36
+ allowWrites?: boolean;
37
+ /** Largest accepted batch read. Defaults to 64. */
38
+ maxBatchTags?: number;
35
39
  /** Chart redraw interval, independent of reads. Defaults to 250 ms. */
36
40
  displayRefreshMs?: number;
37
41
  /** Most points retained per chart. Defaults to 1200. */
@@ -45,4 +49,5 @@ export interface Config {
45
49
  */
46
50
  export declare function apply(ctx: PluginContext, config?: Config): void;
47
51
  export { DirectPolling } from './engine/direct-polling.ts';
52
+ export { CipTransactions } from './engine/cip-transaction.ts';
48
53
  export { liveTools } from './tools/live.ts';
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { DirectPolling } from "./engine/direct-polling.js";
12
+ import { CipTransactions } from "./engine/cip-transaction.js";
12
13
  import { liveTools } from "./tools/live.js";
13
14
  import { PROJECTS_SERVICE } from "./index.js";
14
15
  /** Plugin name in a composition. */
@@ -39,10 +40,20 @@ export function apply(ctx, config = {}) {
39
40
  displayRefreshMs: config.displayRefreshMs ?? 250,
40
41
  displayMaxSamples: config.displayMaxSamples ?? 1200,
41
42
  });
43
+ const transactions = new CipTransactions(fileURLToPath(new URL('../cip-worker.mjs', import.meta.url)), {
44
+ timeout: config.requestTimeoutMs ?? 3000,
45
+ maxConcurrent: config.maxConcurrent ?? 2,
46
+ });
42
47
  const effect = ctx.effect;
43
- effect?.(() => () => { void polling.dispose(); });
44
- for (const definition of liveTools(session, polling, ctx))
48
+ effect?.(() => () => { void polling.dispose(); void transactions.dispose(); });
49
+ const extensions = {
50
+ transactions,
51
+ allowWrites: config.allowWrites === true,
52
+ maxBatchTags: config.maxBatchTags ?? 64,
53
+ };
54
+ for (const definition of liveTools(session, polling, ctx, extensions))
45
55
  ctx.tools.register(definition);
46
56
  }
47
57
  export { DirectPolling } from "./engine/direct-polling.js";
58
+ export { CipTransactions } from "./engine/cip-transaction.js";
48
59
  export { liveTools } from "./tools/live.js";
@@ -1,21 +1,32 @@
1
1
  /**
2
- * SDK-free live reads over EtherNet/IP.
2
+ * SDK-free EtherNet/IP surface: reads, identity, discovery and the two
3
+ * approval-gated writes.
3
4
  *
4
- * Two tools: a bounded foreground poll, and a session-owned trend job the chart
5
- * can follow without a model call per sample. Both read declared scalar tags,
6
- * never write, and state that live logic is not the exported logic.
5
+ * Reads never change the controller and need no approval. Writes do: a write is
6
+ * refused unless the row's config allows writes AND a human approves that exact
7
+ * operation, and its result is the value read back over CIP, not an assumption.
7
8
  *
8
9
  * @module dsh-engineering-l5x/tools/live
9
10
  */
10
11
  import type { PluginContext, ToolDefinition } from '../core/tool.ts';
11
12
  import type { ProjectSession } from '../session.ts';
12
13
  import { type DirectPolling } from '../engine/direct-polling.ts';
14
+ import { type CipTransactions } from '../engine/cip-transaction.ts';
15
+ /** Extra capabilities the live entry mounts beside polling. */
16
+ export interface LiveExtensions {
17
+ readonly transactions: CipTransactions;
18
+ /** Writes are refused unless the row config enables them. */
19
+ readonly allowWrites: boolean;
20
+ /** Largest accepted batch read. */
21
+ readonly maxBatchTags: number;
22
+ }
13
23
  /**
14
24
  * The live tools.
15
25
  *
16
26
  * @param session - Project handles for the calling chat.
17
27
  * @param polling - The polling engine and its configuration.
18
- * @param ctx - Plugin context that may provide the job service.
28
+ * @param ctx - Plugin context that may provide the job and approval services.
29
+ * @param extensions - One-shot transactions and the write switch.
19
30
  * @returns Tool definitions in registration order.
20
31
  */
21
- export declare function liveTools(session: ProjectSession, polling: DirectPolling, ctx: PluginContext): readonly ToolDefinition[];
32
+ export declare function liveTools(session: ProjectSession, polling: DirectPolling, ctx: PluginContext, extensions: LiveExtensions): readonly ToolDefinition[];