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,273 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawn, execSync } from "child_process";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync, unlinkSync, } from "fs";
|
|
4
|
+
import { join, dirname } from "path";
|
|
5
|
+
import { readState, writeState, getHmiForProject, setHmiForProject, clearHmiForProject } from "../state.js";
|
|
6
|
+
import { DATASERVICE_EXE, killDataService, withEngineLock } from "../utils/engine.js";
|
|
7
|
+
import { resolveLvpPath } from "../utils/resolvePaths.js";
|
|
8
|
+
import { runVisuOps } from "../utils/visuScript.js";
|
|
9
|
+
import { respond, fail } from "../utils/respond.js";
|
|
10
|
+
import { checkHttpHealth } from "../core/http.js";
|
|
11
|
+
import { isPidRunning, getPortForPid } from "../core/process.js";
|
|
12
|
+
import { startStaticServer, stopStaticServer } from "../core/staticServer.js";
|
|
13
|
+
export const hmiRuntimeSchema = {
|
|
14
|
+
action: z.enum(["start", "stop", "status"]).describe("Action to perform on the HMI runtime DataService."),
|
|
15
|
+
lvp_path: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Absolute path to the .lvp file. Omit to use the currently selected project."),
|
|
19
|
+
debugPublish: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.optional()
|
|
22
|
+
.default(true)
|
|
23
|
+
.describe("Use debug publish (requires TypeScript project support). Fallback to standard publish on failure. Default true."),
|
|
24
|
+
publishFirst: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.default(true)
|
|
28
|
+
.describe("Publish the project before starting the DataService. Default true."),
|
|
29
|
+
};
|
|
30
|
+
function copyDirSync(src, dest) {
|
|
31
|
+
if (!existsSync(dest))
|
|
32
|
+
mkdirSync(dest, { recursive: true });
|
|
33
|
+
for (const item of readdirSync(src)) {
|
|
34
|
+
const srcPath = join(src, item);
|
|
35
|
+
const destPath = join(dest, item);
|
|
36
|
+
const stats = statSync(srcPath);
|
|
37
|
+
if (stats.isDirectory()) {
|
|
38
|
+
copyDirSync(srcPath, destPath);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
copyFileSync(srcPath, destPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function createJunction(src, dest) {
|
|
46
|
+
if (existsSync(dest)) {
|
|
47
|
+
try {
|
|
48
|
+
execSync(`rmdir "${dest}"`, { stdio: "pipe" });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(dest);
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
execSync(`mklink /J "${dest}" "${src}"`, { stdio: "pipe" });
|
|
58
|
+
}
|
|
59
|
+
export async function startHmiRuntime(args) {
|
|
60
|
+
const state = readState();
|
|
61
|
+
const warnings = [];
|
|
62
|
+
if (args.action === "stop") {
|
|
63
|
+
stopStaticServer();
|
|
64
|
+
const running = getHmiForProject(state);
|
|
65
|
+
if (running) {
|
|
66
|
+
killDataService(running.pid);
|
|
67
|
+
clearHmiForProject(state);
|
|
68
|
+
writeState(state);
|
|
69
|
+
return respond({ ok: true, message: `HMI runtime DataService (PID ${running.pid}) stopped.` });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
killDataService();
|
|
73
|
+
return respond({ ok: true, message: "No tracked HMI runtime running. Attempted global process kill." });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (args.action === "status") {
|
|
77
|
+
const running = getHmiForProject(state);
|
|
78
|
+
if (running && isPidRunning(running.pid)) {
|
|
79
|
+
const healthy = await checkHttpHealth(`http://127.0.0.1:${running.port}/`, 1000, true);
|
|
80
|
+
// Make sure the static web server is up (it dies with the MCP process, unlike the DataService)
|
|
81
|
+
let url = running.url;
|
|
82
|
+
try {
|
|
83
|
+
const httpPort = await startStaticServer(running.dataDir);
|
|
84
|
+
url = `http://127.0.0.1:${httpPort}/index.html`;
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
return respond({ ok: true, running: true, healthy, ...running, url });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (running) {
|
|
91
|
+
clearHmiForProject(state);
|
|
92
|
+
writeState(state);
|
|
93
|
+
}
|
|
94
|
+
return respond({ ok: true, running: false });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// action === "start"
|
|
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
|
+
// 1. Publish first if requested
|
|
103
|
+
if (args.publishFirst ?? true) {
|
|
104
|
+
let debug = args.debugPublish ?? true;
|
|
105
|
+
let publishResult = await runVisuOps(resolved.path, [{ type: "publish", debug }]);
|
|
106
|
+
if (!publishResult.ok && debug) {
|
|
107
|
+
warnings.push("Debug publish failed (possibly due to disabled TypeScript). Retrying with standard publish...");
|
|
108
|
+
publishResult = await runVisuOps(resolved.path, [{ type: "publish", debug: false }]);
|
|
109
|
+
}
|
|
110
|
+
if (!publishResult.ok) {
|
|
111
|
+
return fail(`Failed to publish project before starting HMI runtime: ${publishResult.errors.join("\n")}`, []);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// 2. Discover published folders
|
|
115
|
+
const visuDir = dirname(resolved.path);
|
|
116
|
+
const webrootSrc = join(visuDir, "TempPreview", "Publish", "webroot");
|
|
117
|
+
const dataSrc = join(visuDir, "TempPreview", "Publish", "dataservice", "data");
|
|
118
|
+
if (!existsSync(webrootSrc)) {
|
|
119
|
+
return fail(`Published webroot not found at: ${webrootSrc}. Run HMI publish first.`, []);
|
|
120
|
+
}
|
|
121
|
+
// 3. Prepare HMI Dir (with override support)
|
|
122
|
+
const { HMI_DIR } = await import("../utils/config.js");
|
|
123
|
+
const dataDir = HMI_DIR;
|
|
124
|
+
try {
|
|
125
|
+
if (!existsSync(dataDir))
|
|
126
|
+
mkdirSync(dataDir, { recursive: true });
|
|
127
|
+
// Copy webroot files
|
|
128
|
+
copyDirSync(webrootSrc, dataDir);
|
|
129
|
+
// Copy dataservice data if available
|
|
130
|
+
if (existsSync(dataSrc)) {
|
|
131
|
+
copyDirSync(dataSrc, join(dataDir, "dataservice", "data"));
|
|
132
|
+
}
|
|
133
|
+
// Link rt folder
|
|
134
|
+
const visuDesignerDir = DATASERVICE_EXE.substring(0, DATASERVICE_EXE.indexOf("\\Lasal VISUDesigner"));
|
|
135
|
+
const rtSrc = join(visuDesignerDir, "SIGMATEK", "Lasal", "VISUDesigner", "Runtime", "rt");
|
|
136
|
+
if (existsSync(rtSrc)) {
|
|
137
|
+
try {
|
|
138
|
+
createJunction(rtSrc, join(dataDir, "rt"));
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
142
|
+
warnings.push(`Warning: Failed to create junction for 'rt' directory: ${msg}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
warnings.push(`Warning: VISUDesigner Runtime 'rt' directory not found at ${rtSrc}. Paths starting with 'rt/' might fail to resolve.`);
|
|
147
|
+
}
|
|
148
|
+
// Create logs dir
|
|
149
|
+
const logDir = join(dataDir, "dataservice", "logs");
|
|
150
|
+
if (!existsSync(logDir))
|
|
151
|
+
mkdirSync(logDir, { recursive: true });
|
|
152
|
+
// 4. Map hardware-targeted stations to running LARS instances (if any),
|
|
153
|
+
// otherwise keep the published connection config as-is.
|
|
154
|
+
const stationsJsonPath = join(dataDir, "dataservice", "data", "stations.json");
|
|
155
|
+
if (existsSync(stationsJsonPath)) {
|
|
156
|
+
try {
|
|
157
|
+
const stations = JSON.parse(readFileSync(stationsJsonPath, "utf-8"));
|
|
158
|
+
const list = Array.isArray(stations.stations) ? stations.stations : null;
|
|
159
|
+
if (list) {
|
|
160
|
+
const { mapStationsToLars } = await import("../utils/lars.js");
|
|
161
|
+
const mapping = mapStationsToLars(list, state.larsInstances ?? {});
|
|
162
|
+
if (mapping.length > 0) {
|
|
163
|
+
writeFileSync(stationsJsonPath, JSON.stringify(stations, null, 2), "utf-8");
|
|
164
|
+
warnings.push(`DataService stations mapped to local LARS: ${mapping
|
|
165
|
+
.map((m) => `${m.station} ${m.from} -> ${m.to}`)
|
|
166
|
+
.join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch { }
|
|
171
|
+
}
|
|
172
|
+
// 4b. Point the HMI frontend's WebSocket at the local DataService instead of the real panel
|
|
173
|
+
const dsconfigPath = join(dataDir, "res", "data", "dsconfig.json");
|
|
174
|
+
if (existsSync(dsconfigPath)) {
|
|
175
|
+
try {
|
|
176
|
+
const dsconfig = JSON.parse(readFileSync(dsconfigPath, "utf-8"));
|
|
177
|
+
dsconfig.ip = "127.0.0.1";
|
|
178
|
+
writeFileSync(dsconfigPath, JSON.stringify(dsconfig), "utf-8");
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
}
|
|
182
|
+
// 5. Patch config.json
|
|
183
|
+
const configPath = join(dataDir, "dataservice", "config.json");
|
|
184
|
+
const dataConfigPath = join(dataDir, "dataservice", "data", "config.json");
|
|
185
|
+
const configContent = {
|
|
186
|
+
WindowsAutoExit: false,
|
|
187
|
+
WSAccessLog: true,
|
|
188
|
+
WSErrorLog: true,
|
|
189
|
+
LogDir: logDir,
|
|
190
|
+
};
|
|
191
|
+
writeFileSync(configPath, JSON.stringify(configContent, null, 2), "utf-8");
|
|
192
|
+
if (existsSync(join(dataDir, "dataservice", "data"))) {
|
|
193
|
+
writeFileSync(dataConfigPath, JSON.stringify(configContent, null, 2), "utf-8");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
198
|
+
return fail(`Failed to set up HMI runtime directory ${dataDir}: ${msg}`, []);
|
|
199
|
+
}
|
|
200
|
+
// 6. Kill any existing instance
|
|
201
|
+
const existing = getHmiForProject(state);
|
|
202
|
+
if (existing) {
|
|
203
|
+
killDataService(existing.pid);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
killDataService();
|
|
207
|
+
}
|
|
208
|
+
// 7. Spawn DataService detached
|
|
209
|
+
if (!existsSync(DATASERVICE_EXE)) {
|
|
210
|
+
return fail(`LasalVISUDataService.exe not found at standard path: ${DATASERVICE_EXE}`, [
|
|
211
|
+
"Make sure VISUDesigner is installed properly.",
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const child = spawn(DATASERVICE_EXE, [], {
|
|
216
|
+
cwd: dataDir,
|
|
217
|
+
detached: true,
|
|
218
|
+
stdio: "ignore",
|
|
219
|
+
windowsHide: true,
|
|
220
|
+
});
|
|
221
|
+
const pid = child.pid;
|
|
222
|
+
if (!pid) {
|
|
223
|
+
return fail("Failed to spawn LasalVISUDataService.exe process.", []);
|
|
224
|
+
}
|
|
225
|
+
child.unref();
|
|
226
|
+
// Poll HTTP health up to 10 seconds to detect startup and discover port
|
|
227
|
+
let healthy = false;
|
|
228
|
+
let port = 9980;
|
|
229
|
+
const startTime = Date.now();
|
|
230
|
+
while (Date.now() - startTime < 10000) {
|
|
231
|
+
const discoveredPort = getPortForPid(pid);
|
|
232
|
+
if (discoveredPort) {
|
|
233
|
+
port = discoveredPort;
|
|
234
|
+
}
|
|
235
|
+
const isHealthy = await checkHttpHealth(`http://127.0.0.1:${port}/`, 1000, true);
|
|
236
|
+
if (isHealthy) {
|
|
237
|
+
healthy = true;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
241
|
+
}
|
|
242
|
+
// The DataService only speaks WebSocket (9980) and a binary protocol (9981), and the
|
|
243
|
+
// webroot can't be opened via file:// (ES-module CORS) — serve it over local HTTP.
|
|
244
|
+
let url;
|
|
245
|
+
try {
|
|
246
|
+
const httpPort = await startStaticServer(dataDir);
|
|
247
|
+
url = `http://127.0.0.1:${httpPort}/index.html`;
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
251
|
+
warnings.push(`Failed to start static web server: ${msg}. Falling back to file:// URL.`);
|
|
252
|
+
url = `file:///${dataDir.replace(/\\/g, "/")}/index.html`;
|
|
253
|
+
}
|
|
254
|
+
setHmiForProject(state, { pid, port, url, dataDir });
|
|
255
|
+
writeState(state);
|
|
256
|
+
return respond({
|
|
257
|
+
ok: true,
|
|
258
|
+
success: true,
|
|
259
|
+
pid,
|
|
260
|
+
port,
|
|
261
|
+
url,
|
|
262
|
+
healthy,
|
|
263
|
+
...(warnings.length ? { warnings } : {}),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
268
|
+
return fail(`Failed to launch HMI runtime: ${msg}`, []);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export async function hmiRuntimeHandler(args) {
|
|
272
|
+
return withEngineLock(() => startHmiRuntime(args));
|
|
273
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { parseLcp, parseStClass, parseLcn } from "../utils/lasalXml.js";
|
|
4
|
+
import { resolveLcpPath } from "../utils/resolvePaths.js";
|
|
5
|
+
export const inspectProjectSchema = {
|
|
6
|
+
lcp_path: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe("Absolute path to the .lcp file. Omit to use the currently selected project."),
|
|
10
|
+
class_names: z
|
|
11
|
+
.array(z.string())
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Return full channel details only for these class names. Omit to get a summary of all classes (name + channel counts). " +
|
|
14
|
+
"Use this to drill into specific classes after the initial summary."),
|
|
15
|
+
include_networks: z
|
|
16
|
+
.boolean()
|
|
17
|
+
.optional()
|
|
18
|
+
.default(false)
|
|
19
|
+
.describe("Include network objects in the output. Default false to keep output small."),
|
|
20
|
+
include_connections: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.optional()
|
|
23
|
+
.default(false)
|
|
24
|
+
.describe("Include network connections in the output (requires include_networks). Default false."),
|
|
25
|
+
};
|
|
26
|
+
export async function inspectProjectHandler(args) {
|
|
27
|
+
const resolved = resolveLcpPath(args.lcp_path);
|
|
28
|
+
if ("error" in resolved) {
|
|
29
|
+
return { content: [{ type: "text", text: resolved.error }], isError: true };
|
|
30
|
+
}
|
|
31
|
+
let lcpInfo;
|
|
32
|
+
try {
|
|
33
|
+
lcpInfo = parseLcp(resolved.path);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: `Failed to parse .lcp: ${e.message}` }],
|
|
38
|
+
isError: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const filterNames = args.class_names ? new Set(args.class_names) : null;
|
|
42
|
+
// Parse class definitions
|
|
43
|
+
const classErrors = [];
|
|
44
|
+
const parsedClasses = [];
|
|
45
|
+
for (const cf of lcpInfo.classFiles) {
|
|
46
|
+
if (!cf.absPath.endsWith(".st") || !existsSync(cf.absPath))
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
const info = parseStClass(cf.absPath);
|
|
50
|
+
if (filterNames && !filterNames.has(info.name))
|
|
51
|
+
continue;
|
|
52
|
+
const hPath = cf.absPath.replace(/\.st$/, ".h");
|
|
53
|
+
parsedClasses.push({ ...info, stPath: cf.absPath, hPath });
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
classErrors.push(`${cf.relativePath}: ${e.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Build class output: summary (no filter) vs full detail (with filter)
|
|
60
|
+
let classesOutput;
|
|
61
|
+
if (filterNames) {
|
|
62
|
+
// Full detail for requested classes
|
|
63
|
+
classesOutput = parsedClasses.map((c) => ({
|
|
64
|
+
name: c.name,
|
|
65
|
+
revision: c.revision,
|
|
66
|
+
stPath: c.stPath,
|
|
67
|
+
hPath: c.hPath,
|
|
68
|
+
taskTypes: { cyclic: c.cyclicTask, realtime: c.realtimeTask, background: c.backgroundTask },
|
|
69
|
+
servers: c.servers.map((s) => ({
|
|
70
|
+
name: s.name,
|
|
71
|
+
visualized: s.visualized,
|
|
72
|
+
initialize: s.initialize,
|
|
73
|
+
defValue: s.defValue,
|
|
74
|
+
writeProtected: s.writeProtected,
|
|
75
|
+
retentive: s.retentive,
|
|
76
|
+
...(s.comment ? { comment: s.comment } : {}),
|
|
77
|
+
})),
|
|
78
|
+
clients: c.clients.map((cl) => ({
|
|
79
|
+
name: cl.name,
|
|
80
|
+
required: cl.required,
|
|
81
|
+
internal: cl.internal,
|
|
82
|
+
...(cl.comment ? { comment: cl.comment } : {}),
|
|
83
|
+
})),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Summary only — parse all but return just names + counts (avoids giant output)
|
|
88
|
+
const allClasses = [];
|
|
89
|
+
for (const cf of lcpInfo.classFiles) {
|
|
90
|
+
if (!cf.absPath.endsWith(".st") || !existsSync(cf.absPath))
|
|
91
|
+
continue;
|
|
92
|
+
try {
|
|
93
|
+
const info = parseStClass(cf.absPath);
|
|
94
|
+
allClasses.push({
|
|
95
|
+
name: info.name,
|
|
96
|
+
revision: info.revision,
|
|
97
|
+
servers: info.servers.length,
|
|
98
|
+
clients: info.clients.length,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* skip */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
classesOutput = allClasses;
|
|
106
|
+
}
|
|
107
|
+
const result = {
|
|
108
|
+
projectName: lcpInfo.projectName,
|
|
109
|
+
lcpPath: lcpInfo.lcpPath,
|
|
110
|
+
projectDir: lcpInfo.projectDir,
|
|
111
|
+
totalClasses: lcpInfo.classFiles.filter((f) => f.absPath.endsWith(".st")).length,
|
|
112
|
+
totalNetworks: lcpInfo.networkFiles.length,
|
|
113
|
+
...(filterNames ? { classDetail: classesOutput } : { classSummary: classesOutput }),
|
|
114
|
+
};
|
|
115
|
+
// Networks (optional)
|
|
116
|
+
if (args.include_networks) {
|
|
117
|
+
const networks = [];
|
|
118
|
+
const networkErrors = [];
|
|
119
|
+
for (const nf of lcpInfo.networkFiles) {
|
|
120
|
+
if (!existsSync(nf.absPath))
|
|
121
|
+
continue;
|
|
122
|
+
try {
|
|
123
|
+
networks.push(parseLcn(nf.absPath));
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
networkErrors.push(`${nf.relativePath}: ${e.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
result.networks = networks.map((n) => ({
|
|
130
|
+
name: n.name,
|
|
131
|
+
lcnPath: n.lcnPath,
|
|
132
|
+
objects: n.objects.map((o) => ({
|
|
133
|
+
name: o.name,
|
|
134
|
+
className: o.className,
|
|
135
|
+
...(Object.keys(o.channelValues).length ? { channelValues: o.channelValues } : {}),
|
|
136
|
+
})),
|
|
137
|
+
...(args.include_connections
|
|
138
|
+
? {
|
|
139
|
+
connections: n.connections.map((c) => ({
|
|
140
|
+
source: c.source,
|
|
141
|
+
destination: c.destination,
|
|
142
|
+
...(c.remote ? { remote: true, station: c.station } : {}),
|
|
143
|
+
})),
|
|
144
|
+
}
|
|
145
|
+
: {}),
|
|
146
|
+
}));
|
|
147
|
+
if (networkErrors.length)
|
|
148
|
+
result.networkParseErrors = networkErrors;
|
|
149
|
+
}
|
|
150
|
+
if (classErrors.length)
|
|
151
|
+
result.classParseErrors = classErrors;
|
|
152
|
+
const json = JSON.stringify(result, null, 2);
|
|
153
|
+
const MAX_RESPONSE_CHARS = 100_000;
|
|
154
|
+
if (json.length > MAX_RESPONSE_CHARS) {
|
|
155
|
+
result.truncationWarning = `Response truncated from ${json.length} to ${MAX_RESPONSE_CHARS} chars. Use class_names filter or disable include_networks/include_connections to narrow results.`;
|
|
156
|
+
const truncated = json.slice(0, MAX_RESPONSE_CHARS) + "\n... (truncated)";
|
|
157
|
+
return { content: [{ type: "text", text: truncated }] };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: json }],
|
|
161
|
+
};
|
|
162
|
+
}
|