@aryacode/dsh-engineering 0.4.4 → 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.
Files changed (122) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +11 -8
  3. package/cordis.patch.yml +3 -2
  4. package/package.json +3 -2
  5. package/vendor/l5x/cip-worker.mjs +181 -0
  6. package/vendor/l5x/lib/core/params.d.ts +3 -1
  7. package/vendor/l5x/lib/core/params.js +4 -0
  8. package/vendor/l5x/lib/engine/cip-request.d.ts +85 -0
  9. package/vendor/l5x/lib/engine/cip-request.js +134 -0
  10. package/vendor/l5x/lib/engine/cip-transaction.d.ts +115 -0
  11. package/vendor/l5x/lib/engine/cip-transaction.js +185 -0
  12. package/vendor/l5x/lib/live.d.ts +5 -0
  13. package/vendor/l5x/lib/live.js +13 -2
  14. package/vendor/l5x/lib/tools/live.d.ts +17 -6
  15. package/vendor/l5x/lib/tools/live.js +177 -15
  16. package/vendor/optix/bridge/LICENSE +35 -0
  17. package/vendor/optix/bridge/StudioMCPBridge.cs +7022 -0
  18. package/vendor/optix/bridge/source.json +10 -0
  19. package/vendor/optix/icon.svg +13 -0
  20. package/vendor/optix/lib/index.js +1531 -0
  21. package/vendor/optix/lib/types/bridge.d.ts +77 -0
  22. package/vendor/optix/lib/types/index.d.ts +52 -0
  23. package/vendor/optix/lib/types/ocr.d.ts +46 -0
  24. package/vendor/optix/lib/types/operations.d.ts +42 -0
  25. package/vendor/optix/lib/types/preview.d.ts +33 -0
  26. package/vendor/optix/lib/types/templates.d.ts +18 -0
  27. package/vendor/optix/locale/en.json +6 -0
  28. package/vendor/optix/locale/zh.json +6 -0
  29. package/vendor/optix/templates/arya-optix-ipc/manifest.json +260 -0
  30. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -0
  31. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -0
  32. package/vendor/optix/templates/arya-optix-ipc/project/IDEVersion.txt +1 -0
  33. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -0
  34. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -0
  35. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -0
  36. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -0
  37. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -0
  38. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -0
  39. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -0
  40. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -0
  41. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +28 -0
  42. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -0
  43. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -0
  44. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -0
  45. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -0
  46. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -0
  47. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -0
  48. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -0
  49. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -0
  50. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -0
  51. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -0
  52. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -0
  53. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -0
  54. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +306 -0
  55. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +19 -0
  56. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +10 -0
  57. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -0
  58. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -0
  59. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -0
  60. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -0
  61. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -0
  62. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4555 -0
  63. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -0
  64. package/vendor/optix/templates/arya-optix-panel/manifest.json +411 -0
  65. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -0
  66. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -0
  67. package/vendor/optix/templates/arya-optix-panel/project/IDEVersion.txt +1 -0
  68. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -0
  69. package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -0
  70. package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -0
  71. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -0
  72. package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -0
  73. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -0
  74. package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -0
  75. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -0
  76. package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +28 -0
  77. package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -0
  78. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -0
  79. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -0
  80. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -0
  81. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -0
  82. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -0
  83. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -0
  84. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -0
  85. package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -0
  86. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -0
  87. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -0
  88. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -0
  89. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -0
  90. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -0
  91. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -0
  92. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -0
  93. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -0
  94. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -0
  95. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -0
  96. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +308 -0
  97. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +19 -0
  98. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -0
  99. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +10 -0
  100. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -0
  101. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -0
  102. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -0
  103. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -0
  104. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -0
  105. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -0
  106. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -0
  107. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -0
  108. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -0
  109. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -0
  110. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -0
  111. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -0
  112. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -0
  113. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -0
  114. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -0
  115. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -0
  116. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -0
  117. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -0
  118. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -0
  119. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4555 -0
  120. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -0
  121. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -0
  122. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * One-shot EtherNet/IP transactions: batch reads, identity/clock, writes and
3
+ * discovery, each in its own worker so a hung socket cannot hold the host.
4
+ *
5
+ * Reads are the normal case. Writes are only ever reached through a tool that
6
+ * has already asked a human for this exact operation, and every write result is
7
+ * the readback the controller returned, never an assumption that it worked.
8
+ *
9
+ * @module dsh-engineering-l5x/engine/cip-transaction
10
+ */
11
+ /** One observed value from a batch read. */
12
+ export interface CipValue {
13
+ tag: string;
14
+ data_type: string;
15
+ actual_data_type?: string;
16
+ value: boolean | number | null;
17
+ quality: 'good' | 'bad';
18
+ observed_at: string;
19
+ error?: string;
20
+ }
21
+ /** Controller identity as CIP reports it. */
22
+ export interface CipIdentity {
23
+ product_name: string;
24
+ serial_number: number;
25
+ revision: string;
26
+ status: number;
27
+ }
28
+ /** Result of the batch read. */
29
+ export interface CipReadResult {
30
+ controller: CipIdentity;
31
+ request_count: number;
32
+ observed_at: string;
33
+ values: CipValue[];
34
+ }
35
+ /** Result of the clock read. */
36
+ export interface CipClockResult {
37
+ controller: CipIdentity;
38
+ supported: boolean;
39
+ controller_time?: string;
40
+ host_time: string;
41
+ reason?: string;
42
+ }
43
+ /** Result of one tag write. */
44
+ export interface CipWriteResult {
45
+ tag: string;
46
+ declared_data_type: string;
47
+ actual_data_type?: string;
48
+ previous_value: boolean | number | null;
49
+ requested_value: boolean | number;
50
+ readback: boolean | number | null;
51
+ verified: boolean;
52
+ written_at: string;
53
+ }
54
+ /** Result of a wall-clock write. */
55
+ export interface CipWriteClockResult {
56
+ requested_time: string;
57
+ readback: string;
58
+ verified: boolean;
59
+ }
60
+ /** One discovered device. */
61
+ export interface CipDevice {
62
+ ip_address: string;
63
+ product_name: string;
64
+ revision: string;
65
+ serial_number: string;
66
+ vendor_id: number;
67
+ device_type: number;
68
+ status: number;
69
+ }
70
+ /** Result of discovery. */
71
+ export interface CipDiscoverResult {
72
+ seconds: number;
73
+ devices: CipDevice[];
74
+ }
75
+ /** Validate a batch-read result. */
76
+ export declare function decodeReadResult(value: unknown): CipReadResult;
77
+ /** Validate a clock result. */
78
+ export declare function decodeClockResult(value: unknown): CipClockResult;
79
+ /** Validate a write result. */
80
+ export declare function decodeWriteResult(value: unknown): CipWriteResult;
81
+ /** Validate a wall-clock write result. */
82
+ export declare function decodeWriteClockResult(value: unknown): CipWriteClockResult;
83
+ /** Validate a discovery result. */
84
+ export declare function decodeDiscoverResult(value: unknown): CipDiscoverResult;
85
+ /** Bounds shared with the polling engine. */
86
+ export interface TransactionLimits {
87
+ /** Per-request driver timeout, in milliseconds. */
88
+ timeout: number;
89
+ /** Most simultaneous transactions. */
90
+ maxConcurrent: number;
91
+ }
92
+ /**
93
+ * Run one worker transaction and enforce its deadline.
94
+ *
95
+ * The worker emits exactly one message: `{ ok: true, value }` or
96
+ * `{ ok: false, message }`. Anything else, or a worker that outlives the
97
+ * deadline, fails the call rather than returning a partial answer.
98
+ */
99
+ export declare class CipTransactions {
100
+ private readonly workerPath;
101
+ private readonly limits;
102
+ private active;
103
+ private disposed;
104
+ constructor(workerPath: string, limits: TransactionLimits);
105
+ /**
106
+ * Run one operation.
107
+ * @param spec - Worker request, already validated by the caller.
108
+ * @param signal - Optional lifetime, aborted by the calling turn.
109
+ * @param extraMs - Deadline beyond the driver timeout (discovery needs its window).
110
+ * @returns The raw `value` of the worker's reply.
111
+ */
112
+ run(spec: Readonly<Record<string, unknown>>, signal?: AbortSignal, extraMs?: number): Promise<unknown>;
113
+ /** Refuse new work and let running transactions finish. */
114
+ dispose(): Promise<void>;
115
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * One-shot EtherNet/IP transactions: batch reads, identity/clock, writes and
3
+ * discovery, each in its own worker so a hung socket cannot hold the host.
4
+ *
5
+ * Reads are the normal case. Writes are only ever reached through a tool that
6
+ * has already asked a human for this exact operation, and every write result is
7
+ * the readback the controller returned, never an assumption that it worked.
8
+ *
9
+ * @module dsh-engineering-l5x/engine/cip-transaction
10
+ */
11
+ import { Worker } from 'node:worker_threads';
12
+ import { EngineeringError } from "../session.js";
13
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ const isText = (value) => typeof value === 'string';
15
+ const isInteger = (value) => typeof value === 'number' && Number.isInteger(value);
16
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
17
+ const isScalar = (value) => typeof value === 'boolean' || isFiniteNumber(value);
18
+ /** Narrow one identity record. */
19
+ function identityOf(value) {
20
+ if (!isRecord(value) || !isText(value.product_name) || !isInteger(value.serial_number)
21
+ || !isText(value.revision) || !isFiniteNumber(value.status))
22
+ throw new Error('Invalid controller identity in CIP result');
23
+ return { product_name: value.product_name, serial_number: value.serial_number, revision: value.revision, status: value.status };
24
+ }
25
+ /** Narrow one observed value. */
26
+ function valueOf(value) {
27
+ if (!isRecord(value) || !isText(value.tag) || !isText(value.data_type) || !isText(value.observed_at)
28
+ || !(value.value === null || isScalar(value.value)) || (value.quality !== 'good' && value.quality !== 'bad'))
29
+ throw new Error('Invalid CIP read value');
30
+ return {
31
+ tag: value.tag,
32
+ data_type: value.data_type,
33
+ ...(isText(value.actual_data_type) ? { actual_data_type: value.actual_data_type } : {}),
34
+ value: value.value,
35
+ quality: value.quality,
36
+ observed_at: value.observed_at,
37
+ ...(isText(value.error) ? { error: value.error.slice(0, 1000) } : {}),
38
+ };
39
+ }
40
+ /** Validate a batch-read result. */
41
+ export function decodeReadResult(value) {
42
+ if (!isRecord(value) || !isInteger(value.request_count) || !isText(value.observed_at)
43
+ || !Array.isArray(value.values) || value.values.length > 256)
44
+ throw new Error('Invalid CIP read result');
45
+ return {
46
+ controller: identityOf(value.controller),
47
+ request_count: value.request_count,
48
+ observed_at: value.observed_at,
49
+ values: value.values.map(valueOf),
50
+ };
51
+ }
52
+ /** Validate a clock result. */
53
+ export function decodeClockResult(value) {
54
+ if (!isRecord(value) || typeof value.supported !== 'boolean' || !isText(value.host_time))
55
+ throw new Error('Invalid CIP clock result');
56
+ return {
57
+ controller: identityOf(value.controller),
58
+ supported: value.supported,
59
+ host_time: value.host_time,
60
+ ...(isText(value.controller_time) ? { controller_time: value.controller_time } : {}),
61
+ ...(isText(value.reason) ? { reason: value.reason.slice(0, 500) } : {}),
62
+ };
63
+ }
64
+ /** Validate a write result. */
65
+ export function decodeWriteResult(value) {
66
+ if (!isRecord(value) || !isText(value.tag) || !isText(value.declared_data_type) || !isText(value.written_at)
67
+ || !(value.previous_value === null || isScalar(value.previous_value))
68
+ || !isScalar(value.requested_value) || !(value.readback === null || isScalar(value.readback))
69
+ || typeof value.verified !== 'boolean')
70
+ throw new Error('Invalid CIP write result');
71
+ return {
72
+ tag: value.tag,
73
+ declared_data_type: value.declared_data_type,
74
+ ...(isText(value.actual_data_type) ? { actual_data_type: value.actual_data_type } : {}),
75
+ previous_value: value.previous_value,
76
+ requested_value: value.requested_value,
77
+ readback: value.readback,
78
+ verified: value.verified,
79
+ written_at: value.written_at,
80
+ };
81
+ }
82
+ /** Validate a wall-clock write result. */
83
+ export function decodeWriteClockResult(value) {
84
+ if (!isRecord(value) || !isText(value.requested_time) || !isText(value.readback) || typeof value.verified !== 'boolean')
85
+ throw new Error('Invalid CIP clock write result');
86
+ return { requested_time: value.requested_time, readback: value.readback, verified: value.verified };
87
+ }
88
+ /** Validate a discovery result. */
89
+ export function decodeDiscoverResult(value) {
90
+ if (!isRecord(value) || !isFiniteNumber(value.seconds) || !Array.isArray(value.devices) || value.devices.length > 512)
91
+ throw new Error('Invalid CIP discovery result');
92
+ return {
93
+ seconds: value.seconds,
94
+ devices: value.devices.map((device) => {
95
+ if (!isRecord(device) || !isText(device.ip_address) || !isText(device.product_name) || !isText(device.revision)
96
+ || !isText(device.serial_number) || !isFiniteNumber(device.vendor_id) || !isFiniteNumber(device.device_type)
97
+ || !isFiniteNumber(device.status))
98
+ throw new Error('Invalid discovered device');
99
+ return {
100
+ ip_address: device.ip_address,
101
+ product_name: device.product_name,
102
+ revision: device.revision,
103
+ serial_number: device.serial_number,
104
+ vendor_id: device.vendor_id,
105
+ device_type: device.device_type,
106
+ status: device.status,
107
+ };
108
+ }),
109
+ };
110
+ }
111
+ /**
112
+ * Run one worker transaction and enforce its deadline.
113
+ *
114
+ * The worker emits exactly one message: `{ ok: true, value }` or
115
+ * `{ ok: false, message }`. Anything else, or a worker that outlives the
116
+ * deadline, fails the call rather than returning a partial answer.
117
+ */
118
+ export class CipTransactions {
119
+ workerPath;
120
+ limits;
121
+ active = 0;
122
+ disposed = false;
123
+ constructor(workerPath, limits) {
124
+ this.workerPath = workerPath;
125
+ this.limits = limits;
126
+ }
127
+ /**
128
+ * Run one operation.
129
+ * @param spec - Worker request, already validated by the caller.
130
+ * @param signal - Optional lifetime, aborted by the calling turn.
131
+ * @param extraMs - Deadline beyond the driver timeout (discovery needs its window).
132
+ * @returns The raw `value` of the worker's reply.
133
+ */
134
+ async run(spec, signal, extraMs = 0) {
135
+ if (this.disposed)
136
+ throw new EngineeringError('CONTROLLER_BUSY', 'The controller bridge is shutting down');
137
+ if (this.active >= this.limits.maxConcurrent)
138
+ throw new EngineeringError('CONTROLLER_BUSY', 'Controller transaction capacity is occupied');
139
+ signal?.throwIfAborted();
140
+ this.active += 1;
141
+ const worker = new Worker(this.workerPath, { workerData: spec, env: {}, stdout: true, stderr: true });
142
+ worker.stdout.resume();
143
+ worker.stderr.resume();
144
+ const deadline = this.limits.timeout + extraMs;
145
+ const timer = setTimeout(() => { void worker.terminate(); }, deadline);
146
+ const onAbort = () => { void worker.terminate(); };
147
+ signal?.addEventListener('abort', onAbort, { once: true });
148
+ try {
149
+ return await new Promise((resolve, reject) => {
150
+ let settled = false;
151
+ const settle = (fn) => { if (!settled) {
152
+ settled = true;
153
+ fn();
154
+ } };
155
+ worker.on('message', (message) => {
156
+ if (!isRecord(message))
157
+ return settle(() => reject(new Error('Malformed controller reply')));
158
+ if (message.ok === true)
159
+ return settle(() => resolve(message.value));
160
+ settle(() => reject(new Error(isText(message.message) ? message.message : 'Controller transaction failed')));
161
+ });
162
+ worker.once('error', error => settle(() => reject(error)));
163
+ worker.once('exit', code => {
164
+ settle(() => reject(new Error(`Controller transaction ended without a reply (exit ${String(code)})`)));
165
+ });
166
+ });
167
+ }
168
+ catch (error) {
169
+ if (signal?.aborted)
170
+ throw new EngineeringError('CONTROLLER_CANCELLED', 'The controller transaction was cancelled');
171
+ throw error instanceof EngineeringError ? error : new EngineeringError('CONTROLLER_UNREACHABLE', error instanceof Error ? error.message : 'Controller transaction failed', 'Check the controller address and slot, that the port is reachable, and retry.');
172
+ }
173
+ finally {
174
+ clearTimeout(timer);
175
+ signal?.removeEventListener('abort', onAbort);
176
+ this.active -= 1;
177
+ void worker.terminate();
178
+ }
179
+ }
180
+ /** Refuse new work and let running transactions finish. */
181
+ async dispose() {
182
+ this.disposed = true;
183
+ this.active = 0;
184
+ }
185
+ }
@@ -32,6 +32,10 @@ export interface Config {
32
32
  maxConcurrent?: number;
33
33
  /** Optional IPv4 allowlist; empty permits any explicit address. */
34
34
  allowedAddresses?: string[];
35
+ /** Permit one-shot writes (tag and wall clock). Each write still needs human approval. Defaults to false. */
36
+ allowWrites?: boolean;
37
+ /** Largest accepted batch read. Defaults to 64. */
38
+ maxBatchTags?: number;
35
39
  /** Chart redraw interval, independent of reads. Defaults to 250 ms. */
36
40
  displayRefreshMs?: number;
37
41
  /** Most points retained per chart. Defaults to 1200. */
@@ -45,4 +49,5 @@ export interface Config {
45
49
  */
46
50
  export declare function apply(ctx: PluginContext, config?: Config): void;
47
51
  export { DirectPolling } from './engine/direct-polling.ts';
52
+ export { CipTransactions } from './engine/cip-transaction.ts';
48
53
  export { liveTools } from './tools/live.ts';
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { DirectPolling } from "./engine/direct-polling.js";
12
+ import { CipTransactions } from "./engine/cip-transaction.js";
12
13
  import { liveTools } from "./tools/live.js";
13
14
  import { PROJECTS_SERVICE } from "./index.js";
14
15
  /** Plugin name in a composition. */
@@ -39,10 +40,20 @@ export function apply(ctx, config = {}) {
39
40
  displayRefreshMs: config.displayRefreshMs ?? 250,
40
41
  displayMaxSamples: config.displayMaxSamples ?? 1200,
41
42
  });
43
+ const transactions = new CipTransactions(fileURLToPath(new URL('../cip-worker.mjs', import.meta.url)), {
44
+ timeout: config.requestTimeoutMs ?? 3000,
45
+ maxConcurrent: config.maxConcurrent ?? 2,
46
+ });
42
47
  const effect = ctx.effect;
43
- effect?.(() => () => { void polling.dispose(); });
44
- for (const definition of liveTools(session, polling, ctx))
48
+ effect?.(() => () => { void polling.dispose(); void transactions.dispose(); });
49
+ const extensions = {
50
+ transactions,
51
+ allowWrites: config.allowWrites === true,
52
+ maxBatchTags: config.maxBatchTags ?? 64,
53
+ };
54
+ for (const definition of liveTools(session, polling, ctx, extensions))
45
55
  ctx.tools.register(definition);
46
56
  }
47
57
  export { DirectPolling } from "./engine/direct-polling.js";
58
+ export { CipTransactions } from "./engine/cip-transaction.js";
48
59
  export { liveTools } from "./tools/live.js";
@@ -1,21 +1,32 @@
1
1
  /**
2
- * SDK-free live reads over EtherNet/IP.
2
+ * SDK-free EtherNet/IP surface: reads, identity, discovery and the two
3
+ * approval-gated writes.
3
4
  *
4
- * Two tools: a bounded foreground poll, and a session-owned trend job the chart
5
- * can follow without a model call per sample. Both read declared scalar tags,
6
- * never write, and state that live logic is not the exported logic.
5
+ * Reads never change the controller and need no approval. Writes do: a write is
6
+ * refused unless the row's config allows writes AND a human approves that exact
7
+ * operation, and its result is the value read back over CIP, not an assumption.
7
8
  *
8
9
  * @module dsh-engineering-l5x/tools/live
9
10
  */
10
11
  import type { PluginContext, ToolDefinition } from '../core/tool.ts';
11
12
  import type { ProjectSession } from '../session.ts';
12
13
  import { type DirectPolling } from '../engine/direct-polling.ts';
14
+ import { type CipTransactions } from '../engine/cip-transaction.ts';
15
+ /** Extra capabilities the live entry mounts beside polling. */
16
+ export interface LiveExtensions {
17
+ readonly transactions: CipTransactions;
18
+ /** Writes are refused unless the row config enables them. */
19
+ readonly allowWrites: boolean;
20
+ /** Largest accepted batch read. */
21
+ readonly maxBatchTags: number;
22
+ }
13
23
  /**
14
24
  * The live tools.
15
25
  *
16
26
  * @param session - Project handles for the calling chat.
17
27
  * @param polling - The polling engine and its configuration.
18
- * @param ctx - Plugin context that may provide the job service.
28
+ * @param ctx - Plugin context that may provide the job and approval services.
29
+ * @param extensions - One-shot transactions and the write switch.
19
30
  * @returns Tool definitions in registration order.
20
31
  */
21
- export declare function liveTools(session: ProjectSession, polling: DirectPolling, ctx: PluginContext): readonly ToolDefinition[];
32
+ export declare function liveTools(session: ProjectSession, polling: DirectPolling, ctx: PluginContext, extensions: LiveExtensions): readonly ToolDefinition[];
@@ -1,16 +1,20 @@
1
1
  /**
2
- * SDK-free live reads over EtherNet/IP.
2
+ * SDK-free EtherNet/IP surface: reads, identity, discovery and the two
3
+ * approval-gated writes.
3
4
  *
4
- * Two tools: a bounded foreground poll, and a session-owned trend job the chart
5
- * can follow without a model call per sample. Both read declared scalar tags,
6
- * never write, and state that live logic is not the exported logic.
5
+ * Reads never change the controller and need no approval. Writes do: a write is
6
+ * refused unless the row's config allows writes AND a human approves that exact
7
+ * operation, and its result is the value read back over CIP, not an assumption.
7
8
  *
8
9
  * @module dsh-engineering-l5x/tools/live
9
10
  */
10
11
  import { 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 both tools. */
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 service.
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
- 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 }),
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
- ip_address: string('Controller IPv4 address the human named. Example: "192.168.1.10".'),
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,