sylo-plc-comms 0.1.0

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 (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/extensions/index.ts +326 -0
  4. package/package.json +29 -0
  5. package/scripts/__pycache__/_cip_client.cpython-312.pyc +0 -0
  6. package/scripts/__pycache__/_download_allowlist.cpython-312.pyc +0 -0
  7. package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
  8. package/scripts/__pycache__/_opcua_client.cpython-312.pyc +0 -0
  9. package/scripts/__pycache__/cip_plc_info.cpython-312.pyc +0 -0
  10. package/scripts/__pycache__/cip_tag_list.cpython-312.pyc +0 -0
  11. package/scripts/__pycache__/cip_tag_read.cpython-312.pyc +0 -0
  12. package/scripts/__pycache__/cip_tag_write.cpython-312.pyc +0 -0
  13. package/scripts/__pycache__/opcua_browse.cpython-312.pyc +0 -0
  14. package/scripts/__pycache__/opcua_read.cpython-312.pyc +0 -0
  15. package/scripts/__pycache__/opcua_status.cpython-312.pyc +0 -0
  16. package/scripts/__pycache__/opcua_tag_list.cpython-312.pyc +0 -0
  17. package/scripts/__pycache__/opcua_write.cpython-312.pyc +0 -0
  18. package/scripts/_cip_client.py +139 -0
  19. package/scripts/_download_allowlist.py +111 -0
  20. package/scripts/_json_out.py +19 -0
  21. package/scripts/_opcua_client.py +142 -0
  22. package/scripts/cip_plc_info.py +57 -0
  23. package/scripts/cip_tag_list.py +99 -0
  24. package/scripts/cip_tag_read.py +130 -0
  25. package/scripts/cip_tag_write.py +169 -0
  26. package/scripts/opcua_browse.py +139 -0
  27. package/scripts/opcua_read.py +149 -0
  28. package/scripts/opcua_status.py +102 -0
  29. package/scripts/opcua_tag_list.py +160 -0
  30. package/scripts/opcua_write.py +212 -0
  31. package/scripts/requirements.txt +3 -0
  32. package/vendor/ciplogix/README.md +38 -0
  33. package/vendor/ciplogix/ciplogix-1.1.0-py3-none-any.whl +0 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yeti-Trix <131923258+Yeti-Trix@users.noreply.github.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # sylo-plc-comms
2
+
3
+ Sylo package: **PLC comms layer for Studio 5000/Logix** — CIP (EtherNet/IP via
4
+ `ciplogix`, vendored wheel) controller info + tag reads/writes, and OPC UA
5
+ (`asyncua`) browse/read/write against the controller's OPC UA server. **No
6
+ Logix Designer SDK required.**
7
+
8
+ Split out of `sylo-allen-bradley` on 2026-09-09. Sibling packages in this
9
+ bundle:
10
+
11
+ - `sylo-allen-bradley` — Logix Designer SDK wrapper (`.acd` upload/download, L5X export/import). SDK not bundled.
12
+ - `sylo-logicforge` — L5X parse/IO-scaffold, Parse Rules UI, bundled LogicForge backend.
13
+
14
+ ## Tools
15
+
16
+ | Tool | What it does |
17
+ |---|---|
18
+ | `plc_comms_cip_plc_info` | Full controller attributes (read-only CIP Identity) |
19
+ | `plc_comms_cip_tag_list` | Controller + program tag list (read-only) |
20
+ | `plc_comms_cip_tag_read` | Read one/many tags (pycomm3 syntax) |
21
+ | `plc_comms_cip_tag_write` | Write one/many tags — **allowlist-gated** |
22
+ | `plc_comms_opcua_status` | Probe the OPC UA server (endpoints, namespaces) |
23
+ | `plc_comms_opcua_browse` | Browse the address space (read-only) |
24
+ | `plc_comms_opcua_tag_list` | Recursive Tags-namespace listing (read-only) |
25
+ | `plc_comms_opcua_read` | Read nodes by node-id/tag path (read-only) |
26
+ | `plc_comms_opcua_write` | Write nodes — **allowlist-gated** |
27
+
28
+ ## Allowlist
29
+
30
+ Writes (`cip_tag_write`, `opcua_write`) are gated by the same operator-managed
31
+ download allowlist as project downloads. The **canonical file lives in
32
+ `sylo-logicforge`** at `packages/sylo-logicforge/assets/download-allowlist.json`
33
+ (this package's loader resolves it across the monorepo); env override:
34
+ `LOGICFORGE_DOWNLOAD_ALLOWLIST`.
35
+
36
+ ## Python
37
+
38
+ `ciplogix` installs on demand from `vendor/ciplogix/*.whl` (committed);
39
+ `asyncua` pip-installs on demand (see `scripts/requirements.txt`). Runs on the
40
+ same `SYLO_PYTHON` as the other controls packages — no SDK Python needed.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * sylo-plc-comms — PLC comms layer for Studio 5000/Logix.
3
+ *
4
+ * CIP (EtherNet/IP via ciplogix) controller info + tag reads/writes, and OPC UA
5
+ * (asyncua) browse/read/write. No Logix Designer SDK required. Split out of
6
+ * sylo-allen-bradley on 2026-09-09 (see the sylo-logicforge/sylo-allen-bradley
7
+ * packages for the SDK wrapper and L5X parse/IO-scaffold flows).
8
+ */
9
+ import { execFile } from 'node:child_process'
10
+ import { promisify } from 'node:util'
11
+ import { fileURLToPath } from 'node:url'
12
+ import path from 'node:path'
13
+
14
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
15
+ import { Type } from 'typebox'
16
+
17
+ const execFileAsync = promisify(execFile)
18
+
19
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
20
+ const SCRIPTS_DIR = path.join(PACKAGE_ROOT, 'scripts')
21
+
22
+ type ToolContentBlock = { type: 'text'; text: string }
23
+
24
+ // CIP (EtherNet/IP via ciplogix) and OPC UA (asyncua) scripts run on the same
25
+ // Python as the parse scripts (no Logix Designer SDK needed).
26
+
27
+ function resolvePythonInvocation(): { command: string; prefixArgs: string[] } {
28
+ const envPython = process.env.SYLO_PYTHON?.trim()
29
+ if (envPython) {
30
+ return { command: envPython, prefixArgs: [] }
31
+ }
32
+ return { command: process.platform === 'win32' ? 'python' : 'python3', prefixArgs: [] }
33
+ }
34
+
35
+ function toolError(text: string): { content: ToolContentBlock[] } {
36
+ return { content: [{ type: 'text', text }] }
37
+ }
38
+
39
+ function tail(text: string, lines = 12): string {
40
+ return text.trim().split('\n').slice(-lines).join('\n').trim()
41
+ }
42
+
43
+ type ExecOutput = { stdout: string; stderr: string }
44
+
45
+ async function execScript(scriptName: string, args: string[], timeoutMs: number): Promise<ExecOutput> {
46
+ const scriptPath = path.join(SCRIPTS_DIR, scriptName)
47
+ const { command, prefixArgs } = resolvePythonInvocation()
48
+ return execFileAsync(command, [...prefixArgs, scriptPath, ...args], {
49
+ cwd: PACKAGE_ROOT,
50
+ maxBuffer: 32 * 1024 * 1024,
51
+ windowsHide: true,
52
+ timeout: timeoutMs,
53
+ })
54
+ }
55
+
56
+ async function runPythonScript(
57
+ scriptName: string,
58
+ args: string[],
59
+ timeoutMs = 120_000,
60
+ ): Promise<{ content: ToolContentBlock[] }> {
61
+ try {
62
+ const { stdout, stderr } = await execScript(scriptName, args, timeoutMs)
63
+ let parsed: { ok?: boolean; error?: string; operator_chat?: string } | null = null
64
+ const trimmed = stdout.trim()
65
+ try {
66
+ parsed = JSON.parse(trimmed) as { ok?: boolean; error?: string; operator_chat?: string }
67
+ } catch {
68
+ /* scripts print {"ok": false, "error": ...} on failure paths — fall through */
69
+ }
70
+ if (!parsed) {
71
+ return toolError(tail(stdout) || stderr.trim() || `${scriptName} produced no output`)
72
+ }
73
+ if (parsed.ok === false) {
74
+ return toolError(parsed.error ?? `${scriptName} failed`)
75
+ }
76
+ if (typeof parsed.operator_chat === 'string' && parsed.operator_chat.trim()) {
77
+ return { content: [{ type: 'text', text: parsed.operator_chat.trim() }] }
78
+ }
79
+ return { content: [{ type: 'text', text: JSON.stringify(parsed, null, 2) }] }
80
+ } catch (err) {
81
+ // Non-zero exit: scripts print {"ok": false, "error": ...} before exiting 1 —
82
+ // surface that instead of Node's generic "Command failed" message.
83
+ const e = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string }
84
+ let parsed: { ok?: boolean; error?: string } | null = null
85
+ try {
86
+ parsed = JSON.parse(String(e.stdout ?? '').trim())
87
+ } catch {
88
+ parsed = null
89
+ }
90
+ if (parsed && typeof parsed.error === 'string' && parsed.error.trim()) {
91
+ return toolError(parsed.error.trim())
92
+ }
93
+ const detail = [
94
+ typeof e.stdout === 'string' ? tail(e.stdout) : '',
95
+ typeof e.stderr === 'string' ? tail(e.stderr) : '',
96
+ ]
97
+ .filter(Boolean)
98
+ .join('\n')
99
+ const message = err instanceof Error ? err.message : String(err)
100
+ return toolError(detail ? `${message}\n${detail}` : message)
101
+ }
102
+ }
103
+
104
+ export default function syloPlcCommsExtension(pi: ExtensionAPI): void {
105
+ // ---- CIP tag tools (EtherNet/IP via ciplogix) --------------------------------
106
+
107
+ pi.registerTool({
108
+ name: 'plc_comms_cip_plc_info',
109
+ label: 'PLC comms CIP PLC info',
110
+ description:
111
+ 'Full controller attributes via ciplogix (read-only CIP Identity): product name, vendor, revision, serial, keyswitch/mode, project name. No Logix Designer SDK required.',
112
+ parameters: Type.Object({
113
+ ip: Type.String({ description: 'Controller IPv4 address (e.g. 10.1.200.45)' }),
114
+ }),
115
+ async execute(_toolCallId, params) {
116
+ const ip = String(params.ip ?? '').trim()
117
+ if (!ip) return toolError('plc_comms_cip_plc_info requires ip.')
118
+ return runPythonScript('cip_plc_info.py', ['--ip', ip], 60_000)
119
+ },
120
+ })
121
+
122
+ pi.registerTool({
123
+ name: 'plc_comms_cip_tag_list',
124
+ label: 'PLC comms CIP tag list',
125
+ description:
126
+ 'List controller (and optionally program-scoped) tags from a Logix PLC via ciplogix (read-only). Returns tag name, type, data type, array size. Optionally filter by substring. No SDK required.',
127
+ parameters: Type.Object({
128
+ ip: Type.String({ description: 'Controller IPv4 address' }),
129
+ filter: Type.Optional(Type.String({ description: 'Case-insensitive tag-name substring filter' })),
130
+ include_programs: Type.Optional(
131
+ Type.Boolean({
132
+ description: 'Also list program-scoped tags (extra round trip per program)',
133
+ }),
134
+ ),
135
+ limit: Type.Optional(Type.Number({ description: 'Cap number of tags returned (0 = no cap)' })),
136
+ }),
137
+ async execute(_toolCallId, params) {
138
+ const ip = String(params.ip ?? '').trim()
139
+ if (!ip) return toolError('plc_comms_cip_tag_list requires ip.')
140
+ const args = ['--ip', ip]
141
+ const filter = String(params.filter ?? '').trim()
142
+ if (filter) args.push('--filter', filter)
143
+ if (params.include_programs === true) args.push('--include-programs')
144
+ if (typeof params.limit === 'number' && params.limit > 0) args.push('--limit', String(params.limit))
145
+ return runPythonScript('cip_tag_list.py', args, 180_000)
146
+ },
147
+ })
148
+
149
+ pi.registerTool({
150
+ name: 'plc_comms_cip_tag_read',
151
+ label: 'PLC comms CIP tag read',
152
+ description:
153
+ 'Read one or many Logix tags from a PLC via ciplogix (read-only). Pass an array of tag names; uses multi-service read. Bit/array/struct syntax follows pycomm3: MyTag, MyTag.0, MyArray[3], MyStruct.Member, Program:MainProgram.MyTag. No SDK required.',
154
+ parameters: Type.Object({
155
+ ip: Type.String({ description: 'Controller IPv4 address' }),
156
+ tags: Type.Optional(Type.Array(Type.String(), { description: 'Tag names to read' })),
157
+ }),
158
+ async execute(_toolCallId, params) {
159
+ const ip = String(params.ip ?? '').trim()
160
+ if (!ip) return toolError('plc_comms_cip_tag_read requires ip.')
161
+ const tags = Array.isArray(params.tags) ? params.tags.map((t) => String(t)).filter(Boolean) : []
162
+ if (!tags.length) return toolError('plc_comms_cip_tag_read requires a non-empty tags array.')
163
+ const args = ['--ip', ip, '--tags-json', JSON.stringify(tags)]
164
+ return runPythonScript('cip_tag_read.py', args, 120_000)
165
+ },
166
+ })
167
+
168
+ pi.registerTool({
169
+ name: 'plc_comms_cip_tag_write',
170
+ label: 'PLC comms CIP tag write',
171
+ description:
172
+ 'Write one or many Logix tags to a PLC via ciplogix. GATED by the operator-managed download allowlist — the target IP must be present and enabled. Pass an array of {tag, value} objects. No SDK required.',
173
+ parameters: Type.Object({
174
+ ip: Type.String({ description: 'Controller IPv4 address — must be in the allowlist' }),
175
+ writes: Type.Array(
176
+ Type.Object({
177
+ tag: Type.String({ description: 'Tag name (pycomm3 syntax; program scope via Program:..MyTag)' }),
178
+ value: Type.Unknown({ description: 'Value (JSON type; bool/int/float/str/list)' }),
179
+ }),
180
+ { description: 'Tag writes to perform' },
181
+ ),
182
+ dry_run: Type.Optional(Type.Boolean({ description: 'Validate against allowlist but do not send to PLC' })),
183
+ }),
184
+ async execute(_toolCallId, params) {
185
+ const ip = String(params.ip ?? '').trim()
186
+ if (!ip) return toolError('plc_comms_cip_tag_write requires ip.')
187
+ const writes = Array.isArray(params.writes) ? params.writes : []
188
+ if (!writes.length) return toolError('plc_comms_cip_tag_write requires a non-empty writes array.')
189
+ const args = ['--ip', ip, '--writes-json', JSON.stringify(writes)]
190
+ if (params.dry_run === true) args.push('--dry-run')
191
+ return runPythonScript('cip_tag_write.py', args, 120_000)
192
+ },
193
+ })
194
+
195
+ // ---- OPC UA client tools (asyncua) -------------------------------------------
196
+
197
+ pi.registerTool({
198
+ name: 'plc_comms_opcua_status',
199
+ label: 'PLC comms OPC UA status',
200
+ description:
201
+ 'Probe an OPC UA server (read-only): endpoints, security policies, namespaces, tags namespace index, server status. Use to confirm the server is reachable and discover the tags namespace before browse/read/write. Requires the PLC OPC UA server to be enabled (Studio 5000 CIP Security → OPC UA Server).',
202
+ parameters: Type.Object({
203
+ ip: Type.Optional(Type.String({ description: 'Controller IP (endpoint opc.tcp://IP:4840)' })),
204
+ endpoint: Type.Optional(Type.String({ description: 'Full opc.tcp:// endpoint (overrides ip)' })),
205
+ port: Type.Optional(Type.Number({ description: 'OPC UA port (default 4840)' })),
206
+ }),
207
+ async execute(_toolCallId, params) {
208
+ const endpoint = String(params.endpoint ?? '').trim()
209
+ const ip = String(params.ip ?? '').trim()
210
+ if (!endpoint && !ip) return toolError('plc_comms_opcua_status requires ip and/or endpoint.')
211
+ const args = ['--ip', ip || '0']
212
+ if (endpoint) args.push('--endpoint', endpoint)
213
+ if (typeof params.port === 'number') args.push('--port', String(params.port))
214
+ return runPythonScript('opcua_status.py', args, 60_000)
215
+ },
216
+ })
217
+
218
+ pi.registerTool({
219
+ name: 'plc_comms_opcua_browse',
220
+ label: 'PLC comms OPC UA browse',
221
+ description:
222
+ 'Browse an OPC UA address space node and return its children (read-only): node id, browse/display name, node class, data type, and value for Variable nodes. Default starting node is the Objects folder. Pass a node-id spec (ns=2;s=...) to browse a specific node.',
223
+ parameters: Type.Object({
224
+ ip: Type.Optional(Type.String({ description: 'Controller IP' })),
225
+ endpoint: Type.Optional(Type.String({ description: 'Full opc.tcp:// endpoint (overrides ip)' })),
226
+ node: Type.Optional(Type.String({ description: 'Node-id spec to browse (default Objects folder)' })),
227
+ namespace: Type.Optional(Type.Number({ description: 'Default ns for bare node specs (auto: Tags ns)' })),
228
+ }),
229
+ async execute(_toolCallId, params) {
230
+ const endpoint = String(params.endpoint ?? '').trim()
231
+ const ip = String(params.ip ?? '').trim()
232
+ if (!endpoint && !ip) return toolError('plc_comms_opcua_browse requires ip and/or endpoint.')
233
+ const args = ['--ip', ip || '0']
234
+ if (endpoint) args.push('--endpoint', endpoint)
235
+ const node = String(params.node ?? '').trim()
236
+ if (node) args.push('--node', node)
237
+ if (typeof params.namespace === 'number') args.push('--namespace', String(params.namespace))
238
+ return runPythonScript('opcua_browse.py', args, 90_000)
239
+ },
240
+ })
241
+
242
+ pi.registerTool({
243
+ name: 'plc_comms_opcua_tag_list',
244
+ label: 'PLC comms OPC UA tag list',
245
+ description:
246
+ 'List OPC UA tags by recursively browsing the controller Tags namespace (read-only). Returns tag node id, browse path, tag path (string NodeId identifier), data type, array dimensions. Optional substring filter. Capped by max-depth/max-nodes. Requires the OPC UA server enabled on the PLC.',
247
+ parameters: Type.Object({
248
+ ip: Type.Optional(Type.String({ description: 'Controller IP' })),
249
+ endpoint: Type.Optional(Type.String({ description: 'Full opc.tcp:// endpoint (overrides ip)' })),
250
+ filter: Type.Optional(Type.String({ description: 'Case-insensitive substring filter on tag/browse name' })),
251
+ max_depth: Type.Optional(Type.Number({ description: 'Max browse depth (default 4)' })),
252
+ max_nodes: Type.Optional(Type.Number({ description: 'Cap on collected tag nodes (default 2000)' })),
253
+ }),
254
+ async execute(_toolCallId, params) {
255
+ const endpoint = String(params.endpoint ?? '').trim()
256
+ const ip = String(params.ip ?? '').trim()
257
+ if (!endpoint && !ip) return toolError('plc_comms_opcua_tag_list requires ip and/or endpoint.')
258
+ const args = ['--ip', ip || '0']
259
+ if (endpoint) args.push('--endpoint', endpoint)
260
+ const filter = String(params.filter ?? '').trim()
261
+ if (filter) args.push('--filter', filter)
262
+ if (typeof params.max_depth === 'number') args.push('--max-depth', String(params.max_depth))
263
+ if (typeof params.max_nodes === 'number') args.push('--max-nodes', String(params.max_nodes))
264
+ return runPythonScript('opcua_tag_list.py', args, 180_000)
265
+ },
266
+ })
267
+
268
+ pi.registerTool({
269
+ name: 'plc_comms_opcua_read',
270
+ label: 'PLC comms OPC UA read',
271
+ description:
272
+ 'Read one or many OPC UA nodes by node-id spec or tag path (read-only). Bare names resolve to the Tags namespace (ns=2 on Rockwell). Returns per-node value, data type, source timestamp. Requires the OPC UA server enabled on the PLC.',
273
+ parameters: Type.Object({
274
+ ip: Type.Optional(Type.String({ description: 'Controller IP' })),
275
+ endpoint: Type.Optional(Type.String({ description: 'Full opc.tcp:// endpoint (overrides ip)' })),
276
+ nodes: Type.Array(Type.String(), { description: 'Node-id specs or bare tag names to read' }),
277
+ namespace: Type.Optional(Type.Number({ description: 'Default ns for bare names (auto: Tags ns)' })),
278
+ }),
279
+ async execute(_toolCallId, params) {
280
+ const endpoint = String(params.endpoint ?? '').trim()
281
+ const ip = String(params.ip ?? '').trim()
282
+ if (!endpoint && !ip) return toolError('plc_comms_opcua_read requires ip and/or endpoint.')
283
+ const nodes = Array.isArray(params.nodes) ? params.nodes.map((n) => String(n)).filter(Boolean) : []
284
+ if (!nodes.length) return toolError('plc_comms_opcua_read requires a non-empty nodes array.')
285
+ const args = ['--ip', ip || '0', '--nodes-json', JSON.stringify(nodes)]
286
+ if (endpoint) args.push('--endpoint', endpoint)
287
+ if (typeof params.namespace === 'number') args.push('--namespace', String(params.namespace))
288
+ return runPythonScript('opcua_read.py', args, 90_000)
289
+ },
290
+ })
291
+
292
+ pi.registerTool({
293
+ name: 'plc_comms_opcua_write',
294
+ label: 'PLC comms OPC UA write',
295
+ description:
296
+ 'Write one or many OPC UA nodes by node-id spec or tag path. GATED by the operator-managed download allowlist (endpoint host IP must be present and enabled). Pass an array of {node, value, variant_type?}. variant_type (e.g. Int32, Boolean, Float) is optional — asyncua infers from the Python value for common scalars. Requires the OPC UA server enabled on the PLC.',
297
+ parameters: Type.Object({
298
+ ip: Type.Optional(Type.String({ description: 'Controller IP — must be in the allowlist' })),
299
+ endpoint: Type.Optional(Type.String({ description: 'Full opc.tcp:// endpoint (overrides ip)' })),
300
+ writes: Type.Array(
301
+ Type.Object({
302
+ node: Type.String({ description: 'Node-id spec or bare tag name (Tags ns)' }),
303
+ value: Type.Unknown({ description: 'Value (JSON type)' }),
304
+ variant_type: Type.Optional(
305
+ Type.String({ description: 'asyncua VariantType name, e.g. Int32, Boolean, Float, Double, String' }),
306
+ ),
307
+ }),
308
+ { description: 'OPC UA node writes to perform' },
309
+ ),
310
+ namespace: Type.Optional(Type.Number({ description: 'Default ns for bare names (auto: Tags ns)' })),
311
+ dry_run: Type.Optional(Type.Boolean({ description: 'Validate against allowlist but do not send' })),
312
+ }),
313
+ async execute(_toolCallId, params) {
314
+ const endpoint = String(params.endpoint ?? '').trim()
315
+ const ip = String(params.ip ?? '').trim()
316
+ if (!endpoint && !ip) return toolError('plc_comms_opcua_write requires ip and/or endpoint.')
317
+ const writes = Array.isArray(params.writes) ? params.writes : []
318
+ if (!writes.length) return toolError('plc_comms_opcua_write requires a non-empty writes array.')
319
+ const args = ['--ip', ip || '0', '--writes-json', JSON.stringify(writes)]
320
+ if (endpoint) args.push('--endpoint', endpoint)
321
+ if (typeof params.namespace === 'number') args.push('--namespace', String(params.namespace))
322
+ if (params.dry_run === true) args.push('--dry-run')
323
+ return runPythonScript('opcua_write.py', args, 90_000)
324
+ },
325
+ })
326
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "sylo-plc-comms",
3
+ "version": "0.1.0",
4
+ "description": "PLC comms layer for Studio 5000/Logix — CIP (EtherNet/IP via ciplogix, vendored wheel) controller info + tag reads/writes, and OPC UA (asyncua) browse/read/write. No Logix Designer SDK required.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "extensions",
11
+ "scripts",
12
+ "vendor",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {},
17
+ "pi": {
18
+ "extensions": [
19
+ "./extensions/index.ts"
20
+ ]
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "^0.84.2"
24
+ },
25
+ "dependencies": {
26
+ "typebox": "^1.1.24"
27
+ },
28
+ "license": "MIT"
29
+ }
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Shared ciplogix (EtherNet/IP) connect helper for sylo-plc-comms scripts.
3
+
4
+ Handles vendored-wheel install-on-demand and provides a thin connect wrapper.
5
+ Does NOT touch the Logix Designer SDK. Read/write path uses pycomm3-compatible
6
+ LogixDriver API (ciplogix is a hardened pycomm3 fork).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from _json_out import emit_error
17
+
18
+
19
+ def ensure_ciplogix() -> str:
20
+ """pip-install the vendored ciplogix wheel (+ pycomm3 dep) if not importable.
21
+
22
+ Returns a status string for diagnostics.
23
+ """
24
+ try:
25
+ import ciplogix # noqa: F401
26
+
27
+ return "already_importable"
28
+ except ImportError:
29
+ pass
30
+
31
+ root = Path(__file__).resolve().parent.parent
32
+ wheels = list((root / "vendor" / "ciplogix").glob("ciplogix-*.whl"))
33
+ if not wheels:
34
+ raise RuntimeError(
35
+ "ciplogix wheel not found under vendor/ciplogix/. "
36
+ "Restore from packages/sylo-plc-comms/vendor/ciplogix/."
37
+ )
38
+ subprocess.check_call(
39
+ [sys.executable, "-m", "pip", "install", str(wheels[0]), "--quiet"],
40
+ stdout=subprocess.DEVNULL,
41
+ stderr=subprocess.DEVNULL,
42
+ )
43
+ return str(wheels[0])
44
+
45
+
46
+ def open_driver(ip: str, init_tags: bool = False):
47
+ """Ensure ciplogix is importable, open a LogixDriver, return the live plc handle.
48
+
49
+ Caller is responsible for plc.close() (use a try/finally). On connection
50
+ failure this calls emit_error and exits (script convention).
51
+ """
52
+ try:
53
+ ensure_ciplogix()
54
+ except Exception as exc:
55
+ emit_error(f"ciplogix setup failed: {exc}")
56
+
57
+ from ciplogix import LogixDriver
58
+
59
+ plc = LogixDriver(ip, init_tags=init_tags)
60
+ try:
61
+ plc.open()
62
+ except Exception as exc:
63
+ emit_error(f"connection failed to {ip}: {exc}")
64
+
65
+ if not plc.connected:
66
+ emit_error(f"connection did not open to {ip}")
67
+
68
+ return plc
69
+
70
+
71
+ def normalize_tag_entry(t: dict[str, Any]) -> dict[str, Any]:
72
+ """Reduce a ciplogix tag dict to the fields the agent/UI needs."""
73
+ return {
74
+ "tag_name": t.get("tag_name"),
75
+ "tag_type": t.get("tag_type"),
76
+ "data_type": t.get("data_type_name") or t.get("data_type"),
77
+ "array_size": t.get("array") or None,
78
+ "program": t.get("program") or None,
79
+ "instance_id": t.get("instance_id"),
80
+ "dimensions": t.get("dimensions"),
81
+ }
82
+
83
+
84
+ def _jsonable(v):
85
+ """Coerce common non-JSON ciplogix values (bytes, datetime, enum)."""
86
+ if v is None:
87
+ return None
88
+ if isinstance(v, (str, int, float, bool)):
89
+ return v
90
+ if isinstance(v, bytes):
91
+ try:
92
+ return v.decode("utf-8", errors="replace")
93
+ except Exception:
94
+ return v.hex()
95
+ if isinstance(v, (list, tuple)):
96
+ return [_jsonable(x) for x in v]
97
+ if isinstance(v, dict):
98
+ return {str(k): _jsonable(val) for k, val in v.items()}
99
+ return str(v)
100
+
101
+
102
+ def plc_info_dict(plc) -> dict[str, Any]:
103
+ """Full controller attributes via ciplogix get_plc_info + extras."""
104
+ info = plc.get_plc_info()
105
+ keyswitch = str(info.get("keyswitch", "UNKNOWN") or "UNKNOWN").upper()
106
+ key_position = (
107
+ "REM" if keyswitch.startswith("REMOTE")
108
+ else ("RUN" if keyswitch == "RUN" else ("PROG" if keyswitch == "PROG" else None))
109
+ )
110
+ mode = (
111
+ "RUN" if "RUN" in keyswitch
112
+ else ("PROGRAM" if "PROG" in keyswitch else None)
113
+ )
114
+ out = {
115
+ "ip": str(getattr(plc, "_ip", "") or ""),
116
+ "product_name": _jsonable(info.get("product_name")),
117
+ "vendor": _jsonable(info.get("vendor")),
118
+ "revision": _jsonable(info.get("revision")),
119
+ "serial": _jsonable(info.get("serial")),
120
+ "device_type": _jsonable(info.get("device_type")),
121
+ "product_code": _jsonable(info.get("product_code")),
122
+ "keyswitch": keyswitch,
123
+ "key_position": key_position,
124
+ "mode": mode,
125
+ "major_revision": _jsonable(info.get("major_revision")),
126
+ "minor_revision": _jsonable(info.get("minor_revision")),
127
+ "status": _jsonable(info.get("status")),
128
+ "state": _jsonable(info.get("state")),
129
+ "name": _jsonable(getattr(plc, "name", None)),
130
+ "project_name": _jsonable(getattr(plc, "project_name", None)),
131
+ }
132
+ # Some ciplogix builds expose controller attributes via .get_config()
133
+ try:
134
+ cfg = plc.get_config() # type: ignore[attr-defined]
135
+ if isinstance(cfg, dict):
136
+ out["config"] = _jsonable(cfg)
137
+ except Exception:
138
+ pass
139
+ return out