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,320 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import { runBatchOps, runScript, emitPy27String, emitPath, buildRawScript } from "../utils/batchScript.js";
|
|
6
|
+
import { runVisuOps } from "../utils/visuScript.js";
|
|
7
|
+
import { resolveLcpPath, resolveLvpPath } from "../utils/resolvePaths.js";
|
|
8
|
+
import { withEngineLock, SCRATCH } from "../utils/engine.js";
|
|
9
|
+
import { startHmiRuntime } from "./hmiRuntime.js";
|
|
10
|
+
import { TIMEOUTS } from "../utils/config.js";
|
|
11
|
+
import { preflightPlc, preflightHmi, resolveConnection, isLoopbackTarget } from "../utils/preflight.js";
|
|
12
|
+
import { respond, fail } from "../utils/respond.js";
|
|
13
|
+
import { batchToStepResult, visuToStepResult } from "../core/response.js";
|
|
14
|
+
import { isTransientError } from "../core/errors.js";
|
|
15
|
+
import { ensureScratch } from "../core/scratch.js";
|
|
16
|
+
export const deployAllSchema = {
|
|
17
|
+
lcp_path: z.string().optional().describe("Absolute path to the .lcp file. Omit to use the selected project."),
|
|
18
|
+
lvp_path: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Absolute path to the .lvp file. Omit to auto-detect from the selected project."),
|
|
22
|
+
plc_connection: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("PLC connection string (e.g. 'TCPIP:192.168.1.100'). Omit to use the connection saved in the project's .lss file."),
|
|
26
|
+
visu_connection: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("HMI connection string (e.g. 'TCPIP:192.168.1.100'). Required when download_visu is true."),
|
|
30
|
+
compile: z.boolean().optional().default(true).describe("Compile the CLASS 2 project. Default true."),
|
|
31
|
+
compile_options: z
|
|
32
|
+
.enum(["RebuildAll", "BuildChanges"])
|
|
33
|
+
.optional()
|
|
34
|
+
.default("RebuildAll")
|
|
35
|
+
.describe("Compile mode. RebuildAll is safest; BuildChanges is faster for incremental work."),
|
|
36
|
+
download_plc: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.optional()
|
|
39
|
+
.default(true)
|
|
40
|
+
.describe("Download the compiled CLASS 2 project to the PLC. Default true."),
|
|
41
|
+
update_visu_stations: z
|
|
42
|
+
.boolean()
|
|
43
|
+
.optional()
|
|
44
|
+
.default(true)
|
|
45
|
+
.describe("Run update_all_stations on the VISUDesigner project to sync datapoints after CLASS 2 changes. Default true."),
|
|
46
|
+
download_visu: z
|
|
47
|
+
.boolean()
|
|
48
|
+
.optional()
|
|
49
|
+
.default(false)
|
|
50
|
+
.describe("Download the VISUDesigner project to the HMI after updating stations. Requires visu_connection. Default false."),
|
|
51
|
+
visu_download_flags: z
|
|
52
|
+
.number()
|
|
53
|
+
.int()
|
|
54
|
+
.optional()
|
|
55
|
+
.default(0)
|
|
56
|
+
.describe("VISUDesigner download mode: 0=normal (default), 1=changes only, 2=publish+download changes."),
|
|
57
|
+
add_plc_loader: z
|
|
58
|
+
.boolean()
|
|
59
|
+
.optional()
|
|
60
|
+
.default(false)
|
|
61
|
+
.describe("Force loader download to PLC even if the target already has a compatible loader."),
|
|
62
|
+
add_visu_runtime: z
|
|
63
|
+
.boolean()
|
|
64
|
+
.optional()
|
|
65
|
+
.default(false)
|
|
66
|
+
.describe("Force runtime download to HMI even if the version already matches."),
|
|
67
|
+
start_plc: z
|
|
68
|
+
.boolean()
|
|
69
|
+
.optional()
|
|
70
|
+
.default(true)
|
|
71
|
+
.describe("Start the PLC runtime after a successful download. Default true."),
|
|
72
|
+
start_hmi_runtime: z
|
|
73
|
+
.boolean()
|
|
74
|
+
.optional()
|
|
75
|
+
.default(false)
|
|
76
|
+
.describe("Start the local HMI runtime (DataService) and copy HMI files after a successful compilation/update. Default false."),
|
|
77
|
+
timeout_s: z.number().int().optional().describe("Timeout override in seconds for compile and download steps."),
|
|
78
|
+
};
|
|
79
|
+
export async function deployAllHandler(args) {
|
|
80
|
+
return withEngineLock(async () => {
|
|
81
|
+
const doCompile = args.compile ?? true;
|
|
82
|
+
const doDownloadPlc = args.download_plc ?? true;
|
|
83
|
+
const doStartPlc = doDownloadPlc && (args.start_plc ?? true);
|
|
84
|
+
const doUpdateVisuStations = args.update_visu_stations ?? true;
|
|
85
|
+
const doDownloadVisu = args.download_visu ?? false;
|
|
86
|
+
const steps = {};
|
|
87
|
+
// Validate paths upfront
|
|
88
|
+
let lcpPath;
|
|
89
|
+
if (doCompile || doDownloadPlc) {
|
|
90
|
+
const resolved = resolveLcpPath(args.lcp_path);
|
|
91
|
+
if ("error" in resolved) {
|
|
92
|
+
return fail(resolved.error, ["Select a project first using select_project or specify lcp_path."]);
|
|
93
|
+
}
|
|
94
|
+
lcpPath = resolved.path;
|
|
95
|
+
}
|
|
96
|
+
let lvpPath;
|
|
97
|
+
if (doUpdateVisuStations || doDownloadVisu || args.start_hmi_runtime) {
|
|
98
|
+
const resolved = resolveLvpPath(args.lvp_path);
|
|
99
|
+
if ("error" in resolved) {
|
|
100
|
+
return fail(resolved.error, ["Select a project first using select_project or specify lvp_path."]);
|
|
101
|
+
}
|
|
102
|
+
lvpPath = resolved.path;
|
|
103
|
+
}
|
|
104
|
+
if (doDownloadVisu && !args.visu_connection) {
|
|
105
|
+
return fail("visu_connection is required when download_visu is true.", ["Provide visu_connection."]);
|
|
106
|
+
}
|
|
107
|
+
// Resolve connections and preflight upfront
|
|
108
|
+
const plcConnectionInfo = lcpPath
|
|
109
|
+
? resolveConnection(lcpPath, args.plc_connection)
|
|
110
|
+
: { connection: "", source: "none", ip: undefined, port: undefined };
|
|
111
|
+
const plcIp = plcConnectionInfo.ip ?? "";
|
|
112
|
+
const plcConnectionUsed = plcConnectionInfo.connection;
|
|
113
|
+
if (doDownloadPlc || doStartPlc) {
|
|
114
|
+
const pfPlc = await preflightPlc(lcpPath, args.plc_connection);
|
|
115
|
+
if (!pfPlc.ok) {
|
|
116
|
+
return respond({
|
|
117
|
+
ok: false,
|
|
118
|
+
preflightPlc: pfPlc,
|
|
119
|
+
connections: {
|
|
120
|
+
plc: { ip: plcIp, source: plcConnectionInfo.source, connection: plcConnectionUsed },
|
|
121
|
+
},
|
|
122
|
+
errors: pfPlc.problems.map((p) => p.message),
|
|
123
|
+
hints: pfPlc.problems.map((p) => p.fix),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let visuIp = "";
|
|
128
|
+
if (doDownloadVisu) {
|
|
129
|
+
const pfHmi = await preflightHmi(lvpPath, args.visu_connection);
|
|
130
|
+
const m = args.visu_connection.match(/TCPIP:(.+)/i);
|
|
131
|
+
visuIp = m?.[1]?.split(":")[0] ?? args.visu_connection ?? "";
|
|
132
|
+
if (!pfHmi.ok) {
|
|
133
|
+
return respond({
|
|
134
|
+
ok: false,
|
|
135
|
+
preflightHmi: pfHmi,
|
|
136
|
+
connections: {
|
|
137
|
+
plc: { ip: plcIp, source: plcConnectionInfo.source, connection: plcConnectionUsed },
|
|
138
|
+
visu: { ip: visuIp, connection: args.visu_connection ?? "" },
|
|
139
|
+
},
|
|
140
|
+
errors: pfHmi.problems.map((p) => p.message),
|
|
141
|
+
hints: pfHmi.problems.map((p) => p.fix),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function failResponse(msg, hints = []) {
|
|
146
|
+
const body = {
|
|
147
|
+
ok: false,
|
|
148
|
+
steps,
|
|
149
|
+
connections: {
|
|
150
|
+
plc: { ip: plcIp, source: plcConnectionInfo.source, connection: plcConnectionUsed },
|
|
151
|
+
...(doDownloadVisu ? { visu: { ip: visuIp, connection: args.visu_connection ?? "" } } : {}),
|
|
152
|
+
},
|
|
153
|
+
hints,
|
|
154
|
+
};
|
|
155
|
+
if (msg)
|
|
156
|
+
body.error = msg;
|
|
157
|
+
return respond(body);
|
|
158
|
+
}
|
|
159
|
+
// Step 1: compile
|
|
160
|
+
if (doCompile) {
|
|
161
|
+
const timeoutMs = args.timeout_s ? args.timeout_s * 1000 : TIMEOUTS.compile;
|
|
162
|
+
const br = await runBatchOps(lcpPath, [{ type: "compile", optionName: args.compile_options ?? "RebuildAll" }], timeoutMs);
|
|
163
|
+
steps.compile = batchToStepResult(br);
|
|
164
|
+
if (!br.ok)
|
|
165
|
+
return failResponse("Compilation step failed.", br.hints ?? []);
|
|
166
|
+
}
|
|
167
|
+
// Step 2: download PLC
|
|
168
|
+
if (doDownloadPlc) {
|
|
169
|
+
const timeoutMs = args.timeout_s ? args.timeout_s * 1000 : TIMEOUTS.download;
|
|
170
|
+
// LARS (local runtime) targets need the PC loader or the download ends in "Linker_Error".
|
|
171
|
+
const addLoaderAnyway = args.add_plc_loader ?? isLoopbackTarget(plcConnectionInfo);
|
|
172
|
+
let br = await runBatchOps(lcpPath, [
|
|
173
|
+
{
|
|
174
|
+
type: "download",
|
|
175
|
+
connection: plcConnectionUsed,
|
|
176
|
+
addLoaderAnyway,
|
|
177
|
+
},
|
|
178
|
+
], timeoutMs);
|
|
179
|
+
if (!br.ok && isTransientError(br.errors)) {
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
181
|
+
br = await runBatchOps(lcpPath, [
|
|
182
|
+
{
|
|
183
|
+
type: "download",
|
|
184
|
+
connection: plcConnectionUsed,
|
|
185
|
+
addLoaderAnyway,
|
|
186
|
+
},
|
|
187
|
+
], timeoutMs);
|
|
188
|
+
br.hints = [...(br.hints ?? []), "Retried download once due to a transient connection failure."];
|
|
189
|
+
}
|
|
190
|
+
steps.download_plc = batchToStepResult(br);
|
|
191
|
+
if (!br.ok)
|
|
192
|
+
return failResponse("PLC download step failed.", br.hints ?? []);
|
|
193
|
+
}
|
|
194
|
+
// Step 2a: verify PLC after download
|
|
195
|
+
if (doDownloadPlc) {
|
|
196
|
+
ensureScratch();
|
|
197
|
+
const id = randomUUID();
|
|
198
|
+
const logPath = join(SCRATCH, `${id}.log`);
|
|
199
|
+
const stepsPath = join(SCRATCH, `${id}.steps`);
|
|
200
|
+
const resultPath = join(SCRATCH, `${id}.state.json`);
|
|
201
|
+
const conn = plcConnectionUsed;
|
|
202
|
+
const bodyLines = [
|
|
203
|
+
"state_map = {}",
|
|
204
|
+
"for attr_name in dir(batch.PLCStates):",
|
|
205
|
+
" if not attr_name.startswith('_'):",
|
|
206
|
+
" try: state_map[int(getattr(batch.PLCStates, attr_name))] = attr_name",
|
|
207
|
+
" except: pass",
|
|
208
|
+
`state_val = batch.GetPlcState(prj, ${emitPy27String(conn)})`,
|
|
209
|
+
"state_int = int(state_val)",
|
|
210
|
+
"state_name = state_map.get(state_int, 'Unknown(%d)' % state_int)",
|
|
211
|
+
"result = {'stateValue': state_int, 'stateName': state_name}",
|
|
212
|
+
"import json",
|
|
213
|
+
`f_state = open(${emitPath(resultPath)}, 'w')`,
|
|
214
|
+
"json.dump(result, f_state)",
|
|
215
|
+
"f_state.close()",
|
|
216
|
+
];
|
|
217
|
+
const script = buildRawScript(lcpPath, bodyLines, logPath, stepsPath, ["get_state"]);
|
|
218
|
+
let br = await runScript(script, logPath, TIMEOUTS.script, ["get_state"], stepsPath);
|
|
219
|
+
let stateData = {};
|
|
220
|
+
if (existsSync(resultPath)) {
|
|
221
|
+
try {
|
|
222
|
+
stateData = JSON.parse(readFileSync(resultPath, "utf-8"));
|
|
223
|
+
}
|
|
224
|
+
catch { }
|
|
225
|
+
}
|
|
226
|
+
steps.verify_plc = {
|
|
227
|
+
...batchToStepResult(br),
|
|
228
|
+
plcState: stateData,
|
|
229
|
+
};
|
|
230
|
+
if (!br.ok)
|
|
231
|
+
return failResponse("PLC verification step failed.", br.hints ?? []);
|
|
232
|
+
}
|
|
233
|
+
// Step 2b: start PLC after download
|
|
234
|
+
if (doStartPlc) {
|
|
235
|
+
ensureScratch();
|
|
236
|
+
const id = randomUUID();
|
|
237
|
+
const logPath = join(SCRATCH, `${id}.log`);
|
|
238
|
+
const stepsPath = join(SCRATCH, `${id}.steps`);
|
|
239
|
+
const resultPath = join(SCRATCH, `${id}.state.json`);
|
|
240
|
+
const conn = plcConnectionUsed;
|
|
241
|
+
const bodyLines = [
|
|
242
|
+
`batch.Start(prj, ${emitPy27String(conn)})`,
|
|
243
|
+
"import time",
|
|
244
|
+
"time.sleep(1.0)",
|
|
245
|
+
"state_map = {}",
|
|
246
|
+
"for attr_name in dir(batch.PLCStates):",
|
|
247
|
+
" if not attr_name.startswith('_'):",
|
|
248
|
+
" try: state_map[int(getattr(batch.PLCStates, attr_name))] = attr_name",
|
|
249
|
+
" except: pass",
|
|
250
|
+
`state_val = batch.GetPlcState(prj, ${emitPy27String(conn)})`,
|
|
251
|
+
"state_int = int(state_val)",
|
|
252
|
+
"state_name = state_map.get(state_int, 'Unknown(%d)' % state_int)",
|
|
253
|
+
"result = {'stateValue': state_int, 'stateName': state_name}",
|
|
254
|
+
"import json",
|
|
255
|
+
`f_state = open(${emitPath(resultPath)}, 'w')`,
|
|
256
|
+
"json.dump(result, f_state)",
|
|
257
|
+
"f_state.close()",
|
|
258
|
+
];
|
|
259
|
+
const script = buildRawScript(lcpPath, bodyLines, logPath, stepsPath, ["start"]);
|
|
260
|
+
let br = await runScript(script, logPath, TIMEOUTS.script, ["start"], stepsPath);
|
|
261
|
+
if (!br.ok && isTransientError(br.errors)) {
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
263
|
+
br = await runScript(script, logPath, TIMEOUTS.script, ["start"], stepsPath);
|
|
264
|
+
br.hints = [...(br.hints ?? []), "Retried start once due to a transient connection failure."];
|
|
265
|
+
}
|
|
266
|
+
let stateData = {};
|
|
267
|
+
if (existsSync(resultPath)) {
|
|
268
|
+
try {
|
|
269
|
+
stateData = JSON.parse(readFileSync(resultPath, "utf-8"));
|
|
270
|
+
}
|
|
271
|
+
catch { }
|
|
272
|
+
}
|
|
273
|
+
steps.start_plc = {
|
|
274
|
+
...batchToStepResult(br),
|
|
275
|
+
postStartState: stateData,
|
|
276
|
+
};
|
|
277
|
+
if (!br.ok)
|
|
278
|
+
return failResponse("PLC start step failed.", br.hints ?? []);
|
|
279
|
+
}
|
|
280
|
+
// Step 3: update visu stations + optional download
|
|
281
|
+
if (doUpdateVisuStations || doDownloadVisu) {
|
|
282
|
+
const visuOps = [];
|
|
283
|
+
if (doUpdateVisuStations)
|
|
284
|
+
visuOps.push({ type: "update_all_stations" });
|
|
285
|
+
if (doDownloadVisu)
|
|
286
|
+
visuOps.push({
|
|
287
|
+
type: "download",
|
|
288
|
+
connection: args.visu_connection,
|
|
289
|
+
flags: args.visu_download_flags ?? 0,
|
|
290
|
+
add_runtime: args.add_visu_runtime ?? false,
|
|
291
|
+
});
|
|
292
|
+
const vr = await runVisuOps(lvpPath, visuOps, true, TIMEOUTS.visu);
|
|
293
|
+
steps.visu = visuToStepResult(vr);
|
|
294
|
+
if (!vr.ok)
|
|
295
|
+
return failResponse("Visu designer operations step failed.", vr.hints ?? []);
|
|
296
|
+
}
|
|
297
|
+
// Step 4: start HMI runtime
|
|
298
|
+
if (args.start_hmi_runtime) {
|
|
299
|
+
const startStart = Date.now();
|
|
300
|
+
const hmiRes = await startHmiRuntime({ action: "start", lvp_path: lvpPath });
|
|
301
|
+
steps.hmi_runtime = {
|
|
302
|
+
ok: !hmiRes.isError,
|
|
303
|
+
durationMs: Date.now() - startStart,
|
|
304
|
+
...(hmiRes.isError
|
|
305
|
+
? { errors: [hmiRes.content[0]?.text ?? "Unknown error"] }
|
|
306
|
+
: { logTail: [`HMI started: ${hmiRes.content[0]?.text ?? "started"}`] }),
|
|
307
|
+
};
|
|
308
|
+
if (hmiRes.isError)
|
|
309
|
+
return failResponse("HMI runtime start step failed.");
|
|
310
|
+
}
|
|
311
|
+
return respond({
|
|
312
|
+
ok: true,
|
|
313
|
+
steps,
|
|
314
|
+
connections: {
|
|
315
|
+
plc: { ip: plcIp, source: plcConnectionInfo.source, connection: plcConnectionUsed },
|
|
316
|
+
...(doDownloadVisu ? { visu: { ip: visuIp, connection: args.visu_connection ?? "" } } : {}),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { chromium } from "playwright-core";
|
|
3
|
+
import { readState } from "../state.js";
|
|
4
|
+
import { EDGE_EXE } from "../utils/engine.js";
|
|
5
|
+
import { checkHttpHealth } from "../core/http.js";
|
|
6
|
+
import { findLsmPath, parseSolution } from "../utils/projectScanner.js";
|
|
7
|
+
/** Find the live HMI panel URL: the station that owns an .lvp project, served on port 80. */
|
|
8
|
+
async function findLiveHmiUrl() {
|
|
9
|
+
const state = readState();
|
|
10
|
+
if (!state.currentProject)
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const lsmPath = findLsmPath(state.currentProject);
|
|
14
|
+
if (!lsmPath)
|
|
15
|
+
return null;
|
|
16
|
+
const solution = parseSolution(lsmPath);
|
|
17
|
+
const hmiStation = solution.stations.find((s) => s.lvpPaths.length > 0 && s.ip);
|
|
18
|
+
if (!hmiStation?.ip)
|
|
19
|
+
return null;
|
|
20
|
+
const url = `http://${hmiStation.ip}/`;
|
|
21
|
+
const reachable = await checkHttpHealth(url, 2000, true);
|
|
22
|
+
return reachable ? url : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export const hmiBrowserSchema = {
|
|
29
|
+
action: z
|
|
30
|
+
.enum(["open", "screenshot", "console", "eval", "click", "type", "wait", "close"])
|
|
31
|
+
.describe("Action to perform in the HMI browser session."),
|
|
32
|
+
url: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("URL to navigate to (open action only). Defaults to the live HMI panel (http://<hmi-station-ip>/) when reachable, else the local HMI runtime URL."),
|
|
36
|
+
viewport: z
|
|
37
|
+
.object({
|
|
38
|
+
width: z.number().int(),
|
|
39
|
+
height: z.number().int(),
|
|
40
|
+
})
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Browser viewport size (open action only). Defaults to 1200x800."),
|
|
43
|
+
fullPage: z.boolean().optional().default(false).describe("Take a full page screenshot (screenshot action only)."),
|
|
44
|
+
selector: z.string().optional().describe("CSS/Text selector to screenshot, click, type, or wait for."),
|
|
45
|
+
text: z.string().optional().describe("Text content to type, or to find for clicking."),
|
|
46
|
+
x: z.number().int().optional().describe("X coordinate for mouse click."),
|
|
47
|
+
y: z.number().int().optional().describe("Y coordinate for mouse click."),
|
|
48
|
+
expression: z.string().optional().describe("JavaScript expression to evaluate (eval action only)."),
|
|
49
|
+
ms: z.number().int().optional().describe("Milliseconds to wait (wait action only)."),
|
|
50
|
+
clear: z
|
|
51
|
+
.boolean()
|
|
52
|
+
.optional()
|
|
53
|
+
.default(false)
|
|
54
|
+
.describe("Clear the console/error buffers after reading them (console action only)."),
|
|
55
|
+
};
|
|
56
|
+
let browser = null;
|
|
57
|
+
let context = null;
|
|
58
|
+
let page = null;
|
|
59
|
+
const consoleLogs = [];
|
|
60
|
+
const pageErrors = [];
|
|
61
|
+
async function ensureBrowser() {
|
|
62
|
+
if (browser)
|
|
63
|
+
return;
|
|
64
|
+
const launchOptions = {
|
|
65
|
+
headless: true,
|
|
66
|
+
};
|
|
67
|
+
if (EDGE_EXE) {
|
|
68
|
+
launchOptions.executablePath = EDGE_EXE;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
launchOptions.channel = "msedge";
|
|
72
|
+
}
|
|
73
|
+
browser = await chromium.launch(launchOptions);
|
|
74
|
+
context = await browser.newContext();
|
|
75
|
+
page = await context.newPage();
|
|
76
|
+
page.on("console", (msg) => {
|
|
77
|
+
consoleLogs.push(`[${msg.type()}] ${msg.text()}`);
|
|
78
|
+
if (consoleLogs.length > 500)
|
|
79
|
+
consoleLogs.shift();
|
|
80
|
+
});
|
|
81
|
+
page.on("pageerror", (err) => {
|
|
82
|
+
pageErrors.push(err.stack || err.message);
|
|
83
|
+
if (pageErrors.length > 100)
|
|
84
|
+
pageErrors.shift();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export async function hmiBrowserHandler(args) {
|
|
88
|
+
try {
|
|
89
|
+
if (args.action === "close") {
|
|
90
|
+
if (browser) {
|
|
91
|
+
await browser.close();
|
|
92
|
+
browser = null;
|
|
93
|
+
context = null;
|
|
94
|
+
page = null;
|
|
95
|
+
}
|
|
96
|
+
return { content: [{ type: "text", text: "Browser session closed." }] };
|
|
97
|
+
}
|
|
98
|
+
await ensureBrowser();
|
|
99
|
+
const p = page;
|
|
100
|
+
switch (args.action) {
|
|
101
|
+
case "open": {
|
|
102
|
+
const state = readState();
|
|
103
|
+
// Default: live HMI panel if reachable, else the local simulation runtime
|
|
104
|
+
const targetUrl = args.url || (await findLiveHmiUrl()) || state.hmiRuntime?.url || "file:///C:/lslvisu/index.html";
|
|
105
|
+
const width = args.viewport?.width ?? 1200;
|
|
106
|
+
const height = args.viewport?.height ?? 800;
|
|
107
|
+
await p.setViewportSize({ width, height });
|
|
108
|
+
await p.goto(targetUrl, { waitUntil: "networkidle", timeout: 15000 });
|
|
109
|
+
// Also wait a brief moment for HMI animation to settle
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
111
|
+
return {
|
|
112
|
+
content: [
|
|
113
|
+
{ type: "text", text: `Successfully opened ${targetUrl} with viewport ${width}x${height}` },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
case "screenshot": {
|
|
118
|
+
let buffer;
|
|
119
|
+
if (args.selector) {
|
|
120
|
+
buffer = await p.locator(args.selector).screenshot();
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
buffer = await p.screenshot({ fullPage: args.fullPage });
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: "image",
|
|
129
|
+
data: buffer.toString("base64"),
|
|
130
|
+
mimeType: "image/png",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
case "console": {
|
|
136
|
+
const logs = [...consoleLogs];
|
|
137
|
+
const errors = [...pageErrors];
|
|
138
|
+
if (args.clear) {
|
|
139
|
+
consoleLogs.length = 0;
|
|
140
|
+
pageErrors.length = 0;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
content: [
|
|
144
|
+
{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: JSON.stringify({ logs, errors }, null, 2),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
case "eval": {
|
|
152
|
+
if (!args.expression) {
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text", text: "expression is required for action 'eval'" }],
|
|
155
|
+
isError: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const res = await p.evaluate(args.expression);
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: "text",
|
|
163
|
+
text: JSON.stringify(res, null, 2),
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
case "click": {
|
|
169
|
+
if (args.selector) {
|
|
170
|
+
await p.click(args.selector, { timeout: 10000 });
|
|
171
|
+
}
|
|
172
|
+
else if (args.text) {
|
|
173
|
+
await p.click(`text=${args.text}`, { timeout: 10000 });
|
|
174
|
+
}
|
|
175
|
+
else if (args.x !== undefined && args.y !== undefined) {
|
|
176
|
+
await p.mouse.click(args.x, args.y);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
return {
|
|
180
|
+
content: [
|
|
181
|
+
{ type: "text", text: "selector, text, or coordinates (x, y) required for action 'click'" },
|
|
182
|
+
],
|
|
183
|
+
isError: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// Give time for click events/transitions to resolve
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
188
|
+
return { content: [{ type: "text", text: "Click executed successfully." }] };
|
|
189
|
+
}
|
|
190
|
+
case "type": {
|
|
191
|
+
if (!args.selector) {
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: "text", text: "selector is required for action 'type'" }],
|
|
194
|
+
isError: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const val = args.text ?? "";
|
|
198
|
+
await p.fill(args.selector, val, { timeout: 10000 });
|
|
199
|
+
return { content: [{ type: "text", text: `Successfully typed "${val}" into ${args.selector}.` }] };
|
|
200
|
+
}
|
|
201
|
+
case "wait": {
|
|
202
|
+
if (args.selector) {
|
|
203
|
+
await p.waitForSelector(args.selector, { timeout: args.ms ?? 10000 });
|
|
204
|
+
return { content: [{ type: "text", text: `Selector ${args.selector} appeared.` }] };
|
|
205
|
+
}
|
|
206
|
+
else if (args.ms) {
|
|
207
|
+
await new Promise((resolve) => setTimeout(resolve, args.ms));
|
|
208
|
+
return { content: [{ type: "text", text: `Waited for ${args.ms} ms.` }] };
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: "text", text: "selector or ms is required for action 'wait'" }],
|
|
213
|
+
isError: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
return { content: [{ type: "text", text: `Unknown action: ${args.action}` }], isError: true };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
return { content: [{ type: "text", text: `Browser error: ${e.message}` }], isError: true };
|
|
223
|
+
}
|
|
224
|
+
}
|