@aryacode/dsh-engineering 0.4.5 → 0.4.6
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.
- package/CHANGELOG.md +31 -0
- package/README.md +11 -8
- package/package.json +1 -1
- package/vendor/l5x/cip-worker.mjs +181 -0
- package/vendor/l5x/lib/core/params.d.ts +3 -1
- package/vendor/l5x/lib/core/params.js +4 -0
- package/vendor/l5x/lib/engine/cip-request.d.ts +85 -0
- package/vendor/l5x/lib/engine/cip-request.js +134 -0
- package/vendor/l5x/lib/engine/cip-transaction.d.ts +115 -0
- package/vendor/l5x/lib/engine/cip-transaction.js +185 -0
- package/vendor/l5x/lib/live.d.ts +5 -0
- package/vendor/l5x/lib/live.js +13 -2
- package/vendor/l5x/lib/tools/live.d.ts +17 -6
- package/vendor/l5x/lib/tools/live.js +177 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.6] - 2026-10-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
The EtherNet/IP surface now uses the driver rather than one corner of it. Five new tools, all under the
|
|
8
|
+
L5X product row's `live` switch:
|
|
9
|
+
|
|
10
|
+
- `read_live_tags` — up to 64 declared scalars in **one** request (CIP tag group) instead of one
|
|
11
|
+
connection-and-read per tag.
|
|
12
|
+
- `read_controller_clock` — controller identity plus the wall clock beside the workstation clock and the
|
|
13
|
+
offset between them; reports "unsupported" on non-L8x models instead of guessing.
|
|
14
|
+
- `discover_controllers` — bounded UDP broadcast discovery: address, product, revision, serial, status.
|
|
15
|
+
Needs no address and no export.
|
|
16
|
+
- `write_live_tag` — one declared scalar, **approval-gated**, then read back over CIP so the result is
|
|
17
|
+
the controller's own value.
|
|
18
|
+
- `write_controller_clock` — set the wall clock (L8x), approval-gated, read back.
|
|
19
|
+
|
|
20
|
+
### Safety
|
|
21
|
+
|
|
22
|
+
- Writes need **two** independent switches: the row's new `allowWrites` config (off by default) **and** a
|
|
23
|
+
human approving that exact operation. With `allowWrites` on and a `never` approval policy the tool
|
|
24
|
+
refuses with `APPROVAL_UNAVAILABLE`; a declined prompt is `APPROVAL_DENIED`. Nothing reaches a
|
|
25
|
+
controller before both pass.
|
|
26
|
+
- Every write validates against the open export first: declared, not an alias, no array dimensions,
|
|
27
|
+
`ExternalAccess` not `None`, value inside the declared type and range (BOOL boolean; INT/DINT integer
|
|
28
|
+
within range; REAL finite).
|
|
29
|
+
- A write whose readback differs from the requested value fails with `WRITE_NOT_VERIFIED` rather than
|
|
30
|
+
reporting success.
|
|
31
|
+
- `test/live.spec.ts` covers the gating: switch off, policy `never`, declined prompt, wrong value type,
|
|
32
|
+
and a UDT tag refused as non-scalar — 70 tests in the package.
|
|
33
|
+
|
|
3
34
|
## [0.4.5] - 2026-10-25
|
|
4
35
|
|
|
5
36
|
### Changed
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
MotionWorks IEC and FactoryTalk Optix sit behind a single install, one row per surface. A row is the
|
|
5
5
|
switch: disable it and that plugin unloads, so its tool schemas leave the request.
|
|
6
6
|
|
|
7
|
-
`@aryacode/dsh-engineering` ·
|
|
7
|
+
`@aryacode/dsh-engineering` · 84 tools · 5 rows · 4 products · MIT
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -21,7 +21,8 @@ the row, off by default, so a read-only profile pays for the read tools and noth
|
|
|
21
21
|
|---|---|---|---|
|
|
22
22
|
| `l5x` | `editing` | off | the nine `draft_*` schemas |
|
|
23
23
|
| `l5x` | `studio` | off | the Logix Designer SDK and supervised controller schemas |
|
|
24
|
-
| `l5x` | `live` | off | the EtherNet/IP
|
|
24
|
+
| `l5x` | `live` | off | the EtherNet/IP read, batch, discovery, clock and trend schemas |
|
|
25
|
+
| `l5x` | `allowWrites` | off | the two approval-gated writes (tag, wall clock) |
|
|
25
26
|
| `ftview` | `edit`, `studio` | on | staged ME edits and the Studio handoff |
|
|
26
27
|
| `mwi` | `edit` | off | the seven MotionWorks edit schemas |
|
|
27
28
|
|
|
@@ -33,7 +34,7 @@ bundle.
|
|
|
33
34
|
|
|
34
35
|
| Product | Tools | Default | Needs |
|
|
35
36
|
|---|---|---|---|
|
|
36
|
-
| **Studio 5000 L5X** |
|
|
37
|
+
| **Studio 5000 L5X** | 40 | read on; edit, Studio, live **off** | Node for offline reads; Studio 5000 + Logix Designer SDK for SDK work; a controller for live |
|
|
37
38
|
| **FactoryTalk View ME** | 14 | all on | Node for an export folder; FactoryTalk View Studio ME for discovery and the handoff |
|
|
38
39
|
| **MotionWorks IEC** | 24 | session, read, verify on; edit **off** | MotionWorks IEC 3 Pro installed; verified against 1.19 |
|
|
39
40
|
| **FactoryTalk Optix** | 6 | on | Optix Studio installed |
|
|
@@ -41,7 +42,7 @@ bundle.
|
|
|
41
42
|
Tool groups, by product:
|
|
42
43
|
|
|
43
44
|
- **L5X** — 11 read (`open_project` … `compare_projects`), 9 staged drafts (`draft_*`), 6 Logix
|
|
44
|
-
Designer SDK, 7 supervised controller,
|
|
45
|
+
Designer SDK, 7 supervised controller, 7 live EtherNet/IP (2 reads, 2 identity/discovery, 1 batch read, 2 approval-gated writes)
|
|
45
46
|
- **FactoryTalk View** — 6 read, 5 staged edits, 3 Studio handoff
|
|
46
47
|
- **MotionWorks IEC** — 7 session, 7 saved-project reads, 3 verify, 7 staged edits
|
|
47
48
|
- **Optix** — 6 tools, unlinked to a skill until one exists
|
|
@@ -55,7 +56,7 @@ down:
|
|
|
55
56
|
|
|
56
57
|
| Row | Mounts | Config options (default) |
|
|
57
58
|
|---|---|---|
|
|
58
|
-
| `l5x` | offline reads, checks, comparison and the `rockwell-l5x` guidance; optionally drafts, SDK, live | `editing` (off), `studio` (off), `live` (off) |
|
|
59
|
+
| `l5x` | offline reads, checks, comparison and the `rockwell-l5x` guidance; optionally drafts, SDK, live (reads, batch, discovery, clock) and supervised writes | `editing` (off), `studio` (off), `live` (off), `allowWrites` (off) |
|
|
59
60
|
| `ftview` | FactoryTalk View ME read, guidance and edits; optionally the Studio handoff | `edit` (on), `studio` (on) |
|
|
60
61
|
| `mwi` | MotionWorks IEC session, reads, verify and guidance; optionally the edits | `edit` (off) |
|
|
61
62
|
| `viewers` | ladder + ME display previews | — |
|
|
@@ -86,19 +87,20 @@ Where each surface actually stands, based on what has been exercised.
|
|
|
86
87
|
- Logix Designer SDK: a real ACD exported to a 3.0 MB L5X against SDK v37
|
|
87
88
|
- Supervised controller access — upload, mode change and download behind per-call approval, with
|
|
88
89
|
downloads accepting only a verification id produced by a passed build
|
|
89
|
-
- Live EtherNet/IP reads and a bounded
|
|
90
|
+
- Live EtherNet/IP: polling, one-request batch reads of up to 64 tags, controller identity and clock, LAN discovery, and a bounded trend
|
|
91
|
+
- Supervised live writes: `write_live_tag` and `write_controller_clock` need the row's `allowWrites` switch **and** per-call human approval, and report the value the controller reads back
|
|
90
92
|
- FactoryTalk View ME: export reads, structural checks, comparison, staged display and HMI tag edits
|
|
91
93
|
- MotionWorks IEC: verified live against 3 Pro 1.19 — stage, start, worksheet, close, saved-project
|
|
92
94
|
reads, compile diagnostics, code replacement, declaration add, POU create/delete, block insertion
|
|
93
95
|
and a POU package export/delete/import round trip
|
|
94
96
|
- Ladder viewer and FactoryTalk View display preview
|
|
95
97
|
|
|
96
|
-
**Deliberately off by default** — enabled per profile: `l5x-editing`, `l5x-studio`, `l5x-live`,
|
|
98
|
+
**Deliberately off by default** — enabled per profile: `l5x-editing`, `l5x-studio`, `l5x-live`, `allowWrites`,
|
|
97
99
|
`mwi-edit`. A read-only profile should not pay for nine draft schemas or a controller surface.
|
|
98
100
|
|
|
99
101
|
**Known limits** — stated in the bundle, not hidden:
|
|
100
102
|
|
|
101
|
-
- Live reads
|
|
103
|
+
- Live reads, SDK builds and live writes cannot establish that online logic matches the export; every result says
|
|
102
104
|
so in `coverage`
|
|
103
105
|
- FBD/SFC semantics are not exercised
|
|
104
106
|
- FactoryTalk View Studio work is a **manual handoff**: the bundle plans the import and preflights
|
|
@@ -113,6 +115,7 @@ Where each surface actually stands, based on what has been exercised.
|
|
|
113
115
|
|---|---|
|
|
114
116
|
| **0.4.4** | One row per product: five rows, optional surfaces moved into each row's config |
|
|
115
117
|
| **0.4.0** | Both previews fold into the bundle's own client face — one package, one browser bundle, 16 rows |
|
|
118
|
+
| **0.4.6** | Full EtherNet/IP surface: batch reads, discovery, controller clock, and approval-gated tag and clock writes; 84 tools |
|
|
116
119
|
| **0.3.0** | FactoryTalk Optix becomes a bundle component; 79 tools total |
|
|
117
120
|
| **0.2.0** | MotionWorks IEC ports into the bundle — 24 tools over the COM bridge and saved-model engine, verified live; edit off by default |
|
|
118
121
|
| **0.1.2** | Harness filesystem contract fixed across L5X and FactoryTalk View; every L5X tool linked to its capability by name; generated tool reference, examples and architecture docs |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aryacode/dsh-engineering",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "AryaCode engineering bundle: one install surface for the Studio 5000 L5X, FactoryTalk View ME, MotionWorks IEC and FactoryTalk Optix surfaces, one row per product, with the ladder and display previews.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** Isolated one-shot EtherNet/IP transactions. The parent owns deadlines, approval and validation. */
|
|
2
|
+
import { isMainThread, parentPort, workerData } from 'node:worker_threads'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
import ethernetIp from 'st-ethernet-ip'
|
|
5
|
+
import { isIPv4 } from 'node:net'
|
|
6
|
+
|
|
7
|
+
/** Shape one tag address for output. */
|
|
8
|
+
function addressOf(entry) {
|
|
9
|
+
return entry.program === undefined ? entry.name : `Program:${entry.program}.${entry.name}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** One observed value, quality included. */
|
|
13
|
+
function readValue(entry, tag) {
|
|
14
|
+
const typeMatches = tag.type === entry.data_type
|
|
15
|
+
const scalar = typeof tag.value === 'boolean' || (typeof tag.value === 'number' && Number.isFinite(tag.value))
|
|
16
|
+
return {
|
|
17
|
+
tag: addressOf(entry),
|
|
18
|
+
data_type: entry.data_type,
|
|
19
|
+
actual_data_type: tag.type,
|
|
20
|
+
value: typeMatches && scalar ? tag.value : null,
|
|
21
|
+
quality: typeMatches && scalar ? 'good' : 'bad',
|
|
22
|
+
...typeMatches && scalar ? {} : { error: 'Live type or value differs from the export declaration' },
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Open a controller, run one operation, and always close the connection.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} spec Validated worker request.
|
|
30
|
+
* @returns {Promise<object>} The operation's result value.
|
|
31
|
+
*/
|
|
32
|
+
async function connectAndRun(spec, run) {
|
|
33
|
+
if (!spec || typeof spec.ip !== 'string' || !isIPv4(spec.ip) || !Number.isInteger(spec.slot) || spec.slot < 0 || spec.slot > 31
|
|
34
|
+
|| !Number.isInteger(spec.timeout) || spec.timeout < 1)
|
|
35
|
+
throw new Error('Invalid CIP worker request')
|
|
36
|
+
const plc = new ethernetIp.Controller(false)
|
|
37
|
+
plc.timeout_sp = spec.timeout
|
|
38
|
+
// Driver transport failures are reported per request; keep the socket event handled.
|
|
39
|
+
plc.on('error', error => { void error })
|
|
40
|
+
try {
|
|
41
|
+
await plc.connect(spec.ip, spec.slot, false)
|
|
42
|
+
await plc.readControllerProps()
|
|
43
|
+
const controller = {
|
|
44
|
+
product_name: plc.properties.name,
|
|
45
|
+
serial_number: plc.properties.serial_number,
|
|
46
|
+
revision: plc.properties.version,
|
|
47
|
+
status: plc.properties.status,
|
|
48
|
+
}
|
|
49
|
+
return await run(plc, controller)
|
|
50
|
+
} finally {
|
|
51
|
+
// Unconnected explicit messaging needs no Forward Close request.
|
|
52
|
+
const closed = new Promise(resolve => plc.once('close', resolve))
|
|
53
|
+
if (!plc.closed) { if (!plc.destroyed) plc.destroy(); await closed }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Batch read: one explicit request carries every tag in the group. */
|
|
58
|
+
async function read(spec) {
|
|
59
|
+
return await connectAndRun(spec, async (plc, controller) => {
|
|
60
|
+
const group = new ethernetIp.TagGroup()
|
|
61
|
+
const tags = spec.tags.map(entry => {
|
|
62
|
+
if (!entry || typeof entry.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(entry.name)
|
|
63
|
+
|| !['BOOL', 'INT', 'DINT', 'REAL'].includes(entry.data_type)
|
|
64
|
+
|| (entry.program !== undefined && (typeof entry.program !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(entry.program))))
|
|
65
|
+
throw new Error('Invalid CIP worker tag')
|
|
66
|
+
const tag = new ethernetIp.Tag(entry.name, entry.program)
|
|
67
|
+
group.add(tag)
|
|
68
|
+
return { entry, tag }
|
|
69
|
+
})
|
|
70
|
+
await plc.readTagGroup(group)
|
|
71
|
+
return {
|
|
72
|
+
controller,
|
|
73
|
+
request_count: 1,
|
|
74
|
+
observed_at: new Date().toISOString(),
|
|
75
|
+
values: tags.map(({ entry, tag }) => ({ ...readValue(entry, tag), observed_at: new Date().toISOString() })),
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Identity and wall clock; the clock exists only on L8x controllers. */
|
|
81
|
+
async function clock(spec) {
|
|
82
|
+
return await connectAndRun(spec, async (plc, controller) => {
|
|
83
|
+
try {
|
|
84
|
+
await plc.readWallClock()
|
|
85
|
+
return {
|
|
86
|
+
controller,
|
|
87
|
+
supported: true,
|
|
88
|
+
controller_time: plc.properties.time instanceof Date ? plc.properties.time.toISOString() : String(plc.properties.time),
|
|
89
|
+
host_time: new Date().toISOString(),
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return {
|
|
93
|
+
controller,
|
|
94
|
+
supported: false,
|
|
95
|
+
host_time: new Date().toISOString(),
|
|
96
|
+
reason: error instanceof Error ? error.message : 'Wall clock is unavailable on this controller',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One tag write, read back over CIP so the result is the controller's answer. */
|
|
103
|
+
async function write(spec) {
|
|
104
|
+
return await connectAndRun(spec, async (plc) => {
|
|
105
|
+
const entry = spec.tag
|
|
106
|
+
const tag = new ethernetIp.Tag(entry.name, entry.program)
|
|
107
|
+
// Read first: the driver needs the controller's own type to encode the write.
|
|
108
|
+
await plc.readTag(tag)
|
|
109
|
+
const before = tag.value
|
|
110
|
+
const actualType = tag.type
|
|
111
|
+
tag.value = spec.value
|
|
112
|
+
await plc.writeTag(tag)
|
|
113
|
+
await plc.readTag(tag)
|
|
114
|
+
const readback = tag.value
|
|
115
|
+
return {
|
|
116
|
+
tag: addressOf(entry),
|
|
117
|
+
declared_data_type: entry.data_type,
|
|
118
|
+
actual_data_type: actualType,
|
|
119
|
+
previous_value: before,
|
|
120
|
+
requested_value: spec.value,
|
|
121
|
+
readback,
|
|
122
|
+
verified: Object.is(readback, spec.value) || (typeof readback === 'number' && typeof spec.value === 'number' && Math.abs(readback - spec.value) < 1e-6),
|
|
123
|
+
written_at: new Date().toISOString(),
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Set the wall clock; L8x only, and read back to prove it landed. */
|
|
129
|
+
async function writeClock(spec) {
|
|
130
|
+
return await connectAndRun(spec, async (plc) => {
|
|
131
|
+
const target = new Date(spec.iso)
|
|
132
|
+
if (Number.isNaN(target.getTime())) throw new Error('Invalid wall clock timestamp')
|
|
133
|
+
await plc.writeWallClock(target)
|
|
134
|
+
await plc.readWallClock()
|
|
135
|
+
const readback = plc.properties.time instanceof Date ? plc.properties.time.toISOString() : String(plc.properties.time)
|
|
136
|
+
return { requested_time: target.toISOString(), readback, verified: Math.abs(Date.parse(readback) - target.getTime()) < 2000 }
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** UDP broadcast discovery: where are the controllers on this LAN. */
|
|
141
|
+
async function discover(spec) {
|
|
142
|
+
const browser = new ethernetIp.Browser()
|
|
143
|
+
const devices = new Map()
|
|
144
|
+
browser.on('Device List Updated', list => {
|
|
145
|
+
for (const device of list) {
|
|
146
|
+
const address = device?.socketAddress?.sin_addr
|
|
147
|
+
if (typeof address === 'string') devices.set(address, device)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
browser.on('error', error => { void error })
|
|
151
|
+
try {
|
|
152
|
+
browser.start()
|
|
153
|
+
await delay(spec.duration)
|
|
154
|
+
} finally {
|
|
155
|
+
browser.stop()
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
seconds: spec.duration / 1000,
|
|
159
|
+
devices: [...devices.entries()].map(([ip, device]) => ({
|
|
160
|
+
ip_address: ip,
|
|
161
|
+
product_name: String(device.productName ?? ''),
|
|
162
|
+
revision: String(device.revision ?? ''),
|
|
163
|
+
serial_number: String(device.serialNumber ?? ''),
|
|
164
|
+
vendor_id: Number(device.vendorID ?? 0),
|
|
165
|
+
device_type: Number(device.deviceType ?? 0),
|
|
166
|
+
status: Number(device.status ?? 0),
|
|
167
|
+
})),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const OPERATIONS = { read, clock, write, 'write-clock': writeClock, discover }
|
|
172
|
+
|
|
173
|
+
if (!isMainThread) {
|
|
174
|
+
try {
|
|
175
|
+
const operation = OPERATIONS[workerData?.op]
|
|
176
|
+
if (operation === undefined) throw new Error('Unknown CIP worker operation')
|
|
177
|
+
parentPort.postMessage({ ok: true, value: await operation(workerData) })
|
|
178
|
+
} catch (error) {
|
|
179
|
+
parentPort.postMessage({ ok: false, message: error instanceof Error ? error.message : 'Controller transaction failed' })
|
|
180
|
+
} finally { parentPort.close() }
|
|
181
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
/** One JSON Schema property, with the description that is mandatory here. */
|
|
11
11
|
export interface ParameterSchema {
|
|
12
|
-
readonly type: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object';
|
|
12
|
+
readonly type: 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'object' | readonly ('string' | 'integer' | 'number' | 'boolean')[];
|
|
13
13
|
readonly description: string;
|
|
14
14
|
readonly enum?: readonly string[];
|
|
15
15
|
readonly items?: ParameterSchema;
|
|
@@ -28,6 +28,8 @@ export declare function string(description: string, extra?: Partial<ParameterSch
|
|
|
28
28
|
export declare function integer(description: string, extra?: Partial<ParameterSchema>): ParameterSchema;
|
|
29
29
|
/** A string parameter restricted to a known set. */
|
|
30
30
|
export declare function choice(description: string, values: readonly string[], extra?: Partial<ParameterSchema>): ParameterSchema;
|
|
31
|
+
/** A boolean-or-number parameter, for a value whose real type comes from the project. */
|
|
32
|
+
export declare function scalar(description: string, extra?: Partial<ParameterSchema>): ParameterSchema;
|
|
31
33
|
/** An array of one parameter type. */
|
|
32
34
|
export declare function list(description: string, items: ParameterSchema, extra?: Partial<ParameterSchema>): ParameterSchema;
|
|
33
35
|
/** One object element of an array parameter. */
|
|
@@ -19,6 +19,10 @@ export function integer(description, extra = {}) {
|
|
|
19
19
|
export function choice(description, values, extra = {}) {
|
|
20
20
|
return { type: 'string', description, enum: values, ...extra };
|
|
21
21
|
}
|
|
22
|
+
/** A boolean-or-number parameter, for a value whose real type comes from the project. */
|
|
23
|
+
export function scalar(description, extra = {}) {
|
|
24
|
+
return { type: ['boolean', 'number'], description, ...extra };
|
|
25
|
+
}
|
|
22
26
|
/** An array of one parameter type. */
|
|
23
27
|
export function list(description, items, extra = {}) {
|
|
24
28
|
return { type: 'array', description, items, ...extra };
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { type ProjectSnapshot } from '../session.ts';
|
|
11
|
+
import type { PollingConfig } from './direct-polling.ts';
|
|
12
|
+
/** Scalar types this surface can read and write. */
|
|
13
|
+
declare const SCALARS: readonly ["BOOL", "INT", "DINT", "REAL"];
|
|
14
|
+
type Scalar = (typeof SCALARS)[number];
|
|
15
|
+
/** One resolved scalar address. */
|
|
16
|
+
export interface ScalarTag {
|
|
17
|
+
name: string;
|
|
18
|
+
program?: string;
|
|
19
|
+
data_type: Scalar;
|
|
20
|
+
}
|
|
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 declare function controllerTarget(input: Readonly<Record<string, unknown>>, config: PollingConfig): {
|
|
28
|
+
ip: string;
|
|
29
|
+
slot: number;
|
|
30
|
+
timeout: number;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Resolve declared scalar tags for a batch read, then bound the batch.
|
|
34
|
+
* @param project - Fresh export snapshot.
|
|
35
|
+
* @param input - Tool arguments carrying `tags`.
|
|
36
|
+
* @param config - Resolved deployment limits.
|
|
37
|
+
* @param maxBatch - Largest accepted batch.
|
|
38
|
+
* @returns Worker request and operand evidence.
|
|
39
|
+
*/
|
|
40
|
+
export declare function batchReadRequest(project: ProjectSnapshot, input: Readonly<Record<string, unknown>>, config: PollingConfig, maxBatch: number): {
|
|
41
|
+
request: {
|
|
42
|
+
tags: ScalarTag[];
|
|
43
|
+
ip: string;
|
|
44
|
+
slot: number;
|
|
45
|
+
timeout: number;
|
|
46
|
+
op: string;
|
|
47
|
+
};
|
|
48
|
+
evidence: {
|
|
49
|
+
tag: string;
|
|
50
|
+
scope: string;
|
|
51
|
+
references: import("./analysis.ts").Evidence[];
|
|
52
|
+
reference_completeness: string;
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Resolve one writable scalar and check the value against its declared type.
|
|
57
|
+
* @param project - Fresh export snapshot.
|
|
58
|
+
* @param input - Tool arguments carrying `name`, optional `program`, and `value`.
|
|
59
|
+
* @param config - Resolved deployment limits.
|
|
60
|
+
* @returns The validated worker request.
|
|
61
|
+
*/
|
|
62
|
+
export declare function writableTagRequest(project: ProjectSnapshot, input: Readonly<Record<string, unknown>>, config: PollingConfig): {
|
|
63
|
+
request: {
|
|
64
|
+
tag: ScalarTag;
|
|
65
|
+
value: number | boolean;
|
|
66
|
+
ip: string;
|
|
67
|
+
slot: number;
|
|
68
|
+
timeout: number;
|
|
69
|
+
op: string;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Bound a discovery window.
|
|
74
|
+
* @param input - Tool arguments carrying optional `duration_seconds`.
|
|
75
|
+
* @param config - Resolved deployment limits.
|
|
76
|
+
* @returns Worker request with the window in milliseconds.
|
|
77
|
+
*/
|
|
78
|
+
export declare function discoverRequest(input: Readonly<Record<string, unknown>>, config: PollingConfig): {
|
|
79
|
+
request: {
|
|
80
|
+
op: string;
|
|
81
|
+
duration: number;
|
|
82
|
+
timeout: number;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
export {};
|
|
@@ -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
|
+
}
|
package/vendor/l5x/lib/live.d.ts
CHANGED
|
@@ -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';
|
package/vendor/l5x/lib/live.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2
|
+
* SDK-free EtherNet/IP surface: reads, identity, discovery and the two
|
|
3
|
+
* approval-gated writes.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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[];
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SDK-free
|
|
2
|
+
* SDK-free EtherNet/IP surface: reads, identity, discovery and the two
|
|
3
|
+
* approval-gated writes.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 { success } from "../core/result.js";
|
|
11
|
-
import { integer, list, object, objectItem, string } from "../core/params.js";
|
|
12
|
+
import { integer, list, object, objectItem, scalar, string } from "../core/params.js";
|
|
12
13
|
import { pollingRequest } from "../engine/direct-polling.js";
|
|
14
|
+
import { batchReadRequest, controllerTarget, discoverRequest, writableTagRequest } from "../engine/cip-request.js";
|
|
15
|
+
import { decodeClockResult, decodeDiscoverResult, decodeReadResult, decodeWriteClockResult, decodeWriteResult, } from "../engine/cip-transaction.js";
|
|
13
16
|
import { L5xError } from "../core/errors.js";
|
|
17
|
+
import { requireHumanApproval } from "../approvals.js";
|
|
14
18
|
import { PROJECT_ID, optionalInteger, optionalString, requiredString, tool } from "./common.js";
|
|
15
19
|
/** A direct read is not a guarantee about the scan. */
|
|
16
20
|
const LIVE_COVERAGE = {
|
|
@@ -21,6 +25,15 @@ const LIVE_COVERAGE = {
|
|
|
21
25
|
'transitions between sweeps',
|
|
22
26
|
],
|
|
23
27
|
};
|
|
28
|
+
/** A verified write is a fact about the tag, not about the machine. */
|
|
29
|
+
const WRITE_COVERAGE = {
|
|
30
|
+
complete: false,
|
|
31
|
+
unverified: [
|
|
32
|
+
'that the written value is the intended process change',
|
|
33
|
+
'that the logic reacts as expected on the next scan',
|
|
34
|
+
'that the value is still in place after the next scan',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
24
37
|
/** The tags argument, as flat objects rather than a JSON string. */
|
|
25
38
|
function tagsOf(args) {
|
|
26
39
|
const raw = args.tags;
|
|
@@ -41,28 +54,34 @@ function tagsOf(args) {
|
|
|
41
54
|
});
|
|
42
55
|
return JSON.stringify(tags);
|
|
43
56
|
}
|
|
44
|
-
/** The same tag parameter for
|
|
57
|
+
/** The same tag parameter for the polling tools. */
|
|
45
58
|
const TAGS_PARAM = list('Controller-scoped scalar tags to read, 1 to 16. Each entry names the tag and optionally its program.', objectItem('One declared scalar tag.', {
|
|
46
59
|
name: string('Tag name. Example: "LineSpeed".'),
|
|
47
60
|
program: string('Program scope for a program-scoped tag. Omit for a controller tag.'),
|
|
48
61
|
}, ['name']), { minItems: 1, maxItems: 16 });
|
|
62
|
+
/** The controller address shared by every non-discovery call. */
|
|
63
|
+
const target = {
|
|
64
|
+
ip_address: string('Controller IPv4 address the human named. Example: "192.168.1.10".'),
|
|
65
|
+
slot: integer('CPU slot, 0 to 31. Default 0.', { minimum: 0, maximum: 31, default: 0 }),
|
|
66
|
+
};
|
|
49
67
|
/**
|
|
50
68
|
* The live tools.
|
|
51
69
|
*
|
|
52
70
|
* @param session - Project handles for the calling chat.
|
|
53
71
|
* @param polling - The polling engine and its configuration.
|
|
54
|
-
* @param ctx - Plugin context that may provide the job
|
|
72
|
+
* @param ctx - Plugin context that may provide the job and approval services.
|
|
73
|
+
* @param extensions - One-shot transactions and the write switch.
|
|
55
74
|
* @returns Tool definitions in registration order.
|
|
56
75
|
*/
|
|
57
|
-
export function liveTools(session, polling, ctx) {
|
|
76
|
+
export function liveTools(session, polling, ctx, extensions) {
|
|
77
|
+
const { transactions } = extensions;
|
|
58
78
|
return [
|
|
59
79
|
tool({
|
|
60
80
|
name: 'poll_live_tags',
|
|
61
81
|
description: 'Read declared scalar PLC tags directly over EtherNet/IP without Studio, FactoryTalk Linx, an ACD upload or the SDK. Explicit controller IPv4 and slot required. Returns timestamped sweeps, quality and controller identity; no writes.',
|
|
62
82
|
parameters: object({
|
|
63
83
|
project_id: PROJECT_ID,
|
|
64
|
-
|
|
65
|
-
slot: integer('CPU slot, 0 to 31. Default 0.', { minimum: 0, maximum: 31, default: 0 }),
|
|
84
|
+
...target,
|
|
66
85
|
tags: TAGS_PARAM,
|
|
67
86
|
sample_count: integer('Number of sweeps to read. Default 1.', { minimum: 1, maximum: 1200, default: 1 }),
|
|
68
87
|
interval_ms: integer('Requested interval between sweeps. Default 200.', { minimum: 50, maximum: 600000, default: 200 }),
|
|
@@ -78,7 +97,7 @@ export function liveTools(session, polling, ctx) {
|
|
|
78
97
|
}, polling.config);
|
|
79
98
|
const frames = await polling.start(request, () => { }, exec.signal).done;
|
|
80
99
|
await session.require(project.id, exec);
|
|
81
|
-
return success(`Read ${request.samples} sweep(s) from ${request.ip}.`, {
|
|
100
|
+
return success(`Read ${String(request.samples)} sweep(s) from ${request.ip}.`, {
|
|
82
101
|
project_id: project.id,
|
|
83
102
|
source_sha256: project.sha256,
|
|
84
103
|
ip_address: request.ip,
|
|
@@ -95,13 +114,156 @@ export function liveTools(session, polling, ctx) {
|
|
|
95
114
|
}, [], LIVE_COVERAGE);
|
|
96
115
|
},
|
|
97
116
|
}),
|
|
117
|
+
tool({
|
|
118
|
+
name: 'read_live_tags',
|
|
119
|
+
description: 'Read up to 64 declared scalar tags in ONE EtherNet/IP request (CIP tag group). Faster than polling for a one-shot snapshot; no writes, no Studio, no Linx, no SDK.',
|
|
120
|
+
parameters: object({
|
|
121
|
+
project_id: PROJECT_ID,
|
|
122
|
+
...target,
|
|
123
|
+
tags: list('Declared scalar tags to read together, 1 to 64. Each entry names the tag and optionally its program.', objectItem('One declared scalar tag.', {
|
|
124
|
+
name: string('Tag name. Example: "LineSpeed".'),
|
|
125
|
+
program: string('Program scope for a program-scoped tag. Omit for a controller tag.'),
|
|
126
|
+
}, ['name']), { minItems: 1, maxItems: 64 }),
|
|
127
|
+
}, ['ip_address', 'tags']),
|
|
128
|
+
async run(args, exec) {
|
|
129
|
+
const project = await session.require(optionalString(args, 'project_id'), exec);
|
|
130
|
+
const { request, evidence } = batchReadRequest(project, {
|
|
131
|
+
ip_address: requiredString(args, 'ip_address'),
|
|
132
|
+
slot: optionalInteger(args, 'slot') ?? 0,
|
|
133
|
+
tags_json: tagsOf(args),
|
|
134
|
+
}, polling.config, extensions.maxBatchTags);
|
|
135
|
+
const result = decodeReadResult(await transactions.run(request, exec.signal));
|
|
136
|
+
await session.require(project.id, exec);
|
|
137
|
+
return success(`Read ${String(result.values.length)} tag(s) from ${request.ip} in one request.`, {
|
|
138
|
+
project_id: project.id,
|
|
139
|
+
source_sha256: project.sha256,
|
|
140
|
+
ip_address: request.ip,
|
|
141
|
+
slot: request.slot,
|
|
142
|
+
provider: 'ethernet-ip',
|
|
143
|
+
request_count: result.request_count,
|
|
144
|
+
controller: result.controller,
|
|
145
|
+
values: result.values,
|
|
146
|
+
evidence,
|
|
147
|
+
live_logic_matches_export: 'unverified',
|
|
148
|
+
}, [], LIVE_COVERAGE);
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
tool({
|
|
152
|
+
name: 'read_controller_clock',
|
|
153
|
+
description: 'Read controller identity and, on L8x controllers, the wall clock beside the workstation clock. Reports unsupported instead of guessing on other models. No writes.',
|
|
154
|
+
parameters: object({ ...target }, ['ip_address']),
|
|
155
|
+
async run(args, exec) {
|
|
156
|
+
const request = { op: 'clock', ...controllerTarget(args, polling.config) };
|
|
157
|
+
const result = decodeClockResult(await transactions.run(request, exec.signal));
|
|
158
|
+
const drift = result.supported && result.controller_time !== undefined
|
|
159
|
+
? Date.parse(result.host_time) - Date.parse(result.controller_time)
|
|
160
|
+
: undefined;
|
|
161
|
+
return success(result.supported
|
|
162
|
+
? `Controller clock ${String(result.controller_time)}; workstation ${result.host_time}.`
|
|
163
|
+
: `Controller identity read; wall clock is unavailable on ${result.controller.product_name}.`, {
|
|
164
|
+
ip_address: request.ip,
|
|
165
|
+
slot: request.slot,
|
|
166
|
+
provider: 'ethernet-ip',
|
|
167
|
+
controller: result.controller,
|
|
168
|
+
clock_supported: result.supported,
|
|
169
|
+
...(result.controller_time === undefined ? {} : { controller_time: result.controller_time }),
|
|
170
|
+
host_time: result.host_time,
|
|
171
|
+
...(drift === undefined ? {} : { clock_offset_ms: drift }),
|
|
172
|
+
...(result.reason === undefined ? {} : { reason: result.reason }),
|
|
173
|
+
}, [], LIVE_COVERAGE);
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
tool({
|
|
177
|
+
name: 'discover_controllers',
|
|
178
|
+
description: 'Discover EtherNet/IP controllers on this LAN by UDP broadcast for a bounded window: address, product, revision, serial, status. No address needed, no writes, no Studio or Linx.',
|
|
179
|
+
parameters: object({
|
|
180
|
+
duration_seconds: integer('How long to listen for replies, 1 to 15. Default 3.', { minimum: 1, maximum: 15, default: 3 }),
|
|
181
|
+
}),
|
|
182
|
+
async run(args, exec) {
|
|
183
|
+
const { request } = discoverRequest({ duration_seconds: optionalInteger(args, 'duration_seconds') ?? 3 }, polling.config);
|
|
184
|
+
const result = decodeDiscoverResult(await transactions.run(request, exec.signal, request.duration + 2000));
|
|
185
|
+
return success(`Found ${String(result.devices.length)} controller(s) in ${String(result.seconds)} s.`, {
|
|
186
|
+
provider: 'ethernet-ip',
|
|
187
|
+
broadcast_seconds: result.seconds,
|
|
188
|
+
devices: result.devices,
|
|
189
|
+
limitation: 'Broadcast discovery only reaches devices that answer on this subnet; a routed or filtered network returns nothing.',
|
|
190
|
+
}, [], { complete: false, unverified: ['that a silent device is absent', 'that a listed device is reachable for tag reads'] });
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
tool({
|
|
194
|
+
name: 'write_live_tag',
|
|
195
|
+
description: 'Write ONE declared scalar PLC tag over EtherNet/IP, then read it back and report the controller\'s own value. Requires the row\'s writes switch AND human approval for this exact write. Destructive: it changes a running machine.',
|
|
196
|
+
parameters: object({
|
|
197
|
+
project_id: PROJECT_ID,
|
|
198
|
+
...target,
|
|
199
|
+
name: string('Declared scalar tag name. Example: "LineSpeed".'),
|
|
200
|
+
program: string('Program scope for a program-scoped tag. Omit for a controller tag.'),
|
|
201
|
+
value: scalar('Value to write: true or false for BOOL, a number for INT, DINT or REAL.'),
|
|
202
|
+
}, ['ip_address', 'name', 'value']),
|
|
203
|
+
async run(args, exec) {
|
|
204
|
+
if (!extensions.allowWrites) {
|
|
205
|
+
throw new L5xError('WRITES_DISABLED', 'Live writes are switched off for this bundle row.', 'Turn on "allowWrites" in the L5X row configuration on the Plugins page, then request the write again.');
|
|
206
|
+
}
|
|
207
|
+
const project = await session.require(optionalString(args, 'project_id'), exec);
|
|
208
|
+
const { request } = writableTagRequest(project, {
|
|
209
|
+
ip_address: requiredString(args, 'ip_address'),
|
|
210
|
+
slot: optionalInteger(args, 'slot') ?? 0,
|
|
211
|
+
name: requiredString(args, 'name'),
|
|
212
|
+
...(optionalString(args, 'program') === undefined ? {} : { program: optionalString(args, 'program') }),
|
|
213
|
+
value: args.value,
|
|
214
|
+
}, polling.config);
|
|
215
|
+
const address = request.tag.program === undefined
|
|
216
|
+
? request.tag.name
|
|
217
|
+
: `Program:${String(request.tag.program)}.${String(request.tag.name)}`;
|
|
218
|
+
await requireHumanApproval(ctx, exec, 'write_live_tag', `Write ${JSON.stringify(args.value)} to ${address} on controller ${request.ip} slot ${String(request.slot)}. This changes a running machine.`);
|
|
219
|
+
const result = decodeWriteResult(await transactions.run(request, exec.signal));
|
|
220
|
+
await session.require(project.id, exec);
|
|
221
|
+
if (!result.verified) {
|
|
222
|
+
throw new L5xError('WRITE_NOT_VERIFIED', `${result.tag} was written but the readback is ${JSON.stringify(result.readback)}, not ${JSON.stringify(result.requested_value)}.`, 'Inspect the tag in the controller before retrying; a type or scaling mismatch can reject the value.');
|
|
223
|
+
}
|
|
224
|
+
return success(`Wrote ${JSON.stringify(result.requested_value)} to ${result.tag}; the controller reads back ${JSON.stringify(result.readback)}.`, {
|
|
225
|
+
project_id: project.id,
|
|
226
|
+
source_sha256: project.sha256,
|
|
227
|
+
ip_address: request.ip,
|
|
228
|
+
slot: request.slot,
|
|
229
|
+
provider: 'ethernet-ip',
|
|
230
|
+
approved: 'allowed-once',
|
|
231
|
+
...result,
|
|
232
|
+
live_logic_matches_export: 'unverified',
|
|
233
|
+
}, [], WRITE_COVERAGE);
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
tool({
|
|
237
|
+
name: 'write_controller_clock',
|
|
238
|
+
description: 'Set the controller wall clock (L8x only), then read it back. Requires the row\'s writes switch AND human approval for this exact change.',
|
|
239
|
+
parameters: object({
|
|
240
|
+
...target,
|
|
241
|
+
time: string('ISO 8601 timestamp to set. Omit to use the workstation clock now. Example: "2026-10-25T09:30:00Z".'),
|
|
242
|
+
}, ['ip_address']),
|
|
243
|
+
async run(args, exec) {
|
|
244
|
+
if (!extensions.allowWrites) {
|
|
245
|
+
throw new L5xError('WRITES_DISABLED', 'Live writes are switched off for this bundle row.', 'Turn on "allowWrites" in the L5X row configuration on the Plugins page, then request the change again.');
|
|
246
|
+
}
|
|
247
|
+
const request = { op: 'write-clock', ...controllerTarget(args, polling.config), iso: optionalString(args, 'time') ?? new Date().toISOString() };
|
|
248
|
+
await requireHumanApproval(ctx, exec, 'write_controller_clock', `Set the wall clock of controller ${request.ip} slot ${String(request.slot)} to ${String(request.iso)}.`);
|
|
249
|
+
const result = decodeWriteClockResult(await transactions.run(request, exec.signal));
|
|
250
|
+
if (!result.verified) {
|
|
251
|
+
throw new L5xError('WRITE_NOT_VERIFIED', `The clock readback ${result.readback} does not match ${result.requested_time}.`, 'Retry once; if it still differs, the controller rejects clock changes (unsupported model or protected).');
|
|
252
|
+
}
|
|
253
|
+
return success(`Controller clock set to ${result.readback}.`, {
|
|
254
|
+
ip_address: request.ip,
|
|
255
|
+
slot: request.slot,
|
|
256
|
+
approved: 'allowed-once',
|
|
257
|
+
...result,
|
|
258
|
+
}, [], WRITE_COVERAGE);
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
98
261
|
tool({
|
|
99
262
|
name: 'start_plc_trend',
|
|
100
263
|
description: 'Start a bounded, read-only PLC trend as a session-owned background job. Explicit IPv4, slot and declared scalar tags required. SDK-independent EtherNet/IP; the chart follows the job without a model call per sample.',
|
|
101
264
|
parameters: object({
|
|
102
265
|
project_id: PROJECT_ID,
|
|
103
|
-
|
|
104
|
-
slot: integer('CPU slot, 0 to 31. Default 0.', { minimum: 0, maximum: 31, default: 0 }),
|
|
266
|
+
...target,
|
|
105
267
|
tags: TAGS_PARAM,
|
|
106
268
|
sample_count: integer('Number of sweeps in the trend. Default 300.', { minimum: 1, maximum: 1200, default: 300 }),
|
|
107
269
|
interval_ms: integer('Requested interval between sweeps. Default 200.', { minimum: 50, maximum: 600000, default: 200 }),
|
|
@@ -123,7 +285,7 @@ export function liveTools(session, polling, ctx) {
|
|
|
123
285
|
const jobId = jobs.start({
|
|
124
286
|
kind: 'plc-trend',
|
|
125
287
|
owner,
|
|
126
|
-
label: `PLC trend ${request.ip} slot ${request.slot}`,
|
|
288
|
+
label: `PLC trend ${request.ip} slot ${String(request.slot)}`,
|
|
127
289
|
run(job) {
|
|
128
290
|
job.append(JSON.stringify({
|
|
129
291
|
type: 'source',
|
|
@@ -157,7 +319,7 @@ export function liveTools(session, polling, ctx) {
|
|
|
157
319
|
return { cancel: handle.cancel, done };
|
|
158
320
|
},
|
|
159
321
|
});
|
|
160
|
-
return success(`Trend ${jobId} started for ${request.samples} sweep(s) from ${request.ip}.`, {
|
|
322
|
+
return success(`Trend ${jobId} started for ${String(request.samples)} sweep(s) from ${request.ip}.`, {
|
|
161
323
|
job_id: jobId,
|
|
162
324
|
owner_session_id: owner,
|
|
163
325
|
project_id: project.id,
|