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,536 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { existsSync, readdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { LARS_EXE, larsConfigPath, readLarsWorkspaces, upsertLarsWorkspace, removeLarsWorkspace, startLars, killLars, getLarsPids, isLarsHealthy, pointStationAtLars, safeWorkspaceName, gcLarsWorkspaces, } from "../utils/lars.js";
|
|
5
|
+
import { readState, writeState, getLarsInstance, setLarsInstance, removeLarsInstance, } from "../state.js";
|
|
6
|
+
import { HMI_DIR, LARS_GC_MIN_AGE_H, LARS_GC_STATIONS_DIRS } from "../utils/config.js";
|
|
7
|
+
import { findLsmPath, parseSolution, readVisuStationIds } from "../utils/projectScanner.js";
|
|
8
|
+
import { respond, fail } from "../utils/respond.js";
|
|
9
|
+
export const larsRuntimeSchema = {
|
|
10
|
+
action: z
|
|
11
|
+
.enum(["list", "setup", "start", "stop", "remove", "set_station_target", "restore", "target_pc", "gc"])
|
|
12
|
+
.describe("'list' shows all configured LARS workspaces and their state (auto-cleans stale ones). " +
|
|
13
|
+
"'setup' creates/updates LARS workspaces for the selected project's stations (or one station/lcp. " +
|
|
14
|
+
"'start' launches a LARS instance (auto-creates the workspace first if the station is known but unconfigured), 'stop' terminates it, 'remove' deletes its workspace config. " +
|
|
15
|
+
"'gc' runs lazy garbage collection: deletes auto-created workspaces that are not running and no longer referenced by any station .lss or published DataService stations.json. " +
|
|
16
|
+
"'set_station_target' points a station's .lss at its LARS instance (127.0.0.1:<port>); 'restore' reverts to the saved real target. " +
|
|
17
|
+
"'target_pc' switches an ARM-compiled .lcp to the PC (x86) compile target LARS requires (restore via 'restore')."),
|
|
18
|
+
name: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("LARS workspace name (e.g. 'VisuPalletizer_PLC'). Required for start/stop/remove/set_station_target/restore when station is omitted."),
|
|
22
|
+
station: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Station name from the solution (e.g. 'PLC', 'HMI', 'Local'). Picks the matching workspace. Mutually exclusive with name."),
|
|
26
|
+
lcp_path: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Absolute path to a .lcp to create a workspace for (setup only, when no station name is usable)."),
|
|
30
|
+
project_dir: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Solution directory to auto-detect stations from (setup/list only. Defaults to the selected project."),
|
|
34
|
+
dry_run: z
|
|
35
|
+
.boolean()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("With action 'gc': report what would be removed without deleting anything. Default false."),
|
|
38
|
+
min_age_h: z
|
|
39
|
+
.number()
|
|
40
|
+
.int()
|
|
41
|
+
.min(0)
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("With action 'gc': only delete workspaces unreferenced for at least this many hours (overrides LASAL_MCP_LARS_GC_MIN_AGE_H; 0 = immediately."),
|
|
44
|
+
};
|
|
45
|
+
function detectRole(lcpPath) {
|
|
46
|
+
try {
|
|
47
|
+
const lcpDir = dirname(lcpPath);
|
|
48
|
+
const classDir = join(lcpDir, "Class");
|
|
49
|
+
if (existsSync(classDir)) {
|
|
50
|
+
const entries = readdirSync(classDir);
|
|
51
|
+
if (entries.some((e) => /^_Screen$/i.test(e) || /^_Lse$/i.test(e)))
|
|
52
|
+
return "hmi";
|
|
53
|
+
}
|
|
54
|
+
if (/hmi|visu/i.test(lcpPath))
|
|
55
|
+
return "hmi";
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
function detectStationRole(station) {
|
|
61
|
+
// The station name + presence of a web visu (LVD) project is the reliable
|
|
62
|
+
// discriminator: machine CPUs and panel CPUs both use LSE screen classes.
|
|
63
|
+
if (/hmi|panel|visu|screen/i.test(station.name))
|
|
64
|
+
return "hmi";
|
|
65
|
+
if (station.lvpPaths.length > 0)
|
|
66
|
+
return "hmi";
|
|
67
|
+
if (/plc|machine|mach|local/i.test(station.name))
|
|
68
|
+
return "plc";
|
|
69
|
+
return "unknown";
|
|
70
|
+
}
|
|
71
|
+
/** Read the VISU stationId map (station name -> id) for a project's first HMI station. */
|
|
72
|
+
function resolveVisuStationIds(projectDir) {
|
|
73
|
+
for (const stn of detectStations(projectDir)) {
|
|
74
|
+
if (stn.role === "hmi" && stn.lvpPath) {
|
|
75
|
+
const ids = readVisuStationIds(stn.lvpPath);
|
|
76
|
+
if (Object.keys(ids).length > 0)
|
|
77
|
+
return ids;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
function detectStations(projectDir) {
|
|
83
|
+
const lsmPath = findLsmPath(projectDir);
|
|
84
|
+
if (!lsmPath)
|
|
85
|
+
return [];
|
|
86
|
+
try {
|
|
87
|
+
const solution = parseSolution(lsmPath);
|
|
88
|
+
const result = [];
|
|
89
|
+
for (const stn of solution.stations) {
|
|
90
|
+
const lcp = stn.lcpPaths[0];
|
|
91
|
+
if (!lcp)
|
|
92
|
+
continue;
|
|
93
|
+
result.push({
|
|
94
|
+
stationName: stn.name,
|
|
95
|
+
lssPath: stn.lssPath,
|
|
96
|
+
lcpPath: lcp,
|
|
97
|
+
lvpPath: stn.lvpPaths[0],
|
|
98
|
+
role: detectStationRole(stn),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function workspaceSummary(ws, instances) {
|
|
108
|
+
const inst = instances?.[ws.name];
|
|
109
|
+
const pids = getLarsPids(ws.name);
|
|
110
|
+
const running = pids.length > 0;
|
|
111
|
+
return {
|
|
112
|
+
name: ws.name,
|
|
113
|
+
onlinePort: ws.onlinePort,
|
|
114
|
+
comlinkServerPort: ws.comlinkServerPort,
|
|
115
|
+
comlinkBasePort: ws.comlinkBasePort,
|
|
116
|
+
alarmPort: ws.alarmPort,
|
|
117
|
+
classProjectPath: ws.classProjectPath ?? null,
|
|
118
|
+
screenProjectPath: ws.screenProjectPath ?? null,
|
|
119
|
+
running,
|
|
120
|
+
pid: pids[0] ?? inst?.pid ?? null,
|
|
121
|
+
healthy: running ? await isLarsHealthy(ws.onlinePort) : false,
|
|
122
|
+
stationName: inst?.stationName ?? null,
|
|
123
|
+
lcpPath: inst?.lcpPath ?? null,
|
|
124
|
+
lssPath: inst?.stationLssPath ?? null,
|
|
125
|
+
role: inst?.role ?? null,
|
|
126
|
+
targetedAtLars: inst?.originalIp ? true : false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function requireWorkspace(args) {
|
|
130
|
+
const state = readState();
|
|
131
|
+
const workspaces = readLarsWorkspaces();
|
|
132
|
+
const instances = state.larsInstances ?? {};
|
|
133
|
+
if (args.name) {
|
|
134
|
+
const workspace = workspaces.find((w) => w.name === args.name);
|
|
135
|
+
if (!workspace)
|
|
136
|
+
return { error: `No LARS workspace named '${args.name}' found. Use action 'setup' first.` };
|
|
137
|
+
return { workspace, instance: instances[args.name] };
|
|
138
|
+
}
|
|
139
|
+
if (args.station) {
|
|
140
|
+
const inst = Object.values(instances).find((i) => i.stationName === args.station);
|
|
141
|
+
const workspace = inst ? workspaces.find((w) => w.name === inst.name) : undefined;
|
|
142
|
+
if (!inst || !workspace) {
|
|
143
|
+
return { error: `No LARS instance for station '${args.station}'. Use action 'setup' first.` };
|
|
144
|
+
}
|
|
145
|
+
return { workspace, instance: inst };
|
|
146
|
+
}
|
|
147
|
+
return { error: "Provide either 'name' (workspace name) or 'station' (station name)." };
|
|
148
|
+
}
|
|
149
|
+
function runAutoGc(state, dryRun = false) {
|
|
150
|
+
const result = gcLarsWorkspaces({
|
|
151
|
+
dryRun,
|
|
152
|
+
minAgeH: LARS_GC_MIN_AGE_H,
|
|
153
|
+
dataDirs: [HMI_DIR, ...LARS_GC_STATIONS_DIRS],
|
|
154
|
+
larsGc: state.larsGc,
|
|
155
|
+
instances: state.larsInstances,
|
|
156
|
+
});
|
|
157
|
+
if (!dryRun) {
|
|
158
|
+
state.larsGc = result.larsGc;
|
|
159
|
+
writeState(state);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 'start' auto-creates the workspace whenthe the station is known but not yet configured:
|
|
165
|
+
* eitherthe instance bookkeeping (state) still knows its .lcp, or the station can be
|
|
166
|
+
* detected from the solution. Ports stay stable as long as the workspace exists; a recreated
|
|
167
|
+
* workspace may get a new port block, which is fine since nothing points at it yet.
|
|
168
|
+
*/
|
|
169
|
+
function ensureWorkspaceForStart(args, state) {
|
|
170
|
+
const workspaces = readLarsWorkspaces();
|
|
171
|
+
if (args.name) {
|
|
172
|
+
const existing = workspaces.find((w) => w.name === args.name);
|
|
173
|
+
if (existing)
|
|
174
|
+
return { workspace: existing, instance: state.larsInstances?.[args.name] };
|
|
175
|
+
const inst = state.larsInstances?.[args.name];
|
|
176
|
+
if (inst) {
|
|
177
|
+
const { workspace } = upsertLarsWorkspace(args.name, {
|
|
178
|
+
...(inst.lcpPath ? { classProjectPath: inst.lcpPath } : {}),
|
|
179
|
+
});
|
|
180
|
+
return { workspace, instance: inst, autoCreated: true };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
error: `No LARS workspace named '${args.name}' found and no instance bookkeeping exists — run lars_runtime setup first.`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (args.station) {
|
|
187
|
+
const inst = Object.values(state.larsInstances ?? {}).find((i) => i.stationName === args.station);
|
|
188
|
+
if (inst) {
|
|
189
|
+
const existing = workspaces.find((w) => w.name === inst.name);
|
|
190
|
+
if (existing)
|
|
191
|
+
return { workspace: existing, instance: inst };
|
|
192
|
+
const { workspace } = upsertLarsWorkspace(inst.name, {
|
|
193
|
+
...(inst.lcpPath ? { classProjectPath: inst.lcpPath } : {}),
|
|
194
|
+
});
|
|
195
|
+
return { workspace, instance: inst, autoCreated: true };
|
|
196
|
+
}
|
|
197
|
+
// No bookkeeping — try to detectthe station from the solution and set it up now
|
|
198
|
+
const projectDir = args.project_dir ?? state.currentProject;
|
|
199
|
+
if (projectDir) {
|
|
200
|
+
const projectName = projectDir.split(/[\\/]/).filter(Boolean).pop() ?? "project";
|
|
201
|
+
const stations = detectStations(projectDir);
|
|
202
|
+
const visuStationIds = resolveVisuStationIds(projectDir);
|
|
203
|
+
const station = stations.find((s) => s.stationName === args.station);
|
|
204
|
+
if (station) {
|
|
205
|
+
const name = safeWorkspaceName(projectName, station.stationName);
|
|
206
|
+
const { workspace } = upsertLarsWorkspace(name, { classProjectPath: station.lcpPath });
|
|
207
|
+
const info = {
|
|
208
|
+
name: workspace.name,
|
|
209
|
+
onlinePort: workspace.onlinePort,
|
|
210
|
+
stationName: station.stationName,
|
|
211
|
+
stationId: visuStationIds?.[station.stationName],
|
|
212
|
+
stationLssPath: station.lssPath,
|
|
213
|
+
lcpPath: station.lcpPath,
|
|
214
|
+
projectDir,
|
|
215
|
+
role: station.role,
|
|
216
|
+
};
|
|
217
|
+
setLarsInstance(state, info);
|
|
218
|
+
writeState(state);
|
|
219
|
+
return { workspace, instance: info, autoCreated: true };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
error: `No LARS instance for station '${args.station}' found and none detectable in the solution — run lars_runtime setup first.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { error: "Provide either 'name' (workspace name) or 'station' (station name." };
|
|
227
|
+
}
|
|
228
|
+
export async function larsRuntimeHandler(args) {
|
|
229
|
+
const state = readState();
|
|
230
|
+
const action = args.action;
|
|
231
|
+
if (action === "list") {
|
|
232
|
+
// Lazy cleanup first: stale auto-created workspaces (not running, nothing points at them) are dropped automatically.
|
|
233
|
+
const gcResult = runAutoGc(state);
|
|
234
|
+
const projectDir = args.project_dir ?? state.currentProject;
|
|
235
|
+
const workspaces = readLarsWorkspaces();
|
|
236
|
+
const projectName = projectDir ? (projectDir.split(/[\\/]/).filter(Boolean).pop() ?? "project") : null;
|
|
237
|
+
const detected = projectDir ? detectStations(projectDir) : [];
|
|
238
|
+
// Show detected-but-not-configured stations so the agent knows what to set up
|
|
239
|
+
const unconfigured = detected
|
|
240
|
+
.filter((d) => projectName === null || !workspaces.some((w) => w.name === safeWorkspaceName(projectName, d.stationName)))
|
|
241
|
+
.map((d) => ({ stationName: d.stationName, lcpPath: d.lcpPath, role: d.role }));
|
|
242
|
+
return respond({
|
|
243
|
+
ok: true,
|
|
244
|
+
larsExe: LARS_EXE,
|
|
245
|
+
exists: existsSync(LARS_EXE),
|
|
246
|
+
configPath: larsConfigPath(),
|
|
247
|
+
workspaces: await Promise.all(workspaces.map((w) => workspaceSummary(w, state.larsInstances))),
|
|
248
|
+
gc: {
|
|
249
|
+
checked: gcResult.kept.length + gcResult.removed.length + gcResult.candidates.length,
|
|
250
|
+
removed: gcResult.removed,
|
|
251
|
+
candidates: gcResult.candidates,
|
|
252
|
+
},
|
|
253
|
+
...(unconfigured.length ? { unconfiguredStations: unconfigured } : {}),
|
|
254
|
+
hint: "Call lars_runtime setup to create workspaces for all stations, then start + set_station_target per station. Unreferenced auto-created workspaces are cleaned up automatically.",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (action === "setup") {
|
|
258
|
+
const projectDir = args.project_dir ?? state.currentProject;
|
|
259
|
+
if (!projectDir) {
|
|
260
|
+
return fail("No project selected.", ["Call select_project first or pass project_dir."]);
|
|
261
|
+
}
|
|
262
|
+
// Drop stale auto-created workspaces first, so port allocation sees the current set.
|
|
263
|
+
runAutoGc(state);
|
|
264
|
+
const projectName = projectDir.split(/[\\/]/).filter(Boolean).pop() ?? "project";
|
|
265
|
+
const stations = detectStations(projectDir);
|
|
266
|
+
const created = [];
|
|
267
|
+
// Map station names to their VISU stationIds (e.g. PLC -> 10, HMI -> 255) so the
|
|
268
|
+
// published DataService stations.json (numeric `station` field) can be pointed at
|
|
269
|
+
// the right LARS instance. Read from the first HMI/lvp station's Stations.json.
|
|
270
|
+
const visuStationIds = resolveVisuStationIds(projectDir);
|
|
271
|
+
if (args.lcp_path) {
|
|
272
|
+
// Single lcp: build a workspace named after the project + lcp base
|
|
273
|
+
const lcpBase = args.lcp_path
|
|
274
|
+
.split(/[\\/]/)
|
|
275
|
+
.filter(Boolean)
|
|
276
|
+
.pop()
|
|
277
|
+
?.replace(/\.lcp$/i, "") ?? "project";
|
|
278
|
+
const name = safeWorkspaceName(projectName, lcpBase);
|
|
279
|
+
const role = detectRole(args.lcp_path);
|
|
280
|
+
const { workspace } = upsertLarsWorkspace(name, {
|
|
281
|
+
classProjectPath: args.lcp_path,
|
|
282
|
+
});
|
|
283
|
+
setLarsInstance(state, {
|
|
284
|
+
name: workspace.name,
|
|
285
|
+
onlinePort: workspace.onlinePort,
|
|
286
|
+
lcpPath: args.lcp_path,
|
|
287
|
+
projectDir,
|
|
288
|
+
role,
|
|
289
|
+
});
|
|
290
|
+
writeState(state);
|
|
291
|
+
created.push({ name: workspace.name, onlinePort: workspace.onlinePort, lcpPath: args.lcp_path, role });
|
|
292
|
+
}
|
|
293
|
+
else if (args.station) {
|
|
294
|
+
const station = stations.find((s) => s.stationName === args.station);
|
|
295
|
+
if (!station)
|
|
296
|
+
return fail(`Station '${args.station}' not found in ${projectDir}.`, [
|
|
297
|
+
"Check lasal_status for the station list.",
|
|
298
|
+
]);
|
|
299
|
+
const name = safeWorkspaceName(projectName, station.stationName);
|
|
300
|
+
const { workspace } = upsertLarsWorkspace(name, {
|
|
301
|
+
classProjectPath: station.lcpPath,
|
|
302
|
+
});
|
|
303
|
+
setLarsInstance(state, {
|
|
304
|
+
name: workspace.name,
|
|
305
|
+
onlinePort: workspace.onlinePort,
|
|
306
|
+
stationName: station.stationName,
|
|
307
|
+
stationId: visuStationIds[station.stationName],
|
|
308
|
+
stationLssPath: station.lssPath,
|
|
309
|
+
lcpPath: station.lcpPath,
|
|
310
|
+
projectDir,
|
|
311
|
+
role: station.role,
|
|
312
|
+
});
|
|
313
|
+
writeState(state);
|
|
314
|
+
created.push({
|
|
315
|
+
name: workspace.name,
|
|
316
|
+
onlinePort: workspace.onlinePort,
|
|
317
|
+
stationName: station.stationName,
|
|
318
|
+
lcpPath: station.lcpPath,
|
|
319
|
+
role: station.role,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
for (const station of stations) {
|
|
324
|
+
const name = safeWorkspaceName(projectName, station.stationName);
|
|
325
|
+
const { workspace } = upsertLarsWorkspace(name, {
|
|
326
|
+
classProjectPath: station.lcpPath,
|
|
327
|
+
});
|
|
328
|
+
setLarsInstance(state, {
|
|
329
|
+
name: workspace.name,
|
|
330
|
+
onlinePort: workspace.onlinePort,
|
|
331
|
+
stationName: station.stationName,
|
|
332
|
+
stationId: visuStationIds[station.stationName],
|
|
333
|
+
stationLssPath: station.lssPath,
|
|
334
|
+
lcpPath: station.lcpPath,
|
|
335
|
+
projectDir,
|
|
336
|
+
role: station.role,
|
|
337
|
+
});
|
|
338
|
+
created.push({
|
|
339
|
+
name: workspace.name,
|
|
340
|
+
onlinePort: workspace.onlinePort,
|
|
341
|
+
stationName: station.stationName,
|
|
342
|
+
lcpPath: station.lcpPath,
|
|
343
|
+
role: station.role,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
writeState(state);
|
|
347
|
+
}
|
|
348
|
+
if (created.length === 0) {
|
|
349
|
+
return fail("No stations with .lcp projects found.", [
|
|
350
|
+
"Ensure the solution has a .lsm file with station .lss files.",
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
return respond({
|
|
354
|
+
ok: true,
|
|
355
|
+
created,
|
|
356
|
+
hint: "Next: lars_runtime start for each workspace, then set_station_target to point the station's .lss at LARS.",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (action === "gc") {
|
|
360
|
+
const dryRun = args.dry_run ?? false;
|
|
361
|
+
const gcResult = gcLarsWorkspaces({
|
|
362
|
+
dryRun,
|
|
363
|
+
minAgeH: args.min_age_h ?? LARS_GC_MIN_AGE_H,
|
|
364
|
+
dataDirs: [HMI_DIR, ...LARS_GC_STATIONS_DIRS],
|
|
365
|
+
larsGc: state.larsGc,
|
|
366
|
+
instances: state.larsInstances,
|
|
367
|
+
});
|
|
368
|
+
if (!dryRun) {
|
|
369
|
+
state.larsGc = gcResult.larsGc;
|
|
370
|
+
writeState(state);
|
|
371
|
+
}
|
|
372
|
+
return respond({
|
|
373
|
+
ok: true,
|
|
374
|
+
dryRun,
|
|
375
|
+
gc: {
|
|
376
|
+
checked: gcResult.kept.length + gcResult.removed.length + gcResult.candidates.length,
|
|
377
|
+
removed: gcResult.removed,
|
|
378
|
+
kept: gcResult.kept,
|
|
379
|
+
candidates: gcResult.candidates,
|
|
380
|
+
},
|
|
381
|
+
hint: dryRun
|
|
382
|
+
? "Dry run — nothing deleted. Remove dry_run:false to actually clean up."
|
|
383
|
+
: "Unreferenced auto-created workspaces were removed. 'start' will auto-recreate one on demand if a station needs it again.",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
if (action === "start" ||
|
|
387
|
+
action === "stop" ||
|
|
388
|
+
action === "remove" ||
|
|
389
|
+
action === "set_station_target" ||
|
|
390
|
+
action === "restore" ||
|
|
391
|
+
action === "target_pc") {
|
|
392
|
+
// 'start' auto-creates the workspace when the station is known but unconfigured;
|
|
393
|
+
// all other actions require an existing configured workspace.
|
|
394
|
+
const req = action === "start" ? ensureWorkspaceForStart(args, state) : requireWorkspace(args);
|
|
395
|
+
if (req.error)
|
|
396
|
+
return fail(req.error, []);
|
|
397
|
+
const workspace = req.workspace;
|
|
398
|
+
const autoCreated = req.autoCreated ?? false;
|
|
399
|
+
if (action === "stop") {
|
|
400
|
+
killLars(workspace.name);
|
|
401
|
+
const inst = req.instance;
|
|
402
|
+
if (inst) {
|
|
403
|
+
inst.pid = undefined;
|
|
404
|
+
setLarsInstance(state, inst);
|
|
405
|
+
writeState(state);
|
|
406
|
+
}
|
|
407
|
+
return respond({ ok: true, message: `LARS workspace '${workspace.name}' stopped.` });
|
|
408
|
+
}
|
|
409
|
+
if (action === "remove") {
|
|
410
|
+
killLars(workspace.name);
|
|
411
|
+
removeLarsWorkspace(workspace.name);
|
|
412
|
+
removeLarsInstance(state, workspace.name);
|
|
413
|
+
writeState(state);
|
|
414
|
+
return respond({ ok: true, message: `LARS workspace '${workspace.name}' removed (ports freed).` });
|
|
415
|
+
}
|
|
416
|
+
if (action === "start") {
|
|
417
|
+
const result = await startLars(workspace);
|
|
418
|
+
if (result.error)
|
|
419
|
+
return fail(result.error, ["Check LASAL_LARS_EXE / the LARS installation."]);
|
|
420
|
+
// Poll for online port availability (the runtime takes a moment to bind)
|
|
421
|
+
let healthy = result.healthy;
|
|
422
|
+
if (!healthy && result.running) {
|
|
423
|
+
const start = Date.now();
|
|
424
|
+
while (Date.now() - start < 8000) {
|
|
425
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
426
|
+
healthy = await isLarsHealthy(workspace.onlinePort);
|
|
427
|
+
if (healthy)
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const inst = req.instance ?? getLarsInstance(state, workspace.name);
|
|
432
|
+
if (inst) {
|
|
433
|
+
inst.pid = result.pid ?? inst.pid;
|
|
434
|
+
inst.onlinePort = workspace.onlinePort;
|
|
435
|
+
setLarsInstance(state, inst);
|
|
436
|
+
writeState(state);
|
|
437
|
+
}
|
|
438
|
+
return respond({
|
|
439
|
+
ok: true,
|
|
440
|
+
name: workspace.name,
|
|
441
|
+
onlinePort: workspace.onlinePort,
|
|
442
|
+
running: true,
|
|
443
|
+
healthy,
|
|
444
|
+
pid: result.pid,
|
|
445
|
+
...(autoCreated ? { autoCreated: true } : {}),
|
|
446
|
+
hint: autoCreated
|
|
447
|
+
? "Workspace was auto-created on demand (station known but unconfigured), then LARS started. Next: set_station_target to point the station's .lss at it."
|
|
448
|
+
: healthy
|
|
449
|
+
? "LARS is up. Next: lars_runtime set_station_target <workspace> so build_project/control_plc/plc_values target it."
|
|
450
|
+
: "LARS launched but the online port is not answering yet — check the LARS window or lasal_status.",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
if (action === "set_station_target") {
|
|
454
|
+
const inst = req.instance;
|
|
455
|
+
if (!inst?.stationLssPath) {
|
|
456
|
+
return fail(`No station .lss known for workspace '${workspace.name}'.`, [
|
|
457
|
+
"Run setup with the station linked first.",
|
|
458
|
+
]);
|
|
459
|
+
}
|
|
460
|
+
if (!existsSync(inst.stationLssPath)) {
|
|
461
|
+
return fail(`Station .lss not found: ${inst.stationLssPath}`, []);
|
|
462
|
+
}
|
|
463
|
+
const result = pointStationAtLars(inst.stationLssPath, workspace.onlinePort);
|
|
464
|
+
if ("error" in result)
|
|
465
|
+
return fail(result.error, []);
|
|
466
|
+
if (inst.originalIp === undefined) {
|
|
467
|
+
inst.originalIp = result.previousIp;
|
|
468
|
+
inst.originalPort = result.previousPort;
|
|
469
|
+
}
|
|
470
|
+
setLarsInstance(state, inst);
|
|
471
|
+
writeState(state);
|
|
472
|
+
return respond({
|
|
473
|
+
ok: true,
|
|
474
|
+
workspace: workspace.name,
|
|
475
|
+
station: inst.stationName,
|
|
476
|
+
lssPath: inst.stationLssPath,
|
|
477
|
+
nowTargeting: `127.0.0.1:${workspace.onlinePort} (LARS)`,
|
|
478
|
+
previousTarget: `${result.previousIp}:${result.previousPort}`,
|
|
479
|
+
hint: "Now build_project/control_plc/plc_values with no connection argument will hit LARS. Use 'restore' to revert.",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (action === "restore") {
|
|
483
|
+
const inst = req.instance;
|
|
484
|
+
if (!inst?.stationLssPath)
|
|
485
|
+
return fail("No station .lss known for this workspace.", []);
|
|
486
|
+
if (!inst.originalIp)
|
|
487
|
+
return fail("No saved original target — set_station_target was never called.", []);
|
|
488
|
+
if (!existsSync(inst.stationLssPath))
|
|
489
|
+
return fail(`Station .lss not found: ${inst.stationLssPath}`, []);
|
|
490
|
+
const { updateLssConnection } = await import("../utils/projectScanner.js");
|
|
491
|
+
updateLssConnection(inst.stationLssPath, { ip: inst.originalIp, port: inst.originalPort });
|
|
492
|
+
const restored = { ip: inst.originalIp, port: inst.originalPort };
|
|
493
|
+
inst.originalIp = undefined;
|
|
494
|
+
inst.originalPort = undefined;
|
|
495
|
+
// Also restore the .lcp compile target if it was switched to PC
|
|
496
|
+
let targetRestored = null;
|
|
497
|
+
if (inst.originalTargetTag && inst.lcpPath && existsSync(inst.lcpPath)) {
|
|
498
|
+
const { restoreProjectTarget } = await import("../utils/lars.js");
|
|
499
|
+
if (restoreProjectTarget(inst.lcpPath, inst.originalTargetTag)) {
|
|
500
|
+
targetRestored = inst.originalTargetTag;
|
|
501
|
+
inst.originalTargetTag = undefined;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
setLarsInstance(state, inst);
|
|
505
|
+
writeState(state);
|
|
506
|
+
return respond({ ok: true, workspace: workspace.name, restored, targetRestored });
|
|
507
|
+
}
|
|
508
|
+
if (action === "target_pc") {
|
|
509
|
+
const inst = req.instance;
|
|
510
|
+
if (!inst?.lcpPath) {
|
|
511
|
+
return fail(`No .lcp known for workspace '${workspace.name}'.`, ["Run setup with the station linked first."]);
|
|
512
|
+
}
|
|
513
|
+
if (!existsSync(inst.lcpPath))
|
|
514
|
+
return fail(`Project .lcp not found: ${inst.lcpPath}`, []);
|
|
515
|
+
const { switchProjectTargetToPC } = await import("../utils/lars.js");
|
|
516
|
+
const result = switchProjectTargetToPC(inst.lcpPath);
|
|
517
|
+
if ("error" in result)
|
|
518
|
+
return fail(result.error, []);
|
|
519
|
+
if (inst.originalTargetTag === undefined) {
|
|
520
|
+
inst.originalTargetTag = result.previousTag;
|
|
521
|
+
}
|
|
522
|
+
setLarsInstance(state, inst);
|
|
523
|
+
writeState(state);
|
|
524
|
+
return respond({
|
|
525
|
+
ok: true,
|
|
526
|
+
workspace: workspace.name,
|
|
527
|
+
station: inst.stationName ?? null,
|
|
528
|
+
lcpPath: inst.lcpPath,
|
|
529
|
+
targetBefore: result.previousTag,
|
|
530
|
+
targetNow: result.newTag,
|
|
531
|
+
hint: "Now run build_project compile, then build_project download (the .lss already points at LARS) to get the PC image running.",
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return fail(`Unknown action: ${action}`, []);
|
|
536
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { readState } from "../state.js";
|
|
5
|
+
import { findLcpFiles, findLvpFiles } from "../utils/projectScanner.js";
|
|
6
|
+
import { CLASS2_EXE, VISUDESIGNER_EXE, killClass2, killVisuDesigner } from "../utils/engine.js";
|
|
7
|
+
function requireProject() {
|
|
8
|
+
const state = readState();
|
|
9
|
+
if (!state.currentProject) {
|
|
10
|
+
return { error: "No project selected. Call select_project first." };
|
|
11
|
+
}
|
|
12
|
+
return { path: state.currentProject };
|
|
13
|
+
}
|
|
14
|
+
function launchDetached(exe, args) {
|
|
15
|
+
const child = spawn(exe, args, {
|
|
16
|
+
detached: true,
|
|
17
|
+
stdio: "ignore",
|
|
18
|
+
windowsHide: false,
|
|
19
|
+
});
|
|
20
|
+
child.unref();
|
|
21
|
+
}
|
|
22
|
+
// --- manage_visudesigner ---
|
|
23
|
+
export const manageVisuDesignerSchema = {
|
|
24
|
+
action: z.enum(["open", "close"]).describe("'open' launches VISUDesigner; 'close' kills it."),
|
|
25
|
+
lvp_path: z
|
|
26
|
+
.string()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Full path to the .lvp station file to open (open only). Omit to auto-detect from the selected project."),
|
|
29
|
+
};
|
|
30
|
+
export async function manageVisuDesignerHandler(args) {
|
|
31
|
+
if (args.action === "close") {
|
|
32
|
+
killVisuDesigner();
|
|
33
|
+
return { content: [{ type: "text", text: "VISUDesigner closed." }] };
|
|
34
|
+
}
|
|
35
|
+
const proj = requireProject();
|
|
36
|
+
if ("error" in proj) {
|
|
37
|
+
return { content: [{ type: "text", text: proj.error }], isError: true };
|
|
38
|
+
}
|
|
39
|
+
const lvpPath = args.lvp_path;
|
|
40
|
+
if (!lvpPath) {
|
|
41
|
+
const found = findLvpFiles(proj.path);
|
|
42
|
+
if (found.length === 0) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: `No .lvp files found in ${proj.path}` }],
|
|
45
|
+
isError: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (found.length === 1 && found[0]) {
|
|
49
|
+
launchDetached(VISUDESIGNER_EXE, [found[0]]);
|
|
50
|
+
return { content: [{ type: "text", text: `VISUDesigner opened with: ${found[0]}` }] };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: "text",
|
|
56
|
+
text: ["Multiple .lvp stations found. Specify lvp_path with one of:", ...found.map((f) => ` ${f}`)].join("\n"),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (!existsSync(lvpPath)) {
|
|
62
|
+
return { content: [{ type: "text", text: `File not found: ${lvpPath}` }], isError: true };
|
|
63
|
+
}
|
|
64
|
+
launchDetached(VISUDESIGNER_EXE, [lvpPath]);
|
|
65
|
+
return { content: [{ type: "text", text: `VISUDesigner opened with: ${lvpPath}` }] };
|
|
66
|
+
}
|
|
67
|
+
// --- manage_class2 ---
|
|
68
|
+
export const manageClass2Schema = {
|
|
69
|
+
action: z.enum(["open", "close"]).describe("'open' launches LASAL CLASS 2; 'close' kills it."),
|
|
70
|
+
lcp_path: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe("Full path to the .lcp station file to open (open only). Omit to auto-detect from the selected project."),
|
|
74
|
+
};
|
|
75
|
+
export async function manageClass2Handler(args) {
|
|
76
|
+
if (args.action === "close") {
|
|
77
|
+
killClass2();
|
|
78
|
+
return { content: [{ type: "text", text: "CLASS 2 closed." }] };
|
|
79
|
+
}
|
|
80
|
+
const proj = requireProject();
|
|
81
|
+
if ("error" in proj) {
|
|
82
|
+
return { content: [{ type: "text", text: proj.error }], isError: true };
|
|
83
|
+
}
|
|
84
|
+
const lcpPath = args.lcp_path;
|
|
85
|
+
if (!lcpPath) {
|
|
86
|
+
const found = findLcpFiles(proj.path);
|
|
87
|
+
if (found.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: `No .lcp files found in ${proj.path}` }],
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (found.length === 1 && found[0]) {
|
|
94
|
+
launchDetached(CLASS2_EXE, [found[0]]);
|
|
95
|
+
return { content: [{ type: "text", text: `CLASS 2 opened with: ${found[0]}` }] };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
content: [
|
|
99
|
+
{
|
|
100
|
+
type: "text",
|
|
101
|
+
text: ["Multiple .lcp stations found. Specify lcp_path with one of:", ...found.map((f) => ` ${f}`)].join("\n"),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (!existsSync(lcpPath)) {
|
|
107
|
+
return { content: [{ type: "text", text: `File not found: ${lcpPath}` }], isError: true };
|
|
108
|
+
}
|
|
109
|
+
launchDetached(CLASS2_EXE, [lcpPath]);
|
|
110
|
+
return { content: [{ type: "text", text: `CLASS 2 opened with: ${lcpPath}` }] };
|
|
111
|
+
}
|