lasal-mcp 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 +198 -0
- package/dist/core/envelope.js +8 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/http.js +19 -0
- package/dist/core/process.js +30 -0
- package/dist/core/response.js +37 -0
- package/dist/core/scratch.js +6 -0
- package/dist/core/staticServer.js +88 -0
- package/dist/server.js +230 -0
- package/dist/state.js +57 -0
- package/dist/tools/applyProjectChanges.js +371 -0
- package/dist/tools/deployAll.js +320 -0
- package/dist/tools/hmiBrowser.js +224 -0
- package/dist/tools/hmiRuntime.js +273 -0
- package/dist/tools/inspectProject.js +162 -0
- package/dist/tools/inspectVisuProject.js +474 -0
- package/dist/tools/larsRuntime.js +536 -0
- package/dist/tools/lasalApps.js +111 -0
- package/dist/tools/plcControl.js +530 -0
- package/dist/tools/plcDiagnostics.js +172 -0
- package/dist/tools/readClassSource.js +147 -0
- package/dist/tools/selectProject.js +47 -0
- package/dist/tools/setTargetIp.js +114 -0
- package/dist/tools/status.js +146 -0
- package/dist/tools/visuControl.js +447 -0
- package/dist/tools/visuDashboard.js +571 -0
- package/dist/utils/batchScript.js +257 -0
- package/dist/utils/config.js +34 -0
- package/dist/utils/editTransaction.js +39 -0
- package/dist/utils/engine.js +163 -0
- package/dist/utils/lars.js +471 -0
- package/dist/utils/lasalXml.js +758 -0
- package/dist/utils/preflight.js +194 -0
- package/dist/utils/projectScanner.js +212 -0
- package/dist/utils/resolvePaths.js +46 -0
- package/dist/utils/respond.js +14 -0
- package/dist/utils/scriptRunner.js +161 -0
- package/dist/utils/visuDashboardIO.js +198 -0
- package/dist/utils/visuPropertyEncoding.js +174 -0
- package/dist/utils/visuScript.js +262 -0
- package/package.json +64 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { existsSync, readFileSync, lstatSync, readdirSync } from "fs";
|
|
2
|
+
import { join, dirname, basename, extname } from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { runBatchOps, runScript, emitPy27String, emitPath, buildRawScript } from "../utils/batchScript.js";
|
|
6
|
+
import { resolveLcpPath } from "../utils/resolvePaths.js";
|
|
7
|
+
import { parseLcn } from "../utils/lasalXml.js";
|
|
8
|
+
import { withEngineLock, SCRATCH } from "../utils/engine.js";
|
|
9
|
+
import { TIMEOUTS } from "../utils/config.js";
|
|
10
|
+
import { preflightPlc, resolveConnection, isLoopbackTarget } from "../utils/preflight.js";
|
|
11
|
+
import { respond, fail } from "../utils/respond.js";
|
|
12
|
+
import { batchResultToResponse } from "../core/response.js";
|
|
13
|
+
import { isTransientError } from "../core/errors.js";
|
|
14
|
+
import { ensureScratch } from "../core/scratch.js";
|
|
15
|
+
// Recursively find all project files
|
|
16
|
+
function getProjectFiles(dir, files = []) {
|
|
17
|
+
try {
|
|
18
|
+
for (const f of readdirSync(dir)) {
|
|
19
|
+
const p = join(dir, f);
|
|
20
|
+
if (lstatSync(p).isDirectory()) {
|
|
21
|
+
getProjectFiles(p, files);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
files.push(p);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
// Resolve Structured Text channel type for coercion
|
|
32
|
+
function resolveChannelType(projDir, objectName, channelName) {
|
|
33
|
+
const files = getProjectFiles(projDir);
|
|
34
|
+
const lcnFiles = files.filter((f) => f.endsWith(".lcn"));
|
|
35
|
+
const stFiles = files.filter((f) => f.endsWith(".st"));
|
|
36
|
+
let className = null;
|
|
37
|
+
for (const lcnFile of lcnFiles) {
|
|
38
|
+
try {
|
|
39
|
+
const info = parseLcn(lcnFile);
|
|
40
|
+
const obj = info.objects.find((o) => o.name === objectName);
|
|
41
|
+
if (obj) {
|
|
42
|
+
className = obj.className;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
}
|
|
48
|
+
if (!className)
|
|
49
|
+
return null;
|
|
50
|
+
const stFile = stFiles.find((f) => {
|
|
51
|
+
const base = basename(f, extname(f));
|
|
52
|
+
return base.toLowerCase() === className?.toLowerCase();
|
|
53
|
+
});
|
|
54
|
+
if (!stFile)
|
|
55
|
+
return null;
|
|
56
|
+
try {
|
|
57
|
+
const stContent = readFileSync(stFile, "latin1");
|
|
58
|
+
const re = new RegExp(`\\b${channelName}\\s*:\\s*(\\w+)`, "i");
|
|
59
|
+
const m = stContent.match(re);
|
|
60
|
+
if (m?.[1]) {
|
|
61
|
+
return m[1].toUpperCase();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// ─── build_project ────────────────────────────────────────────────────────────
|
|
68
|
+
export const buildProjectSchema = {
|
|
69
|
+
action: z.enum(["compile", "download"]).describe("'compile' builds the project; 'download' transfers it to the PLC."),
|
|
70
|
+
lcp_path: z.string().optional().describe("Absolute path to the .lcp file. Omit to use the selected project."),
|
|
71
|
+
options: z
|
|
72
|
+
.enum(["RebuildAll", "BuildChanges", "UserClassesOnly", "NoDebugInfo"])
|
|
73
|
+
.optional()
|
|
74
|
+
.default("RebuildAll")
|
|
75
|
+
.describe("Compile mode (compile only). RebuildAll is safest; BuildChanges is faster for incremental work."),
|
|
76
|
+
connection: z
|
|
77
|
+
.string()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Connection string (e.g. 'TCPIP:192.168.1.100') or address-book name (download only). Omit to use the connection saved in the .lss file."),
|
|
80
|
+
add_loader_anyway: z
|
|
81
|
+
.boolean()
|
|
82
|
+
.optional()
|
|
83
|
+
.default(false)
|
|
84
|
+
.describe("Force loader download even if the target OS already has a compatible loader (download only)."),
|
|
85
|
+
timeout_s: z
|
|
86
|
+
.number()
|
|
87
|
+
.int()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("Timeout in seconds for compile or download. Omit for default (600s)."),
|
|
90
|
+
};
|
|
91
|
+
export async function buildProjectHandler(args) {
|
|
92
|
+
return withEngineLock(async () => {
|
|
93
|
+
const resolved = resolveLcpPath(args.lcp_path);
|
|
94
|
+
if ("error" in resolved) {
|
|
95
|
+
return fail(resolved.error, ["Select a project first using select_project or specify lcp_path."]);
|
|
96
|
+
}
|
|
97
|
+
if (args.action === "compile") {
|
|
98
|
+
const timeoutMs = args.timeout_s ? args.timeout_s * 1000 : TIMEOUTS.compile;
|
|
99
|
+
const br = await runBatchOps(resolved.path, [{ type: "compile", optionName: args.options ?? "RebuildAll" }], timeoutMs);
|
|
100
|
+
return batchResultToResponse(br);
|
|
101
|
+
}
|
|
102
|
+
// download
|
|
103
|
+
const connectionInfo = resolveConnection(resolved.path, args.connection);
|
|
104
|
+
const ipUsed = connectionInfo.ip ?? "";
|
|
105
|
+
const connectionUsed = connectionInfo.connection;
|
|
106
|
+
// LARS (local runtime) instances need the PC loader sent along or the
|
|
107
|
+
// download ends in "Linker_Error" on the target.
|
|
108
|
+
const addLoaderAnyway = args.add_loader_anyway ?? isLoopbackTarget(connectionInfo);
|
|
109
|
+
// Preflight PLC target
|
|
110
|
+
const pf = await preflightPlc(resolved.path, args.connection);
|
|
111
|
+
if (!pf.ok) {
|
|
112
|
+
return respond({
|
|
113
|
+
ok: false,
|
|
114
|
+
preflight: pf,
|
|
115
|
+
connectionUsed,
|
|
116
|
+
ipUsed,
|
|
117
|
+
errors: pf.problems.map((p) => p.message),
|
|
118
|
+
hints: pf.problems.map((p) => p.fix),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const timeoutMs = args.timeout_s ? args.timeout_s * 1000 : TIMEOUTS.download;
|
|
122
|
+
let br = await runBatchOps(resolved.path, [{ type: "download", connection: connectionUsed, addLoaderAnyway }], timeoutMs);
|
|
123
|
+
// Auto-retry transient connection errors once
|
|
124
|
+
if (!br.ok && isTransientError(br.errors)) {
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
126
|
+
br = await runBatchOps(resolved.path, [{ type: "download", connection: connectionUsed, addLoaderAnyway }], timeoutMs);
|
|
127
|
+
br.hints = [...(br.hints ?? []), "Retried download once due to a transient connection failure."];
|
|
128
|
+
}
|
|
129
|
+
return batchResultToResponse(br, {
|
|
130
|
+
connectionUsed,
|
|
131
|
+
ipUsed,
|
|
132
|
+
postDownloadState: br.postDownloadState,
|
|
133
|
+
lcpPath: resolved.path,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// ─── control_plc ─────────────────────────────────────────────────────────────
|
|
138
|
+
export const controlPlcSchema = {
|
|
139
|
+
action: z
|
|
140
|
+
.enum(["start", "stop", "get_state"])
|
|
141
|
+
.describe("'start' runs the PLC project; 'stop' halts it; 'get_state' queries its current state."),
|
|
142
|
+
lcp_path: z.string().optional().describe("Absolute path to the .lcp file. Omit to use the selected project."),
|
|
143
|
+
connection: z
|
|
144
|
+
.string()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("Connection string or address-book name. Omit to use the project's saved connection."),
|
|
147
|
+
};
|
|
148
|
+
export async function controlPlcHandler(args) {
|
|
149
|
+
return withEngineLock(async () => {
|
|
150
|
+
const resolved = resolveLcpPath(args.lcp_path);
|
|
151
|
+
if ("error" in resolved) {
|
|
152
|
+
return fail(resolved.error, ["Select a project first using select_project or specify lcp_path."]);
|
|
153
|
+
}
|
|
154
|
+
ensureScratch();
|
|
155
|
+
const id = randomUUID();
|
|
156
|
+
const logPath = join(SCRATCH, `${id}.log`);
|
|
157
|
+
const resultPath = join(SCRATCH, `${id}.state.json`);
|
|
158
|
+
const stepsPath = join(SCRATCH, `${id}.steps`);
|
|
159
|
+
const connectionInfo = resolveConnection(resolved.path, args.connection);
|
|
160
|
+
const ipUsed = connectionInfo.ip ?? "";
|
|
161
|
+
const conn = connectionInfo.connection;
|
|
162
|
+
// Run preflight only if not just querying state
|
|
163
|
+
if (args.action !== "get_state") {
|
|
164
|
+
const pf = await preflightPlc(resolved.path, args.connection);
|
|
165
|
+
if (!pf.ok) {
|
|
166
|
+
return respond({
|
|
167
|
+
ok: false,
|
|
168
|
+
preflight: pf,
|
|
169
|
+
connectionUsed: conn,
|
|
170
|
+
ipUsed,
|
|
171
|
+
errors: pf.problems.map((p) => p.message),
|
|
172
|
+
hints: pf.problems.map((p) => p.fix),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (args.action === "start") {
|
|
177
|
+
const bodyLines = [
|
|
178
|
+
`batch.Start(prj, ${emitPy27String(conn)})`,
|
|
179
|
+
"import time",
|
|
180
|
+
"time.sleep(1.0)",
|
|
181
|
+
"state_map = {}",
|
|
182
|
+
"for attr_name in dir(batch.PLCStates):",
|
|
183
|
+
" if not attr_name.startswith('_'):",
|
|
184
|
+
" try: state_map[int(getattr(batch.PLCStates, attr_name))] = attr_name",
|
|
185
|
+
" except: pass",
|
|
186
|
+
`state_val = batch.GetPlcState(prj, ${emitPy27String(conn)})`,
|
|
187
|
+
"state_int = int(state_val)",
|
|
188
|
+
"state_name = state_map.get(state_int, 'Unknown(%d)' % state_int)",
|
|
189
|
+
"result = {'stateValue': state_int, 'stateName': state_name}",
|
|
190
|
+
"import json",
|
|
191
|
+
`f_state = open(${emitPath(resultPath)}, 'w')`,
|
|
192
|
+
"json.dump(result, f_state)",
|
|
193
|
+
"f_state.close()",
|
|
194
|
+
];
|
|
195
|
+
const script = buildRawScript(resolved.path, bodyLines, logPath, stepsPath, ["start"]);
|
|
196
|
+
let br = await runScript(script, logPath, TIMEOUTS.script, ["start"], stepsPath);
|
|
197
|
+
if (!br.ok && isTransientError(br.errors)) {
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
199
|
+
br = await runScript(script, logPath, TIMEOUTS.script, ["start"], stepsPath);
|
|
200
|
+
br.hints = [...(br.hints ?? []), "Retried start once due to a transient connection failure."];
|
|
201
|
+
}
|
|
202
|
+
let stateData = {};
|
|
203
|
+
if (existsSync(resultPath)) {
|
|
204
|
+
try {
|
|
205
|
+
stateData = JSON.parse(readFileSync(resultPath, "utf-8"));
|
|
206
|
+
}
|
|
207
|
+
catch { }
|
|
208
|
+
}
|
|
209
|
+
// LARS resets SRAM on first boot after a fresh download, leaving the runtime in
|
|
210
|
+
// SRAM_Error until the project is started again. Retry once so callers see Run_RAM.
|
|
211
|
+
const stateName = stateData.stateName;
|
|
212
|
+
if (br.ok && stateName === "SRAM_Error") {
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
214
|
+
br = await runScript(script, logPath, TIMEOUTS.script, ["start"], stepsPath);
|
|
215
|
+
br.hints = [
|
|
216
|
+
...(br.hints ?? []),
|
|
217
|
+
"SRAM was reset on first start (fresh LARS download). Retried start automatically.",
|
|
218
|
+
];
|
|
219
|
+
stateData = {};
|
|
220
|
+
if (existsSync(resultPath)) {
|
|
221
|
+
try {
|
|
222
|
+
stateData = JSON.parse(readFileSync(resultPath, "utf-8"));
|
|
223
|
+
}
|
|
224
|
+
catch { }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return batchResultToResponse(br, {
|
|
228
|
+
connectionUsed: conn,
|
|
229
|
+
ipUsed,
|
|
230
|
+
postStartState: stateData,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (args.action === "stop") {
|
|
234
|
+
const bodyLines = [
|
|
235
|
+
`batch.Stop(prj, ${emitPy27String(conn)})`,
|
|
236
|
+
"import time",
|
|
237
|
+
"time.sleep(1.0)",
|
|
238
|
+
"state_map = {}",
|
|
239
|
+
"for attr_name in dir(batch.PLCStates):",
|
|
240
|
+
" if not attr_name.startswith('_'):",
|
|
241
|
+
" try: state_map[int(getattr(batch.PLCStates, attr_name))] = attr_name",
|
|
242
|
+
" except: pass",
|
|
243
|
+
`state_val = batch.GetPlcState(prj, ${emitPy27String(conn)})`,
|
|
244
|
+
"state_int = int(state_val)",
|
|
245
|
+
"state_name = state_map.get(state_int, 'Unknown(%d)' % state_int)",
|
|
246
|
+
"result = {'stateValue': state_int, 'stateName': state_name}",
|
|
247
|
+
"import json",
|
|
248
|
+
`f_state = open(${emitPath(resultPath)}, 'w')`,
|
|
249
|
+
"json.dump(result, f_state)",
|
|
250
|
+
"f_state.close()",
|
|
251
|
+
];
|
|
252
|
+
const script = buildRawScript(resolved.path, bodyLines, logPath, stepsPath, ["stop"]);
|
|
253
|
+
let br = await runScript(script, logPath, TIMEOUTS.script, ["stop"], stepsPath);
|
|
254
|
+
if (!br.ok && isTransientError(br.errors)) {
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
256
|
+
br = await runScript(script, logPath, TIMEOUTS.script, ["stop"], stepsPath);
|
|
257
|
+
br.hints = [...(br.hints ?? []), "Retried stop once due to a transient connection failure."];
|
|
258
|
+
}
|
|
259
|
+
let stateData = {};
|
|
260
|
+
if (existsSync(resultPath)) {
|
|
261
|
+
try {
|
|
262
|
+
stateData = JSON.parse(readFileSync(resultPath, "utf-8"));
|
|
263
|
+
}
|
|
264
|
+
catch { }
|
|
265
|
+
}
|
|
266
|
+
return batchResultToResponse(br, {
|
|
267
|
+
connectionUsed: conn,
|
|
268
|
+
ipUsed,
|
|
269
|
+
postStopState: stateData,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// get_state
|
|
273
|
+
const bodyLines = [
|
|
274
|
+
"state_map = {}",
|
|
275
|
+
"for attr_name in dir(batch.PLCStates):",
|
|
276
|
+
" if not attr_name.startswith('_'):",
|
|
277
|
+
" try: state_map[int(getattr(batch.PLCStates, attr_name))] = attr_name",
|
|
278
|
+
" except: pass",
|
|
279
|
+
`state_val = batch.GetPlcState(prj, ${emitPy27String(conn)})`,
|
|
280
|
+
"state_int = int(state_val)",
|
|
281
|
+
"state_name = state_map.get(state_int, 'Unknown(%d)' % state_int)",
|
|
282
|
+
"result = {'stateValue': state_int, 'stateName': state_name}",
|
|
283
|
+
"import json",
|
|
284
|
+
`f_state = open(${emitPath(resultPath)}, 'w')`,
|
|
285
|
+
"json.dump(result, f_state)",
|
|
286
|
+
"f_state.close()",
|
|
287
|
+
];
|
|
288
|
+
const script = buildRawScript(resolved.path, bodyLines, logPath, stepsPath, ["get_state"]);
|
|
289
|
+
let br = await runScript(script, logPath, TIMEOUTS.script, ["get_state"], stepsPath);
|
|
290
|
+
if (!br.ok && isTransientError(br.errors)) {
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
292
|
+
br = await runScript(script, logPath, TIMEOUTS.script, ["get_state"], stepsPath);
|
|
293
|
+
br.hints = [...(br.hints ?? []), "Retried get_state once due to a transient connection failure."];
|
|
294
|
+
}
|
|
295
|
+
let stateData = {};
|
|
296
|
+
if (existsSync(resultPath)) {
|
|
297
|
+
try {
|
|
298
|
+
stateData = JSON.parse(readFileSync(resultPath, "utf-8"));
|
|
299
|
+
}
|
|
300
|
+
catch { }
|
|
301
|
+
}
|
|
302
|
+
return batchResultToResponse(br, {
|
|
303
|
+
connectionUsed: conn,
|
|
304
|
+
ipUsed,
|
|
305
|
+
...stateData,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// ─── plc_values ──────────────────────────────────────────────────────────────
|
|
310
|
+
export const plcValuesSchema = {
|
|
311
|
+
action: z.enum(["read", "write"]).describe("'read' fetches live channel values; 'write' pushes new values."),
|
|
312
|
+
lcp_path: z.string().optional().describe("Absolute path to the .lcp file. Omit to use the selected project."),
|
|
313
|
+
connection: z
|
|
314
|
+
.string()
|
|
315
|
+
.optional()
|
|
316
|
+
.describe("Connection string or address-book name. Omit to use the project's saved connection."),
|
|
317
|
+
channels: z
|
|
318
|
+
.array(z.string())
|
|
319
|
+
.optional()
|
|
320
|
+
.describe("Channel paths to read, each in 'ObjectName.ChannelName' format (read only)."),
|
|
321
|
+
values: z
|
|
322
|
+
.array(z.object({
|
|
323
|
+
channel: z.string().describe("Channel path in 'ObjectName.ChannelName' format."),
|
|
324
|
+
value: z.string().describe("New value as string."),
|
|
325
|
+
}))
|
|
326
|
+
.optional()
|
|
327
|
+
.describe("Channel/value pairs to write (write only)."),
|
|
328
|
+
};
|
|
329
|
+
export async function plcValuesHandler(args) {
|
|
330
|
+
return withEngineLock(async () => {
|
|
331
|
+
const resolved = resolveLcpPath(args.lcp_path);
|
|
332
|
+
if ("error" in resolved) {
|
|
333
|
+
return fail(resolved.error, ["Select a project first using select_project or specify lcp_path."]);
|
|
334
|
+
}
|
|
335
|
+
ensureScratch();
|
|
336
|
+
const id = randomUUID();
|
|
337
|
+
const logPath = join(SCRATCH, `${id}.log`);
|
|
338
|
+
const resultPath = join(SCRATCH, `${id}.json`);
|
|
339
|
+
const stepsPath = join(SCRATCH, `${id}.steps`);
|
|
340
|
+
const connectionInfo = resolveConnection(resolved.path, args.connection);
|
|
341
|
+
const ipUsed = connectionInfo.ip ?? "";
|
|
342
|
+
const conn = connectionInfo.connection;
|
|
343
|
+
// Preflight PLC target
|
|
344
|
+
const pf = await preflightPlc(resolved.path, args.connection);
|
|
345
|
+
if (!pf.ok) {
|
|
346
|
+
return respond({
|
|
347
|
+
ok: false,
|
|
348
|
+
preflight: pf,
|
|
349
|
+
connectionUsed: conn,
|
|
350
|
+
ipUsed,
|
|
351
|
+
errors: pf.problems.map((p) => p.message),
|
|
352
|
+
hints: pf.problems.map((p) => p.fix),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
let script;
|
|
356
|
+
if (args.action === "read") {
|
|
357
|
+
if (!args.channels?.length) {
|
|
358
|
+
return fail("channels is required for action 'read'", ["Provide channels to read."]);
|
|
359
|
+
}
|
|
360
|
+
const channelListPy = `[${args.channels.map(emitPy27String).join(", ")}]`;
|
|
361
|
+
const bodyLines = [
|
|
362
|
+
"batch.SetExceptionOnError(False)",
|
|
363
|
+
`conn_ok = batch.OpenPlcConnection(prj, ${emitPy27String(conn)})`,
|
|
364
|
+
"if conn_ok:",
|
|
365
|
+
" import time",
|
|
366
|
+
` channels = ${channelListPy}`,
|
|
367
|
+
" results = {}",
|
|
368
|
+
// Composite objects expose their channels on internal implementation
|
|
369
|
+
// objects, addressed as Object\_base.Channel — try those variants too.
|
|
370
|
+
// Reads can fail right after connecting, so retry failed channels
|
|
371
|
+
// with short delays until the connection is fully usable.
|
|
372
|
+
" def read_channel(ch):",
|
|
373
|
+
" variants = [ch]",
|
|
374
|
+
" if '.' in ch and '\\\\' not in ch:",
|
|
375
|
+
" obj_part, chan_part = ch.split('.', 1)",
|
|
376
|
+
" variants.append(obj_part + '\\\\_base.' + chan_part)",
|
|
377
|
+
" variants.append(obj_part + '\\\\_base\\\\_base.' + chan_part)",
|
|
378
|
+
" for variant in variants:",
|
|
379
|
+
" dic = {}",
|
|
380
|
+
" ok = batch.ReadPlcValue(variant, dic)",
|
|
381
|
+
" if ok:",
|
|
382
|
+
" return ok, dic, variant",
|
|
383
|
+
" return 0, {}, ch",
|
|
384
|
+
" for ch in channels:",
|
|
385
|
+
" ok, dic, used = read_channel(ch)",
|
|
386
|
+
" attempt = 0",
|
|
387
|
+
" while not ok and attempt < 3:",
|
|
388
|
+
" time.sleep(1.0)",
|
|
389
|
+
" ok, dic, used = read_channel(ch)",
|
|
390
|
+
" attempt += 1",
|
|
391
|
+
" data = {}",
|
|
392
|
+
" for k, dv in dic.items():",
|
|
393
|
+
" data[str(k)] = str(dv)",
|
|
394
|
+
" results[ch] = {'ok': bool(ok), 'data': data, 'path': used}",
|
|
395
|
+
" batch.ClosePlcConnection()",
|
|
396
|
+
" result = {'ok': True, 'channels': results}",
|
|
397
|
+
"else:",
|
|
398
|
+
" result = {'ok': False, 'error': 'Failed to open PLC connection - check connection string and PLC state'}",
|
|
399
|
+
`f = open(${emitPath(resultPath)}, 'w')`,
|
|
400
|
+
"json.dump(result, f)",
|
|
401
|
+
"f.close()",
|
|
402
|
+
];
|
|
403
|
+
script = buildRawScript(resolved.path, bodyLines, logPath, stepsPath, ["read"]);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
if (!args.values?.length) {
|
|
407
|
+
return fail("values is required for action 'write'", ["Provide values to write."]);
|
|
408
|
+
}
|
|
409
|
+
const writeOpsList = [];
|
|
410
|
+
const projDir = dirname(resolved.path);
|
|
411
|
+
for (const item of args.values ?? []) {
|
|
412
|
+
const ch = item.channel;
|
|
413
|
+
const parts = ch.split(".");
|
|
414
|
+
let pyVal = `${emitPy27String(item.value)}`;
|
|
415
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
416
|
+
const type = resolveChannelType(projDir, parts[0], parts[1]);
|
|
417
|
+
if (type) {
|
|
418
|
+
if (type === "BOOL") {
|
|
419
|
+
const isTrue = item.value.toLowerCase() === "true" || item.value === "1";
|
|
420
|
+
pyVal = isTrue ? "True" : "False";
|
|
421
|
+
}
|
|
422
|
+
else if (["REAL", "LREAL"].includes(type)) {
|
|
423
|
+
if (!isNaN(Number(item.value))) {
|
|
424
|
+
pyVal = item.value;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else if (["DINT", "INT", "SINT", "UDINT", "UINT", "USINT"].includes(type)) {
|
|
428
|
+
if (/^-?\d+$/.test(item.value)) {
|
|
429
|
+
pyVal = item.value;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
writeOpsList.push(`(${emitPy27String(ch)}, ${pyVal})`);
|
|
435
|
+
}
|
|
436
|
+
const writeOpsPy = `[${writeOpsList.join(", ")}]`;
|
|
437
|
+
const bodyLines = [
|
|
438
|
+
"batch.SetExceptionOnError(False)",
|
|
439
|
+
`conn_ok = batch.OpenPlcConnection(prj, ${emitPy27String(conn)})`,
|
|
440
|
+
"if conn_ok:",
|
|
441
|
+
" import time",
|
|
442
|
+
` write_ops = ${writeOpsPy}`,
|
|
443
|
+
" results = {}",
|
|
444
|
+
" def write_channel(ch, val):",
|
|
445
|
+
" variants = [ch]",
|
|
446
|
+
" if '.' in ch and '\\\\' not in ch:",
|
|
447
|
+
" obj_part, chan_part = ch.split('.', 1)",
|
|
448
|
+
" variants.append(obj_part + '\\\\_base.' + chan_part)",
|
|
449
|
+
" variants.append(obj_part + '\\\\_base\\\\_base.' + chan_part)",
|
|
450
|
+
" for variant in variants:",
|
|
451
|
+
" ok = batch.WritePlcValue(variant, val)",
|
|
452
|
+
" if ok:",
|
|
453
|
+
" return ok, variant",
|
|
454
|
+
" return 0, ch",
|
|
455
|
+
" for ch, val in write_ops:",
|
|
456
|
+
" ok, used = write_channel(ch, val)",
|
|
457
|
+
" attempt = 0",
|
|
458
|
+
" while not ok and attempt < 3:",
|
|
459
|
+
" time.sleep(1.0)",
|
|
460
|
+
" ok, used = write_channel(ch, val)",
|
|
461
|
+
" attempt += 1",
|
|
462
|
+
" results[ch] = {'ok': bool(ok), 'path': used}",
|
|
463
|
+
" batch.ClosePlcConnection()",
|
|
464
|
+
" result = {'ok': True, 'writes': results}",
|
|
465
|
+
"else:",
|
|
466
|
+
" result = {'ok': False, 'error': 'Failed to open PLC connection - check connection string and PLC state'}",
|
|
467
|
+
`f = open(${emitPath(resultPath)}, 'w')`,
|
|
468
|
+
"json.dump(result, f)",
|
|
469
|
+
"f.close()",
|
|
470
|
+
];
|
|
471
|
+
script = buildRawScript(resolved.path, bodyLines, logPath, stepsPath, ["write"]);
|
|
472
|
+
}
|
|
473
|
+
let br = await runScript(script, logPath, TIMEOUTS.script, args.action === "read" ? ["read"] : ["write"], stepsPath);
|
|
474
|
+
if (!br.ok && isTransientError(br.errors)) {
|
|
475
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
476
|
+
br = await runScript(script, logPath, TIMEOUTS.script, args.action === "read" ? ["read"] : ["write"], stepsPath);
|
|
477
|
+
br.hints = [...(br.hints ?? []), `Retried ${args.action} once due to a transient connection failure.`];
|
|
478
|
+
}
|
|
479
|
+
let plcData = {};
|
|
480
|
+
let parseError = null;
|
|
481
|
+
let rawContent = "";
|
|
482
|
+
if (existsSync(resultPath)) {
|
|
483
|
+
rawContent = readFileSync(resultPath, "utf-8");
|
|
484
|
+
try {
|
|
485
|
+
plcData = JSON.parse(rawContent);
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
parseError = e.message;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Aggregate channel-level success
|
|
492
|
+
let overallChannelsOk = true;
|
|
493
|
+
const failedChannels = [];
|
|
494
|
+
if (args.action === "read" && plcData.channels) {
|
|
495
|
+
for (const [name, chInfo] of Object.entries(plcData.channels)) {
|
|
496
|
+
if (!chInfo.ok) {
|
|
497
|
+
overallChannelsOk = false;
|
|
498
|
+
failedChannels.push(name);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
else if (args.action === "write" && plcData.writes) {
|
|
503
|
+
for (const [name, chInfo] of Object.entries(plcData.writes)) {
|
|
504
|
+
if (!chInfo.ok) {
|
|
505
|
+
overallChannelsOk = false;
|
|
506
|
+
failedChannels.push(name);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const ok = br.ok && parseError === null && plcData.ok !== false && overallChannelsOk;
|
|
511
|
+
const hints = [...(br.hints ?? [])];
|
|
512
|
+
if (failedChannels.length > 0) {
|
|
513
|
+
hints.push("Some channels failed. Verify the channel casing and spelling using inspect_project.");
|
|
514
|
+
}
|
|
515
|
+
const body = {
|
|
516
|
+
ok,
|
|
517
|
+
durationMs: br.durationMs,
|
|
518
|
+
...plcData,
|
|
519
|
+
...(parseError ? { ok: false, error: `JSON Parse failure: ${parseError}`, raw: rawContent } : {}),
|
|
520
|
+
...(br.errors.length ? { scriptErrors: br.errors } : {}),
|
|
521
|
+
...(br.warnings.length ? { scriptWarnings: br.warnings } : {}),
|
|
522
|
+
logPath: br.logPath,
|
|
523
|
+
connectionUsed: conn,
|
|
524
|
+
ipUsed,
|
|
525
|
+
hints,
|
|
526
|
+
...(failedChannels.length ? { failedChannels } : {}),
|
|
527
|
+
};
|
|
528
|
+
return respond(body);
|
|
529
|
+
});
|
|
530
|
+
}
|