@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.7] - 2026-10-25
4
+
5
+ ### Fixed
6
+
7
+ - **`allowWrites` could not be reached from the L5X row.** The composite entry mounted the live entry
8
+ with no config, so the write switch had no path from the Plugins page. `entries/l5x.js` now declares
9
+ `allowWrites` and `allowedAddresses` and forwards both into the live entry; the write tools still
10
+ refuse without per-call approval.
11
+
12
+ ### Documentation
13
+
14
+ - README: the safety section states the two keys a live write takes, the tool groups list all seven
15
+ EtherNet/IP tools, and the L5X count is 40 (84 with Optix).
16
+ - `docs/examples.md`: a live section covering batch reads, discovery, the controller clock, the trend,
17
+ and a supervised write with the exact fields the reader must see and the refusals they may hit.
18
+ - `docs/architecture.md`: a new EtherNet/IP section — the two workers, the transaction engine's deadline
19
+ and decoding, and the four gates that stand between a write request and a controller.
20
+ - `docs/port/l5x.md`: the live surface is recorded as seven tools with the driver call each one uses, and
21
+ the unused capability (CIP subscriptions, UDT `Structure` reads) is named.
22
+ - `packages/l5x/README.md`: read and write tool tables, and the full live configuration row.
23
+
24
+ ## [0.4.6] - 2026-10-25
25
+
26
+ ### Added
27
+
28
+ The EtherNet/IP surface now uses the driver rather than one corner of it. Five new tools, all under the
29
+ L5X product row's `live` switch:
30
+
31
+ - `read_live_tags` — up to 64 declared scalars in **one** request (CIP tag group) instead of one
32
+ connection-and-read per tag.
33
+ - `read_controller_clock` — controller identity plus the wall clock beside the workstation clock and the
34
+ offset between them; reports "unsupported" on non-L8x models instead of guessing.
35
+ - `discover_controllers` — bounded UDP broadcast discovery: address, product, revision, serial, status.
36
+ Needs no address and no export.
37
+ - `write_live_tag` — one declared scalar, **approval-gated**, then read back over CIP so the result is
38
+ the controller's own value.
39
+ - `write_controller_clock` — set the wall clock (L8x), approval-gated, read back.
40
+
41
+ ### Safety
42
+
43
+ - Writes need **two** independent switches: the row's new `allowWrites` config (off by default) **and** a
44
+ human approving that exact operation. With `allowWrites` on and a `never` approval policy the tool
45
+ refuses with `APPROVAL_UNAVAILABLE`; a declined prompt is `APPROVAL_DENIED`. Nothing reaches a
46
+ controller before both pass.
47
+ - Every write validates against the open export first: declared, not an alias, no array dimensions,
48
+ `ExternalAccess` not `None`, value inside the declared type and range (BOOL boolean; INT/DINT integer
49
+ within range; REAL finite).
50
+ - A write whose readback differs from the requested value fails with `WRITE_NOT_VERIFIED` rather than
51
+ reporting success.
52
+ - `test/live.spec.ts` covers the gating: switch off, policy `never`, declined prompt, wrong value type,
53
+ and a UDT tag refused as non-scalar — 70 tests in the package.
54
+
3
55
  ## [0.4.5] - 2026-10-25
4
56
 
5
57
  ### 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` · 79 tools · 5 rows · 4 products · MIT
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 polling and trend schemas |
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** | 35 | read on; edit, Studio, live **off** | Node for offline reads; Studio 5000 + Logix Designer SDK for SDK work; a controller for live |
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, 2 live EtherNet/IP
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 PLC trend
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 and SDK builds cannot establish that online logic matches the export; every result says
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 |
@@ -173,6 +176,9 @@ even when the implementation behind a row is replaced.
173
176
  - **Controller changes need a human.** Upload, mode change and download require per-call approval and
174
177
  a session policy that permits asking. The tools refuse before contacting a controller when approval
175
178
  is unavailable, and a download accepts only a verification id produced by a passed build.
179
+ - **Live writes take two keys.** `write_live_tag` and `write_controller_clock` need the row's
180
+ `allowWrites` switch *and* approval for that exact write; the tag is validated against the export
181
+ first and the result is the value the controller reads back (`WRITE_NOT_VERIFIED` if it differs).
176
182
  - **No hidden vendor execution.** The FactoryTalk View Studio handoff returns a manual import plan; it
177
183
  does not run a vendor utility.
178
184
  - **Coverage is stated.** Every result names what it cannot establish, including whether online logic
package/entries/l5x.js CHANGED
@@ -2,10 +2,14 @@
2
2
  * The Studio 5000 L5X product row: one switch for the whole L5X surface.
3
3
  *
4
4
  * The parts are the vendored entries; this module mounts them, so the Plugins
5
- * page shows one row per product instead of one per surface. The three
6
- * expensive surfaces stay off by default and are switched from this row's
7
- * config form, which is the same decision the old separate rows encoded —
8
- * just reachable from the product row instead of from the list.
5
+ * page shows one row per product instead of one per surface. The expensive
6
+ * surfaces stay off by default and are switched from this row's config form,
7
+ * which is the same decision the old separate rows encoded — just reachable
8
+ * from the product row instead of from the list.
9
+ *
10
+ * `allowWrites` is forwarded into the live entry, not consumed here: the write
11
+ * tools refuse from inside that entry, so the switch and the per-call approval
12
+ * both live where the controller traffic happens.
9
13
  */
10
14
  import z from '@deepseek-ai/schemastery'
11
15
  import * as read from '../vendor/l5x/lib/index.js'
@@ -26,19 +30,31 @@ export const Config = z.object({
26
30
  'Logix Designer SDK and supervised controller tools. Needs Studio 5000 with the SDK service installed.',
27
31
  ),
28
32
  live: z.boolean().default(false).description(
29
- 'EtherNet/IP polling and trend tools. Needs a reachable controller address.',
33
+ 'EtherNet/IP surface: polling, batch reads, discovery, controller clock and trends. Needs a reachable controller address.',
34
+ ),
35
+ allowWrites: z.boolean().default(false).description(
36
+ 'Permit the two live write tools (tag, wall clock). Each write still needs human approval for that exact operation, and only applies while live is on.',
37
+ ),
38
+ allowedAddresses: z.array(z.string()).default([]).description(
39
+ 'Optional IPv4 allowlist for live reads, writes and discovery. Empty permits any explicit address.',
30
40
  ),
31
41
  })
32
42
 
33
43
  /**
34
44
  * Mount the L5X surface.
35
45
  * @param ctx - plugin context.
36
- * @param config - the three optional surfaces, all off unless enabled.
46
+ * @param config - the optional surfaces, all off unless enabled.
37
47
  */
38
48
  export function apply(ctx, config = {}) {
39
49
  ctx.plugin(read)
40
50
  ctx.plugin(skill)
41
51
  if (config.editing === true) ctx.plugin(editing)
42
52
  if (config.studio === true) ctx.plugin(studio)
43
- if (config.live === true) ctx.plugin(live)
53
+ if (config.live === true) {
54
+ const addresses = Array.isArray(config.allowedAddresses) ? config.allowedAddresses : []
55
+ ctx.plugin(live, {
56
+ allowWrites: config.allowWrites === true,
57
+ ...(addresses.length === 0 ? {} : { allowedAddresses: addresses }),
58
+ })
59
+ }
44
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-engineering",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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",
@@ -7,7 +7,7 @@ Studio 5000 L5X tools for the `dsh-engineering` bundle. Four loader entries shar
7
7
  | `dsh-engineering-l5x` | 11 offline read and reference tools | Node |
8
8
  | `dsh-engineering-l5x/editing` | 9 `draft_*` tools | the read entry |
9
9
  | `dsh-engineering-l5x/studio` | 6 Logix Designer SDK tools and 7 supervised controller tools | Studio 5000, Logix Designer SDK, FactoryTalk Linx |
10
- | `dsh-engineering-l5x/live` | `poll_live_tags`, `start_plc_trend` | a reachable controller |
10
+ | `dsh-engineering-l5x/live` | 7 EtherNet/IP tools: `poll_live_tags`, `read_live_tags`, `read_controller_clock`, `discover_controllers`, `start_plc_trend`, and the approval-gated `write_live_tag`, `write_controller_clock` | a reachable controller for reads; the row's `allowWrites` plus human approval for writes |
11
11
 
12
12
  The `rockwell-l5x` guidance ships as the separate `dsh-engineering-l5x/skill` entry, and the PLC
13
13
  trend card ships as the client face (`dsh.client`).
@@ -40,9 +40,31 @@ operation is an upload, mode change or download; those require per-call human ap
40
40
 
41
41
  ## Live
42
42
 
43
- `poll_live_tags` and `start_plc_trend` read declared scalar tags over EtherNet/IP without Studio,
44
- Linx, an ACD or the SDK. They are read-only and report that live logic matching the export is
45
- unverified.
43
+ Reads need no approval and no Studio, Linx, ACD or SDK:
44
+
45
+ | Tool | What it does |
46
+ |---|---|
47
+ | `poll_live_tags` | Timed sweeps of 1–16 declared scalar tags, with quality and controller identity |
48
+ | `read_live_tags` | Up to 64 declared scalars in **one** request (CIP tag group) |
49
+ | `read_controller_clock` | Identity plus the wall clock beside the workstation clock and their offset (L8x) |
50
+ | `discover_controllers` | Bounded UDP broadcast discovery of EtherNet/IP devices on the LAN |
51
+ | `start_plc_trend` | Session-owned trend job the chart follows without a model call per sample |
52
+
53
+ Two writes exist and are **supervised**, not merely available:
54
+
55
+ | Tool | What it changes |
56
+ |---|---|
57
+ | `write_live_tag` | One declared scalar, then read back over CIP |
58
+ | `write_controller_clock` | The controller wall clock (L8x), then read back |
59
+
60
+ A write needs the row's `allowWrites` switch **and** a human approving that exact operation: with
61
+ `allowWrites` on and a `never` policy the tool refuses with `APPROVAL_UNAVAILABLE` before opening a
62
+ socket, and a declined prompt is `APPROVAL_DENIED`. Each write is validated against the open export
63
+ (declared, non-alias, no array dimensions, `ExternalAccess` not `None`, value inside the declared
64
+ type and range) and a readback that differs fails with `WRITE_NOT_VERIFIED`.
65
+
66
+ Every read reports that live logic matching the export is unverified, and sequential reads can miss
67
+ transitions between sweeps.
46
68
 
47
69
  ## Configuration
48
70
 
@@ -51,7 +73,7 @@ unverified.
51
73
  | read | `maxFileBytes` | 64 MiB |
52
74
  | editing | `maxFileBytes`, `maxDraftOperations` | 64 MiB, 200 |
53
75
  | studio | `maxFileBytes` | 64 MiB |
54
- | live | `enabled`, `minIntervalMs`, `defaultIntervalMs`, `maxTags`, `maxSamples` | true, 50 ms, 200 ms, 16, 1200 |
76
+ | live | `enabled`, `minIntervalMs`, `defaultIntervalMs`, `maxTags`, `maxSamples`, `allowWrites`, `maxBatchTags`, `allowedAddresses` | true, 50 ms, 200 ms, 16, 1200, false, 64, empty |
55
77
 
56
78
  ## Develop
57
79
 
@@ -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 {};