sylo-allen-bradley 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.
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/extensions/index.ts +299 -0
- package/package.json +28 -0
- package/scripts/__pycache__/_download_allowlist.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_run_yaml.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_sdk_import.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_sdk_paths.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_sdk_plc.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sdk_download_to_plc.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sdk_export_l5x.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sdk_import_l5x.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sdk_upload_from_plc.cpython-312.pyc +0 -0
- package/scripts/_download_allowlist.py +111 -0
- package/scripts/_json_out.py +19 -0
- package/scripts/_run_yaml.py +76 -0
- package/scripts/_sdk_import.py +26 -0
- package/scripts/_sdk_paths.py +173 -0
- package/scripts/_sdk_plc.py +45 -0
- package/scripts/sdk_download_to_plc.py +293 -0
- package/scripts/sdk_export_l5x.py +111 -0
- package/scripts/sdk_import_l5x.py +89 -0
- package/scripts/sdk_upload_from_plc.py +143 -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,37 @@
|
|
|
1
|
+
# sylo-allen-bradley
|
|
2
|
+
|
|
3
|
+
Sylo package: **Studio 5000 Logix Designer SDK wrapper** — controller
|
|
4
|
+
upload/download and `.acd`↔L5X export/import (`allen_bradley_sdk_*` tools).
|
|
5
|
+
**The SDK itself is not bundled and cannot be redistributed** — it ships with
|
|
6
|
+
the licensed Studio 5000 v36+ installation (Windows, Python 3.12 only) and
|
|
7
|
+
requires a valid Studio 5000 license.
|
|
8
|
+
|
|
9
|
+
Split out of the old monolith on 2026-09-09:
|
|
10
|
+
|
|
11
|
+
- **`sylo-plc-comms`** — CIP/OPC UA tag tools (`plc_comms_*`). No SDK required.
|
|
12
|
+
- **`sylo-logicforge`** — L5X parse/review/IO-scaffold, Parse Rules UI, bundled LogicForge backend, canonical download allowlist.
|
|
13
|
+
- This package — SDK tools only: `allen_bradley_sdk_upload_from_plc`, `allen_bradley_sdk_download_to_plc`, `allen_bradley_sdk_export_l5x`, `allen_bradley_sdk_import_l5x`.
|
|
14
|
+
|
|
15
|
+
## Logix Designer SDK wheel (not bundled)
|
|
16
|
+
|
|
17
|
+
The Rockwell-proprietary `logix_designer_sdk` Python wheel ships with your own
|
|
18
|
+
licensed Studio 5000 v36+ install. Resolution order (`scripts/_sdk_paths.py`):
|
|
19
|
+
|
|
20
|
+
1. `LOGIX_DESIGNER_SDK_WHEEL` environment variable — explicit wheel path
|
|
21
|
+
2. `LOGIX_DESIGNER_SDK_SITE` — unpacked SDK tree
|
|
22
|
+
3. A wheel dropped under `packages/sylo-logicforge/vendor/logicforge/` (local only, not committed)
|
|
23
|
+
4. Legacy sibling checkout (`~/Documents/GitHub/sylo-allen-bradley/`)
|
|
24
|
+
5. `LOGICFORGE_SOURCE` dev-checkout overlay
|
|
25
|
+
6. Rockwell install locations (`C:\Program Files (x86)\Rockwell Software\...`)
|
|
26
|
+
|
|
27
|
+
Without the SDK the package still loads — only the direct `.acd` operations
|
|
28
|
+
report that the SDK is missing. Use `sylo-plc-comms` for live tag reads/writes
|
|
29
|
+
(no SDK) and `sylo-logicforge` for L5X parse/IO-scaffold work.
|
|
30
|
+
|
|
31
|
+
## Downloads are allowlist-gated
|
|
32
|
+
|
|
33
|
+
`allen_bradley_sdk_download_to_plc` is the only tool that writes to a live
|
|
34
|
+
controller project. It is hard-gated by the operator-managed allowlist whose
|
|
35
|
+
canonical file lives in `sylo-logicforge`
|
|
36
|
+
(`packages/sylo-logicforge/assets/download-allowlist.json`) — the agent cannot
|
|
37
|
+
download to any IP not present and enabled there.
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sylo-allen-bradley — Studio 5000 Logix Designer SDK wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Controller upload/download and .acd ↔ L5X export/import via the Rockwell
|
|
5
|
+
* Logix Designer SDK. The SDK itself is NOT bundled — it ships with the
|
|
6
|
+
* licensed Studio 5000 v36+ install (wheel via LOGIX_DESIGNER_SDK_WHEEL /
|
|
7
|
+
* LOGIX_DESIGNER_SDK_SITE / vendor drop-spot). Split out of the old
|
|
8
|
+
* sylo-allen-bradley monolith on 2026-09-09: PLC comms moved to
|
|
9
|
+
* sylo-plc-comms, L5X parse/IO-scaffold to sylo-logicforge.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
12
|
+
import { promisify } from 'node:util'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
17
|
+
import { Type } from 'typebox'
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile)
|
|
20
|
+
|
|
21
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
22
|
+
const SCRIPTS_DIR = path.join(PACKAGE_ROOT, 'scripts')
|
|
23
|
+
|
|
24
|
+
type ToolContentBlock = { type: 'text'; text: string }
|
|
25
|
+
|
|
26
|
+
// All registered tools are SDK-backed — run them on the SDK Python (3.12) so
|
|
27
|
+
// logix_designer_sdk imports (SYLO_SDK_PYTHON / py -3.12 on Windows).
|
|
28
|
+
const SDK_SCRIPTS = new Set([
|
|
29
|
+
'sdk_export_l5x.py',
|
|
30
|
+
'sdk_import_l5x.py',
|
|
31
|
+
'sdk_upload_from_plc.py',
|
|
32
|
+
'sdk_download_to_plc.py',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
function resolvePythonInvocation(sdk = false): { command: string; prefixArgs: string[] } {
|
|
36
|
+
if (sdk) {
|
|
37
|
+
const sdkPython = process.env.SYLO_SDK_PYTHON?.trim()
|
|
38
|
+
if (sdkPython) {
|
|
39
|
+
return { command: sdkPython, prefixArgs: [] }
|
|
40
|
+
}
|
|
41
|
+
if (process.platform === 'win32') {
|
|
42
|
+
return { command: 'py', prefixArgs: ['-3.12'] }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const envPython = process.env.SYLO_PYTHON?.trim()
|
|
47
|
+
if (envPython) {
|
|
48
|
+
return { command: envPython, prefixArgs: [] }
|
|
49
|
+
}
|
|
50
|
+
return { command: process.platform === 'win32' ? 'python' : 'python3', prefixArgs: [] }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toolError(text: string): { content: ToolContentBlock[] } {
|
|
54
|
+
return { content: [{ type: 'text', text }] }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Scripts emit JSON as the last thing on stdout, but the Logix Designer SDK's
|
|
59
|
+
* StdOutEventLogger can print INFO lines before it. Parse the trailing JSON
|
|
60
|
+
* object instead of assuming the whole stream is JSON.
|
|
61
|
+
*/
|
|
62
|
+
function parseTrailingJson(stdout: string): Record<string, unknown> | null {
|
|
63
|
+
const trimmed = stdout.trim()
|
|
64
|
+
if (!trimmed) return null
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(trimmed) as Record<string, unknown>
|
|
67
|
+
} catch {
|
|
68
|
+
/* fall through — find last JSON object in mixed output */
|
|
69
|
+
}
|
|
70
|
+
let idx = trimmed.lastIndexOf('\n{')
|
|
71
|
+
while (idx >= 0) {
|
|
72
|
+
const candidate = trimmed.slice(idx + 1)
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(candidate) as Record<string, unknown>
|
|
75
|
+
} catch {
|
|
76
|
+
idx = trimmed.lastIndexOf('\n{', idx - 1)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tail(text: string, lines = 12): string {
|
|
83
|
+
return text.trim().split('\n').slice(-lines).join('\n').trim()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type ExecOutput = { stdout: string; stderr: string }
|
|
87
|
+
|
|
88
|
+
async function execScript(
|
|
89
|
+
scriptName: string,
|
|
90
|
+
args: string[],
|
|
91
|
+
timeoutMs: number,
|
|
92
|
+
): Promise<ExecOutput> {
|
|
93
|
+
const scriptPath = path.join(SCRIPTS_DIR, scriptName)
|
|
94
|
+
const { command, prefixArgs } = resolvePythonInvocation(SDK_SCRIPTS.has(scriptName))
|
|
95
|
+
const sdk = SDK_SCRIPTS.has(scriptName)
|
|
96
|
+
return execFileAsync(command, [...prefixArgs, scriptPath, ...args], {
|
|
97
|
+
cwd: PACKAGE_ROOT,
|
|
98
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
timeout: timeoutMs,
|
|
101
|
+
env: {
|
|
102
|
+
...process.env,
|
|
103
|
+
...(sdk && process.platform === 'win32' && !process.env.SYLO_PYTHON
|
|
104
|
+
? { PYTHONIOENCODING: 'utf-8' }
|
|
105
|
+
: {}),
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runPythonScript(
|
|
111
|
+
scriptName: string,
|
|
112
|
+
args: string[],
|
|
113
|
+
timeoutMs = 120_000,
|
|
114
|
+
): Promise<{ content: ToolContentBlock[] }> {
|
|
115
|
+
try {
|
|
116
|
+
const { stdout, stderr } = await execScript(scriptName, args, timeoutMs)
|
|
117
|
+
const parsed = parseTrailingJson(stdout) as
|
|
118
|
+
| { ok?: boolean; error?: string; operator_chat?: string }
|
|
119
|
+
| null
|
|
120
|
+
if (!parsed) {
|
|
121
|
+
return toolError(tail(stdout) || stderr.trim() || `${scriptName} produced no output`)
|
|
122
|
+
}
|
|
123
|
+
if (parsed.ok === false) {
|
|
124
|
+
return toolError(parsed.error ?? `${scriptName} failed`)
|
|
125
|
+
}
|
|
126
|
+
if (typeof parsed.operator_chat === 'string' && parsed.operator_chat.trim()) {
|
|
127
|
+
return { content: [{ type: 'text', text: parsed.operator_chat.trim() }] }
|
|
128
|
+
}
|
|
129
|
+
return { content: [{ type: 'text', text: JSON.stringify(parsed, null, 2) }] }
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// Non-zero exit: scripts print {"ok": false, "error": ...} before exiting 1 —
|
|
132
|
+
// surface that instead of Node's generic "Command failed" message.
|
|
133
|
+
const e = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string }
|
|
134
|
+
const parsed = typeof e.stdout === 'string' ? parseTrailingJson(e.stdout) : null
|
|
135
|
+
if (parsed && typeof parsed.error === 'string' && parsed.error.trim()) {
|
|
136
|
+
return toolError(parsed.error.trim())
|
|
137
|
+
}
|
|
138
|
+
const detail = [
|
|
139
|
+
typeof e.stdout === 'string' ? tail(e.stdout) : '',
|
|
140
|
+
typeof e.stderr === 'string' ? tail(e.stderr) : '',
|
|
141
|
+
]
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.join('\n')
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
145
|
+
return toolError(detail ? `${message}\n${detail}` : message)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default function syloAllenBradleySdkExtension(pi: ExtensionAPI): void {
|
|
150
|
+
pi.registerTool({
|
|
151
|
+
name: 'allen_bradley_sdk_upload_from_plc',
|
|
152
|
+
label: 'Allen-Bradley SDK upload from PLC',
|
|
153
|
+
description:
|
|
154
|
+
'Windows + Logix Designer SDK: pull the controller project into a new offline `.acd` (Rockwell "upload"). ' +
|
|
155
|
+
'Pass controller `ip` or full `comm_path`. Saves to `output_path` and/or creates a run folder. ' +
|
|
156
|
+
'Read-only toward the PLC — there is no upload-to-PLC risk; downloads are separate. ' +
|
|
157
|
+
'Follow with allen_bradley_sdk_export_l5x to read logic.',
|
|
158
|
+
parameters: Type.Object({
|
|
159
|
+
ip: Type.Optional(
|
|
160
|
+
Type.String({ description: 'Controller IPv4 address (e.g. 192.168.1.10)' }),
|
|
161
|
+
),
|
|
162
|
+
comm_path: Type.Optional(
|
|
163
|
+
Type.String({
|
|
164
|
+
description:
|
|
165
|
+
'Full Rockwell communications path (overrides ip), e.g. AB_ETHIP-1\\10.0.0.1\\Backplane\\0',
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
output_path: Type.Optional(Type.String({ description: 'Output .acd file path' })),
|
|
169
|
+
run_dir: Type.Optional(Type.String({ description: 'Existing run folder from logicforge_run_prepare' })),
|
|
170
|
+
project_dir: Type.Optional(
|
|
171
|
+
Type.String({ description: 'Create runs/<run-id>/ and save working/<PLC_*.acd>' }),
|
|
172
|
+
),
|
|
173
|
+
run_id: Type.Optional(Type.String({ description: 'Run id when using project_dir' })),
|
|
174
|
+
}),
|
|
175
|
+
async execute(_toolCallId, params) {
|
|
176
|
+
const ip = String(params.ip ?? '').trim()
|
|
177
|
+
const commPath = String(params.comm_path ?? '').trim()
|
|
178
|
+
if (!ip && !commPath) {
|
|
179
|
+
return toolError('allen_bradley_sdk_upload_from_plc requires ip and/or comm_path.')
|
|
180
|
+
}
|
|
181
|
+
const args: string[] = []
|
|
182
|
+
if (ip) args.push('--ip', ip)
|
|
183
|
+
if (commPath) args.push('--comm-path', commPath)
|
|
184
|
+
const output = String(params.output_path ?? '').trim()
|
|
185
|
+
const runDir = String(params.run_dir ?? '').trim()
|
|
186
|
+
const projectDir = String(params.project_dir ?? '').trim()
|
|
187
|
+
const runId = String(params.run_id ?? '').trim()
|
|
188
|
+
if (output) args.push('--output', output)
|
|
189
|
+
if (runDir) args.push('--run-dir', runDir)
|
|
190
|
+
if (projectDir) args.push('--project-dir', projectDir)
|
|
191
|
+
if (runId) args.push('--run-id', runId)
|
|
192
|
+
if (!output && !runDir && !projectDir) {
|
|
193
|
+
return toolError(
|
|
194
|
+
'allen_bradley_sdk_upload_from_plc requires output_path, run_dir, and/or project_dir for the saved .acd.',
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
return runPythonScript('sdk_upload_from_plc.py', args, 600_000)
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
pi.registerTool({
|
|
202
|
+
name: 'allen_bradley_sdk_download_to_plc',
|
|
203
|
+
label: 'Allen-Bradley SDK download to PLC',
|
|
204
|
+
description:
|
|
205
|
+
'Windows + Logix Designer SDK: push the working .acd project to a controller (Rockwell "download"). ' +
|
|
206
|
+
'GATED by the operator-managed download allowlist — the agent cannot download to any IP not present ' +
|
|
207
|
+
'and enabled there, even with operator permission, and cannot edit the allowlist. ' +
|
|
208
|
+
'Auto-switches the controller to Program mode first if the key is in REM; refuses if the key is in hard RUN. ' +
|
|
209
|
+
'Leaves the controller in the configured post_download_mode (Program or Run) when the key is in REM.',
|
|
210
|
+
parameters: Type.Object({
|
|
211
|
+
ip: Type.Optional(
|
|
212
|
+
Type.String({ description: 'Controller IPv4 address — must be in the download allowlist' }),
|
|
213
|
+
),
|
|
214
|
+
comm_path: Type.Optional(
|
|
215
|
+
Type.String({
|
|
216
|
+
description:
|
|
217
|
+
'Full Rockwell communications path (overrides ip), e.g. AB_ETHIP-1\\10.0.0.1\\Backplane\\0',
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
acd_path: Type.Optional(Type.String({ description: 'Working .acd to push (or use run_dir)' })),
|
|
221
|
+
run_dir: Type.Optional(
|
|
222
|
+
Type.String({ description: 'Run folder from logicforge_run_prepare (uses working/*.dev.acd)' }),
|
|
223
|
+
),
|
|
224
|
+
}),
|
|
225
|
+
async execute(_toolCallId, params) {
|
|
226
|
+
const ip = String(params.ip ?? '').trim()
|
|
227
|
+
const commPath = String(params.comm_path ?? '').trim()
|
|
228
|
+
if (!ip && !commPath) {
|
|
229
|
+
return toolError('allen_bradley_sdk_download_to_plc requires ip and/or comm_path.')
|
|
230
|
+
}
|
|
231
|
+
const args: string[] = []
|
|
232
|
+
if (ip) args.push('--ip', ip)
|
|
233
|
+
if (commPath) args.push('--comm-path', commPath)
|
|
234
|
+
const acd = String(params.acd_path ?? '').trim()
|
|
235
|
+
const runDir = String(params.run_dir ?? '').trim()
|
|
236
|
+
if (acd) args.push('--acd', acd)
|
|
237
|
+
if (runDir) args.push('--run-dir', runDir)
|
|
238
|
+
if (!acd && !runDir) {
|
|
239
|
+
return toolError('allen_bradley_sdk_download_to_plc requires acd_path and/or run_dir.')
|
|
240
|
+
}
|
|
241
|
+
return runPythonScript('sdk_download_to_plc.py', args, 600_000)
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
pi.registerTool({
|
|
246
|
+
name: 'allen_bradley_sdk_export_l5x',
|
|
247
|
+
label: 'Allen-Bradley SDK export ACD to L5X',
|
|
248
|
+
description:
|
|
249
|
+
'Windows + Logix Designer SDK: export `.acd` → `.L5X` (save_as, detailed_l5x) so the agent can read and edit project logic as XML. Pass run_dir or acd + output paths. Set LOGIX_DESIGNER_SDK_WHEEL if import fails. Parse the export with logicforge_parse_l5x (sylo-logicforge).',
|
|
250
|
+
parameters: Type.Object({
|
|
251
|
+
acd_path: Type.Optional(Type.String({ description: 'Path to .acd (or use run_dir working copy)' })),
|
|
252
|
+
output_path: Type.Optional(Type.String({ description: 'Output .l5x; default run_dir/exports/iter-0/controller.l5x' })),
|
|
253
|
+
run_dir: Type.Optional(Type.String({ description: 'Run folder from logicforge_run_prepare' })),
|
|
254
|
+
detailed_l5x: Type.Optional(Type.Boolean({ description: 'Default true' })),
|
|
255
|
+
}),
|
|
256
|
+
async execute(_toolCallId, params) {
|
|
257
|
+
const args: string[] = []
|
|
258
|
+
const acd = String(params.acd_path ?? '').trim()
|
|
259
|
+
const output = String(params.output_path ?? '').trim()
|
|
260
|
+
const runDir = String(params.run_dir ?? '').trim()
|
|
261
|
+
if (acd) args.push('--acd', acd)
|
|
262
|
+
if (output) args.push('--output', output)
|
|
263
|
+
if (runDir) args.push('--run-dir', runDir)
|
|
264
|
+
if (params.detailed_l5x === false) args.push('--no-detailed-l5x')
|
|
265
|
+
if (!acd && !runDir) {
|
|
266
|
+
return toolError('allen_bradley_sdk_export_l5x requires acd_path and/or run_dir.')
|
|
267
|
+
}
|
|
268
|
+
return runPythonScript('sdk_export_l5x.py', args, 300_000)
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
pi.registerTool({
|
|
273
|
+
name: 'allen_bradley_sdk_import_l5x',
|
|
274
|
+
label: 'Allen-Bradley SDK partial import L5X',
|
|
275
|
+
description:
|
|
276
|
+
'Windows + Logix Designer SDK: partial_import_from_xml_file — merge an L5X fragment (tags, routines, modules, etc.) into `.acd`. Default XPath Controller/Tags; use scoped XPath for programs/routines.',
|
|
277
|
+
parameters: Type.Object({
|
|
278
|
+
l5x_path: Type.String({ description: 'L5X fragment path' }),
|
|
279
|
+
run_dir: Type.Optional(Type.String({ description: 'Run folder (uses the working .acd copy)' })),
|
|
280
|
+
acd_path: Type.Optional(Type.String({ description: 'Target .acd (overrides run_dir working copy)' })),
|
|
281
|
+
xpath: Type.Optional(Type.String({ description: 'Default Controller/Tags' })),
|
|
282
|
+
}),
|
|
283
|
+
async execute(_toolCallId, params) {
|
|
284
|
+
const l5x = String(params.l5x_path ?? '').trim()
|
|
285
|
+
if (!l5x) return toolError('allen_bradley_sdk_import_l5x requires l5x_path.')
|
|
286
|
+
const args = ['--l5x', l5x]
|
|
287
|
+
const runDir = String(params.run_dir ?? '').trim()
|
|
288
|
+
const acd = String(params.acd_path ?? '').trim()
|
|
289
|
+
const xpath = String(params.xpath ?? '').trim()
|
|
290
|
+
if (runDir) args.push('--run-dir', runDir)
|
|
291
|
+
if (acd) args.push('--acd', acd)
|
|
292
|
+
if (xpath) args.push('--xpath', xpath)
|
|
293
|
+
if (!runDir && !acd) {
|
|
294
|
+
return toolError('allen_bradley_sdk_import_l5x requires run_dir and/or acd_path.')
|
|
295
|
+
}
|
|
296
|
+
return runPythonScript('sdk_import_l5x.py', args, 300_000)
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sylo-allen-bradley",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Studio 5000 Logix Designer SDK wrapper — controller upload/download and ACD↔L5X export/import. The SDK itself is not bundled (operator-licensed wheel); PLC comms live in sylo-plc-comms, L5X parse/IO scaffold in sylo-logicforge.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"extensions",
|
|
11
|
+
"scripts",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {},
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./extensions/index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@earendil-works/pi-coding-agent": "^0.84.2"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"typebox": "^1.1.24"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared download-allowlist loader / membership gate.
|
|
3
|
+
|
|
4
|
+
The canonical allowlist lives at packages/sylo-logicforge/assets/download-allowlist.json
|
|
5
|
+
and is operator-managed via the LogicForge Settings tab. The agent never edits
|
|
6
|
+
it — the download script reads it and refuses any IP not present and enabled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def package_root() -> Path:
|
|
19
|
+
return Path(__file__).resolve().parent.parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def allowlist_path() -> Path:
|
|
23
|
+
"""Allowlist JSON path (env override for project-local testing)."""
|
|
24
|
+
env = os.environ.get("LOGICFORGE_DOWNLOAD_ALLOWLIST", "").strip()
|
|
25
|
+
if env:
|
|
26
|
+
return Path(env).expanduser().resolve()
|
|
27
|
+
# Canonical file lives with sylo-logicforge (the LogicForge Settings tab
|
|
28
|
+
# reads/writes it there); resolve across the monorepo so every package
|
|
29
|
+
# enforces the same operator list.
|
|
30
|
+
sibling = (
|
|
31
|
+
Path(__file__).resolve().parents[2]
|
|
32
|
+
/ "sylo-logicforge"
|
|
33
|
+
/ "assets"
|
|
34
|
+
/ "download-allowlist.json"
|
|
35
|
+
)
|
|
36
|
+
if sibling.is_file():
|
|
37
|
+
return sibling
|
|
38
|
+
return package_root() / "assets" / "download-allowlist.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_allowlist() -> dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"allow_downloads": False,
|
|
44
|
+
"post_download_mode": "program",
|
|
45
|
+
"ips": [],
|
|
46
|
+
"updated_at": None,
|
|
47
|
+
"notes": "Operator-managed via LogicForge Settings tab.",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_allowlist() -> dict[str, Any]:
|
|
52
|
+
path = allowlist_path()
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
return default_allowlist()
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
57
|
+
except (json.JSONDecodeError, OSError):
|
|
58
|
+
return default_allowlist()
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
return default_allowlist()
|
|
61
|
+
# Normalize / fill missing keys
|
|
62
|
+
base = default_allowlist()
|
|
63
|
+
base.update(data)
|
|
64
|
+
if not isinstance(base.get("ips"), list):
|
|
65
|
+
base["ips"] = []
|
|
66
|
+
if base.get("post_download_mode") not in ("program", "run"):
|
|
67
|
+
base["post_download_mode"] = "program"
|
|
68
|
+
return base
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_allowlist(data: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
path = allowlist_path()
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
data = dict(data)
|
|
75
|
+
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
76
|
+
if data.get("post_download_mode") not in ("program", "run"):
|
|
77
|
+
data["post_download_mode"] = "program"
|
|
78
|
+
if not isinstance(data.get("ips"), list):
|
|
79
|
+
data["ips"] = []
|
|
80
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ip_from_comm_path(comm_path: str) -> str | None:
|
|
85
|
+
"""Extract a bare IPv4 from a Rockwell comm path, else None."""
|
|
86
|
+
import re
|
|
87
|
+
|
|
88
|
+
raw = comm_path.strip()
|
|
89
|
+
# Look for an IPv4 anywhere in the path
|
|
90
|
+
m = re.search(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", raw)
|
|
91
|
+
return m.group(0) if m else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_ip_allowed(ip_or_comm: str, allowlist: dict[str, Any] | None = None) -> tuple[bool, str]:
|
|
95
|
+
"""Return (allowed, reason). Resolves bare IP from a comm path if needed."""
|
|
96
|
+
al = allowlist if allowlist is not None else load_allowlist()
|
|
97
|
+
if not al.get("allow_downloads", False):
|
|
98
|
+
return False, "Downloads are disabled in the allowlist (allow_downloads=false)."
|
|
99
|
+
|
|
100
|
+
ip = ip_or_comm.strip()
|
|
101
|
+
if "\\" in ip or "/" in ip:
|
|
102
|
+
ip = _ip_from_comm_path(ip) or ip
|
|
103
|
+
|
|
104
|
+
for entry in al.get("ips", []):
|
|
105
|
+
if not isinstance(entry, dict):
|
|
106
|
+
continue
|
|
107
|
+
if entry.get("ip") == ip:
|
|
108
|
+
if entry.get("enabled", True):
|
|
109
|
+
return True, ip
|
|
110
|
+
return False, f"IP {ip} is in the allowlist but disabled."
|
|
111
|
+
return False, f"IP {ip} is not in the download allowlist. The agent cannot download to it."
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared JSON stdout helpers for sylo-allen-bradley (SDK) scripts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit(payload: dict[str, Any]) -> None:
|
|
12
|
+
"""Print JSON to stdout and exit with code 0 or 1."""
|
|
13
|
+
print(json.dumps(payload, indent=2))
|
|
14
|
+
if payload.get("ok") is False:
|
|
15
|
+
sys.exit(1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def emit_error(message: str, **extra: Any) -> None:
|
|
19
|
+
emit({"ok": False, "error": message, **extra})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read/write run.yaml (YAML if PyYAML installed, else JSON)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_simple_yaml(raw: str) -> dict:
|
|
11
|
+
out: dict = {}
|
|
12
|
+
for line in raw.splitlines():
|
|
13
|
+
stripped = line.strip()
|
|
14
|
+
if not stripped or stripped.startswith("#"):
|
|
15
|
+
continue
|
|
16
|
+
if ":" not in stripped:
|
|
17
|
+
continue
|
|
18
|
+
key, value = stripped.split(":", 1)
|
|
19
|
+
out[key.strip()] = value.strip().strip("'\"")
|
|
20
|
+
return out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_run_yaml(run_dir: Path) -> dict:
|
|
24
|
+
path = run_dir / "run.yaml"
|
|
25
|
+
if not path.is_file():
|
|
26
|
+
return {}
|
|
27
|
+
raw = path.read_text(encoding="utf-8")
|
|
28
|
+
try:
|
|
29
|
+
import yaml # type: ignore
|
|
30
|
+
|
|
31
|
+
data = yaml.safe_load(raw)
|
|
32
|
+
return data if isinstance(data, dict) else {}
|
|
33
|
+
except ImportError:
|
|
34
|
+
pass
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(raw)
|
|
39
|
+
return data if isinstance(data, dict) else {}
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return _parse_simple_yaml(raw)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_working_acd(run_dir: Path) -> Path | None:
|
|
45
|
+
"""Working ACD for a run: run.yaml working_acd → working/*.acd → legacy working/project.acd."""
|
|
46
|
+
data = read_run_yaml(run_dir)
|
|
47
|
+
recorded = data.get("working_acd")
|
|
48
|
+
if isinstance(recorded, str) and recorded.strip():
|
|
49
|
+
p = Path(recorded).expanduser()
|
|
50
|
+
if p.is_file():
|
|
51
|
+
return p.resolve()
|
|
52
|
+
working = run_dir / "working"
|
|
53
|
+
if working.is_dir():
|
|
54
|
+
acds = sorted(p for p in working.iterdir() if p.is_file() and p.suffix.lower() == ".acd")
|
|
55
|
+
if len(acds) == 1:
|
|
56
|
+
return acds[0].resolve()
|
|
57
|
+
legacy = working / "project.acd"
|
|
58
|
+
if legacy.is_file():
|
|
59
|
+
return legacy.resolve()
|
|
60
|
+
if acds:
|
|
61
|
+
dev = [p for p in acds if p.stem.lower().endswith(".dev")]
|
|
62
|
+
if len(dev) == 1:
|
|
63
|
+
return dev[0].resolve()
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_run_yaml(run_dir: Path, patch: dict) -> None:
|
|
68
|
+
path = run_dir / "run.yaml"
|
|
69
|
+
data = read_run_yaml(run_dir)
|
|
70
|
+
data.update(patch)
|
|
71
|
+
try:
|
|
72
|
+
import yaml # type: ignore
|
|
73
|
+
|
|
74
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
75
|
+
except ImportError:
|
|
76
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared Logix Designer SDK partial import helper."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sdk_logs_to_stderr():
|
|
12
|
+
"""Route SDK StdOutEventLogger output to stderr so stdout stays clean JSON."""
|
|
13
|
+
return contextlib.redirect_stdout(sys.stderr)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def partial_import_from_l5x(acd_path: Path, xpath: str, fragment_path: Path) -> None:
|
|
17
|
+
from logix_designer_sdk import ImportCollisionOptions, LogixProject, StdOutEventLogger
|
|
18
|
+
|
|
19
|
+
with sdk_logs_to_stderr():
|
|
20
|
+
project = await LogixProject.open_logix_project(str(acd_path), StdOutEventLogger())
|
|
21
|
+
await project.partial_import_from_xml_file(
|
|
22
|
+
xpath,
|
|
23
|
+
str(fragment_path),
|
|
24
|
+
ImportCollisionOptions.OVERWRITE_ON_COLL,
|
|
25
|
+
)
|
|
26
|
+
await project.save()
|