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,172 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { existsSync, readFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { runScript, emitPy27String, emitPath } from "../utils/batchScript.js";
|
|
6
|
+
import { resolveLcpPath } from "../utils/resolvePaths.js";
|
|
7
|
+
import { withEngineLock, SCRATCH } from "../utils/engine.js";
|
|
8
|
+
import { XMLParser } from "fast-xml-parser";
|
|
9
|
+
function batchResultToResponse(br, extra) {
|
|
10
|
+
const body = {
|
|
11
|
+
ok: br.ok,
|
|
12
|
+
durationMs: br.durationMs,
|
|
13
|
+
...(br.errors.length ? { errors: br.errors } : {}),
|
|
14
|
+
...(br.warnings.length ? { warnings: br.warnings } : {}),
|
|
15
|
+
...(br.logTail.length ? { logTail: br.logTail } : {}),
|
|
16
|
+
logPath: br.logPath,
|
|
17
|
+
...extra,
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
21
|
+
...(br.ok ? {} : { isError: true }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export const plcDiagnosticsSchema = {
|
|
25
|
+
action: z
|
|
26
|
+
.enum(["trace", "file_upload", "file_download", "file_delete", "code_analysis"])
|
|
27
|
+
.describe("PLC diagnostics/maintenance action to perform."),
|
|
28
|
+
lcp_path: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Absolute path to the .lcp file. Omit to use the currently selected project."),
|
|
32
|
+
connection: z.string().optional().describe("PLC connection string. Omit to use connection from project's .lss file."),
|
|
33
|
+
config_path: z.string().optional().describe("Absolute path to the DataAnalyzer config file (trace action only)."),
|
|
34
|
+
duration_ms: z
|
|
35
|
+
.number()
|
|
36
|
+
.int()
|
|
37
|
+
.optional()
|
|
38
|
+
.default(5000)
|
|
39
|
+
.describe("Duration in milliseconds to run the trace (trace action only). Default 5000."),
|
|
40
|
+
output_path: z.string().optional().describe("Destination path for the trace output or code analysis result."),
|
|
41
|
+
plc_path: z.string().optional().describe("File path on the PLC (for file upload/download/delete)."),
|
|
42
|
+
local_path: z.string().optional().describe("Local file path on the host (for file upload/download)."),
|
|
43
|
+
};
|
|
44
|
+
export async function plcDiagnosticsHandler(args) {
|
|
45
|
+
const resolved = resolveLcpPath(args.lcp_path);
|
|
46
|
+
if ("error" in resolved) {
|
|
47
|
+
return { content: [{ type: "text", text: resolved.error }], isError: true };
|
|
48
|
+
}
|
|
49
|
+
const id = randomUUID();
|
|
50
|
+
if (!existsSync(SCRATCH))
|
|
51
|
+
mkdirSync(SCRATCH, { recursive: true });
|
|
52
|
+
const logPath = join(SCRATCH, `diag_${id}.log`);
|
|
53
|
+
const conn = args.connection ?? "";
|
|
54
|
+
return withEngineLock(async () => {
|
|
55
|
+
switch (args.action) {
|
|
56
|
+
case "trace": {
|
|
57
|
+
if (!args.config_path) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: "config_path is required for action 'trace'" }],
|
|
60
|
+
isError: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const outPath = args.output_path || join(SCRATCH, `trace_${id}.csv`);
|
|
64
|
+
const durationSec = (args.duration_ms ?? 5000) / 1000.0;
|
|
65
|
+
const script = [
|
|
66
|
+
"# -*- coding: utf-8 -*-",
|
|
67
|
+
"import sigmatek.lasal.batch as batch",
|
|
68
|
+
"import time",
|
|
69
|
+
`batch.OpenLogfile(${emitPath(logPath)})`,
|
|
70
|
+
`prj = batch.LoadProject(${emitPath(resolved.path)})`,
|
|
71
|
+
`batch.DataAnalyzerLoadConfig(${emitPy27String(args.config_path)})`,
|
|
72
|
+
`batch.DataAnalyzerRun(prj, ${emitPy27String(conn)})`,
|
|
73
|
+
`time.sleep(${durationSec})`,
|
|
74
|
+
"batch.DataAnalyzerStop()",
|
|
75
|
+
`batch.DataAnalyzerSaveData(${emitPy27String(outPath)})`,
|
|
76
|
+
"batch.CloseProject(prj)",
|
|
77
|
+
].join("\n") + "\n";
|
|
78
|
+
const br = await runScript(script, logPath);
|
|
79
|
+
return batchResultToResponse(br, { outputPath: outPath });
|
|
80
|
+
}
|
|
81
|
+
case "file_upload": {
|
|
82
|
+
if (!args.plc_path || !args.local_path) {
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: "plc_path and local_path are required for action 'file_upload'" }],
|
|
85
|
+
isError: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// Upload transfers file from PLC (src) to host (dest)
|
|
89
|
+
const script = [
|
|
90
|
+
"# -*- coding: utf-8 -*-",
|
|
91
|
+
"import sigmatek.lasal.batch as batch",
|
|
92
|
+
`batch.OpenLogfile(${emitPath(logPath)})`,
|
|
93
|
+
`prj = batch.LoadProject(${emitPath(resolved.path)})`,
|
|
94
|
+
`ok = batch.UploadFile(prj, ${emitPy27String(conn)}, ${emitPy27String(args.plc_path)}, ${emitPy27String(args.local_path)})`,
|
|
95
|
+
"batch.CloseProject(prj)",
|
|
96
|
+
`if not ok: raise RuntimeError("UploadFile failed")`,
|
|
97
|
+
].join("\n") + "\n";
|
|
98
|
+
const br = await runScript(script, logPath);
|
|
99
|
+
return batchResultToResponse(br);
|
|
100
|
+
}
|
|
101
|
+
case "file_download": {
|
|
102
|
+
if (!args.plc_path || !args.local_path) {
|
|
103
|
+
return {
|
|
104
|
+
content: [
|
|
105
|
+
{ type: "text", text: "plc_path and local_path are required for action 'file_download'" },
|
|
106
|
+
],
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// Download transfers file from host (src) to PLC (dest)
|
|
111
|
+
const script = [
|
|
112
|
+
"# -*- coding: utf-8 -*-",
|
|
113
|
+
"import sigmatek.lasal.batch as batch",
|
|
114
|
+
`batch.OpenLogfile(${emitPath(logPath)})`,
|
|
115
|
+
`prj = batch.LoadProject(${emitPath(resolved.path)})`,
|
|
116
|
+
`ok = batch.DownloadFile(prj, ${emitPy27String(conn)}, ${emitPy27String(args.local_path)}, ${emitPy27String(args.plc_path)})`,
|
|
117
|
+
"batch.CloseProject(prj)",
|
|
118
|
+
`if not ok: raise RuntimeError("DownloadFile failed")`,
|
|
119
|
+
].join("\n") + "\n";
|
|
120
|
+
const br = await runScript(script, logPath);
|
|
121
|
+
return batchResultToResponse(br);
|
|
122
|
+
}
|
|
123
|
+
case "file_delete": {
|
|
124
|
+
if (!args.plc_path) {
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: "plc_path is required for action 'file_delete'" }],
|
|
127
|
+
isError: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const script = [
|
|
131
|
+
"# -*- coding: utf-8 -*-",
|
|
132
|
+
"import sigmatek.lasal.batch as batch",
|
|
133
|
+
`batch.OpenLogfile(${emitPath(logPath)})`,
|
|
134
|
+
`prj = batch.LoadProject(${emitPath(resolved.path)})`,
|
|
135
|
+
`ok = batch.DeleteFileOnPLC(prj, ${emitPy27String(conn)}, ${emitPy27String(args.plc_path)})`,
|
|
136
|
+
"batch.CloseProject(prj)",
|
|
137
|
+
`if not ok: raise RuntimeError("DeleteFileOnPLC failed")`,
|
|
138
|
+
].join("\n") + "\n";
|
|
139
|
+
const br = await runScript(script, logPath);
|
|
140
|
+
return batchResultToResponse(br);
|
|
141
|
+
}
|
|
142
|
+
case "code_analysis": {
|
|
143
|
+
const outPath = args.output_path || join(SCRATCH, `analysis_${id}.xml`);
|
|
144
|
+
const script = [
|
|
145
|
+
"# -*- coding: utf-8 -*-",
|
|
146
|
+
"import sigmatek.lasal.batch as batch",
|
|
147
|
+
`batch.OpenLogfile(${emitPath(logPath)})`,
|
|
148
|
+
`prj = batch.LoadProject(${emitPath(resolved.path)})`,
|
|
149
|
+
`ok = batch.DoCodeAnalysisOnProjekt(prj, ${emitPy27String(outPath)})`,
|
|
150
|
+
"batch.CloseProject(prj)",
|
|
151
|
+
`if not ok: raise RuntimeError("DoCodeAnalysisOnProjekt failed")`,
|
|
152
|
+
].join("\n") + "\n";
|
|
153
|
+
const br = await runScript(script, logPath);
|
|
154
|
+
let summary = {};
|
|
155
|
+
if (br.ok && existsSync(outPath)) {
|
|
156
|
+
try {
|
|
157
|
+
const rawXml = readFileSync(outPath, "utf-8");
|
|
158
|
+
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "" });
|
|
159
|
+
const parsed = parser.parse(rawXml);
|
|
160
|
+
summary = parsed;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
summary = { error: `Failed to parse analysis XML: ${e.message}`, path: outPath };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return batchResultToResponse(br, { analysisResult: summary });
|
|
167
|
+
}
|
|
168
|
+
default:
|
|
169
|
+
return { content: [{ type: "text", text: `Unknown action: ${args.action}` }], isError: true };
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { resolveLcpPath } from "../utils/resolvePaths.js";
|
|
4
|
+
import { parseLcp, parseStClass } from "../utils/lasalXml.js";
|
|
5
|
+
import { isProcessRunning } from "../utils/engine.js";
|
|
6
|
+
import { respond, fail } from "../utils/respond.js";
|
|
7
|
+
export const classSourceSchema = {
|
|
8
|
+
action: z.enum(["read", "write"]).describe("'read' returns the source; 'write' overwrites it."),
|
|
9
|
+
class_name: z.string().describe("Name of the CLASS 2 class (e.g. 'Palletizer')."),
|
|
10
|
+
lcp_path: z.string().optional().describe("Absolute path to the .lcp file. Omit to use the selected project."),
|
|
11
|
+
include_header: z
|
|
12
|
+
.boolean()
|
|
13
|
+
.optional()
|
|
14
|
+
.default(false)
|
|
15
|
+
.describe("Also return the .h file contents (read only). Default false."),
|
|
16
|
+
source: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Full content to write to the .st file — must be latin1-compatible (write only)."),
|
|
20
|
+
header_source: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Content to write to the .h file. Omit to leave it unchanged (write only)."),
|
|
24
|
+
};
|
|
25
|
+
function resolveStPath(lcpPath, className) {
|
|
26
|
+
let lcpInfo;
|
|
27
|
+
try {
|
|
28
|
+
lcpInfo = parseLcp(lcpPath);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
return { error: `Failed to parse .lcp: ${e.message}` };
|
|
32
|
+
}
|
|
33
|
+
for (const cf of lcpInfo.classFiles) {
|
|
34
|
+
if (!cf.absPath.endsWith(".st") || !existsSync(cf.absPath))
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const info = parseStClass(cf.absPath);
|
|
38
|
+
if (info.name === className)
|
|
39
|
+
return { stPath: cf.absPath };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* skip */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const available = lcpInfo.classFiles
|
|
46
|
+
.filter((f) => f.absPath.endsWith(".st") && existsSync(f.absPath))
|
|
47
|
+
.map((f) => {
|
|
48
|
+
try {
|
|
49
|
+
return parseStClass(f.absPath).name;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.filter((x) => x !== null);
|
|
56
|
+
return { error: `Class "${className}" not found.\nAvailable classes: ${available.join(", ")}` };
|
|
57
|
+
}
|
|
58
|
+
function validateLatin1(s) {
|
|
59
|
+
const offending = [];
|
|
60
|
+
for (let i = 0; i < s.length; i++) {
|
|
61
|
+
const code = s.charCodeAt(i);
|
|
62
|
+
if (code > 0xff) {
|
|
63
|
+
offending.push({ char: s[i], code, index: i });
|
|
64
|
+
if (offending.length >= 10)
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
ok: offending.length === 0,
|
|
70
|
+
offending: offending.length > 0 ? offending : undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function classSourceHandler(args) {
|
|
74
|
+
const resolved = resolveLcpPath(args.lcp_path);
|
|
75
|
+
if ("error" in resolved) {
|
|
76
|
+
return fail(resolved.error, ["Select a project first using select_project or specify lcp_path."]);
|
|
77
|
+
}
|
|
78
|
+
const found = resolveStPath(resolved.path, args.class_name);
|
|
79
|
+
if ("error" in found) {
|
|
80
|
+
return fail(found.error, ["Make sure the class name is typed correctly."]);
|
|
81
|
+
}
|
|
82
|
+
const { stPath } = found;
|
|
83
|
+
if (args.action === "read") {
|
|
84
|
+
const result = {
|
|
85
|
+
ok: true,
|
|
86
|
+
className: args.class_name,
|
|
87
|
+
stPath,
|
|
88
|
+
source: readFileSync(stPath, "latin1"),
|
|
89
|
+
};
|
|
90
|
+
if (args.include_header) {
|
|
91
|
+
const hPath = stPath.replace(/\.st$/, ".h");
|
|
92
|
+
if (existsSync(hPath)) {
|
|
93
|
+
result.hPath = hPath;
|
|
94
|
+
result.headerSource = readFileSync(hPath, "latin1");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return respond(result);
|
|
98
|
+
}
|
|
99
|
+
// write
|
|
100
|
+
if (isProcessRunning("Lasal2.exe")) {
|
|
101
|
+
return fail("CLASS 2 IDE is open.", [
|
|
102
|
+
"Close the CLASS 2 IDE manually or run manage_class2 close before writing to Structured Text class files.",
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
if (!args.source) {
|
|
106
|
+
return fail("source is required for action 'write'", ["Provide the source parameter."]);
|
|
107
|
+
}
|
|
108
|
+
// Validate latin1
|
|
109
|
+
const validation = validateLatin1(args.source);
|
|
110
|
+
if (!validation.ok) {
|
|
111
|
+
const details = validation.offending.map((o) => `'${o.char}' (code: ${o.code}) at index ${o.index}`).join(", ");
|
|
112
|
+
return fail("Source contains non-latin1 characters.", [
|
|
113
|
+
"Make sure all characters in the source are representable in ISO-8859-1 (latin1). Offending characters: " +
|
|
114
|
+
details,
|
|
115
|
+
]);
|
|
116
|
+
}
|
|
117
|
+
if (args.header_source !== undefined) {
|
|
118
|
+
const hValidation = validateLatin1(args.header_source);
|
|
119
|
+
if (!hValidation.ok) {
|
|
120
|
+
const details = hValidation.offending.map((o) => `'${o.char}' (code: ${o.code}) at index ${o.index}`).join(", ");
|
|
121
|
+
return fail("Header source contains non-latin1 characters.", [
|
|
122
|
+
"Make sure all characters in the header source are representable in ISO-8859-1 (latin1). Offending characters: " +
|
|
123
|
+
details,
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const result = { ok: true, className: args.class_name, stPath };
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(stPath, args.source, "latin1");
|
|
130
|
+
result.stWritten = true;
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
return fail(`Failed to write .st file: ${e.message}`, []);
|
|
134
|
+
}
|
|
135
|
+
if (args.header_source !== undefined) {
|
|
136
|
+
const hPath = stPath.replace(/\.st$/, ".h");
|
|
137
|
+
try {
|
|
138
|
+
writeFileSync(hPath, args.header_source, "latin1");
|
|
139
|
+
result.hPath = hPath;
|
|
140
|
+
result.hWritten = true;
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
result.hError = `Failed to write .h file: ${e.message}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return respond(result);
|
|
147
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readState, writeState } from "../state.js";
|
|
4
|
+
import { findLsmPath, parseSolution } from "../utils/projectScanner.js";
|
|
5
|
+
export const selectProjectSchema = {
|
|
6
|
+
path: z.string().describe("Full path to the LASAL solution folder (must contain a .lsm file)."),
|
|
7
|
+
};
|
|
8
|
+
export async function selectProjectHandler(args) {
|
|
9
|
+
const lsmPath = findLsmPath(args.path);
|
|
10
|
+
if (!lsmPath || !existsSync(lsmPath)) {
|
|
11
|
+
return {
|
|
12
|
+
content: [
|
|
13
|
+
{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: `Error: no .lsm file found in ${args.path}\nMake sure the path points to a valid LASAL solution folder.`,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
isError: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const state = readState();
|
|
22
|
+
state.currentProject = args.path;
|
|
23
|
+
writeState(state);
|
|
24
|
+
// Discover stations so the agent immediately knows available paths
|
|
25
|
+
let summary = { selectedPath: args.path };
|
|
26
|
+
try {
|
|
27
|
+
const solution = parseSolution(lsmPath);
|
|
28
|
+
summary.stations = solution.stations.map((s) => ({
|
|
29
|
+
name: s.name,
|
|
30
|
+
ip: s.ip ?? null,
|
|
31
|
+
port: s.port ?? "1954",
|
|
32
|
+
ssltls: s.ssltls === "1",
|
|
33
|
+
lcpPaths: s.lcpPaths,
|
|
34
|
+
lvpPaths: s.lvpPaths,
|
|
35
|
+
}));
|
|
36
|
+
summary.hint =
|
|
37
|
+
solution.stations.length > 1
|
|
38
|
+
? "Multiple stations found. Pass lcp_path or lvp_path explicitly to tools that need them."
|
|
39
|
+
: "Single station found. Tools will auto-resolve lcp_path / lvp_path.";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
summary.hint = "Could not parse solution file for station discovery.";
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readState } from "../state.js";
|
|
4
|
+
import { findLsmPath, parseSolution, updateLssConnection } from "../utils/projectScanner.js";
|
|
5
|
+
export const setTargetIpSchema = {
|
|
6
|
+
station: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe("Station name to update (e.g. 'PLC', 'HMI'). Omit if the project has only one station."),
|
|
10
|
+
ip: z.string().optional().describe("New IP address (e.g. '192.168.1.100')."),
|
|
11
|
+
port: z.string().optional().describe("New port (default '1954'). Only change if non-standard."),
|
|
12
|
+
ssltls: z.boolean().optional().describe("Enable SSL/TLS for the connection. Omit to leave unchanged."),
|
|
13
|
+
};
|
|
14
|
+
export async function setTargetIpHandler(args) {
|
|
15
|
+
if (!args.ip && args.port === undefined && args.ssltls === undefined) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text", text: "Error: specify at least one of ip, port, or ssltls to update." }],
|
|
18
|
+
isError: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const state = readState();
|
|
22
|
+
if (!state.currentProject) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text: "No project selected. Call select_project first." }],
|
|
25
|
+
isError: true,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const lsmPath = findLsmPath(state.currentProject);
|
|
29
|
+
if (!lsmPath || !existsSync(lsmPath)) {
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text: `No .lsm file found in ${state.currentProject}` }],
|
|
32
|
+
isError: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
let solution;
|
|
36
|
+
try {
|
|
37
|
+
solution = parseSolution(lsmPath);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: `Failed to parse solution: ${e.message}` }],
|
|
42
|
+
isError: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (solution.stations.length === 0) {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text: "No stations found in solution." }],
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let targetStation = solution.stations[0];
|
|
52
|
+
if (!targetStation) {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: "No stations found in solution." }],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (args.station) {
|
|
59
|
+
const match = solution.stations.find((s) => s.name.toLowerCase() === args.station.toLowerCase());
|
|
60
|
+
if (!match) {
|
|
61
|
+
const names = solution.stations.map((s) => s.name).join(", ");
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text", text: `Station "${args.station}" not found. Available: ${names}` }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
targetStation = match;
|
|
68
|
+
}
|
|
69
|
+
else if (solution.stations.length > 1) {
|
|
70
|
+
const names = solution.stations.map((s) => `${s.name} (${s.ip ?? "no IP"})`).join(", ");
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: "text",
|
|
75
|
+
text: `Multiple stations found — specify 'station':\n${names}`,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
isError: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
updateLssConnection(targetStation.lssPath, {
|
|
83
|
+
ip: args.ip,
|
|
84
|
+
port: args.port,
|
|
85
|
+
ssltls: args.ssltls !== undefined ? (args.ssltls ? "1" : "0") : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: "text", text: `Failed to update .lss: ${e.message}` }],
|
|
91
|
+
isError: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const updates = [];
|
|
95
|
+
if (args.ip)
|
|
96
|
+
updates.push(`IP → ${args.ip}`);
|
|
97
|
+
if (args.port)
|
|
98
|
+
updates.push(`PORT → ${args.port}`);
|
|
99
|
+
if (args.ssltls !== undefined)
|
|
100
|
+
updates.push(`SSLTLS → ${args.ssltls ? "1" : "0"}`);
|
|
101
|
+
return {
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: JSON.stringify({
|
|
106
|
+
ok: true,
|
|
107
|
+
station: targetStation.name,
|
|
108
|
+
lssPath: targetStation.lssPath,
|
|
109
|
+
updated: updates,
|
|
110
|
+
}, null, 2),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { readState, getHmiForProject } from "../state.js";
|
|
3
|
+
import { findLsmPath, parseSolution, findLcpFiles, findLvpFiles } from "../utils/projectScanner.js";
|
|
4
|
+
import { CLASS2_EXE, VISUDESIGNER_EXE, resolveDataServiceExe, isProcessRunning, getProcessPid, } from "../utils/engine.js";
|
|
5
|
+
import { pingHost } from "../utils/preflight.js";
|
|
6
|
+
import { LARS_EXE, readLarsWorkspaces, getLarsPids, isLarsHealthy, larsConfigPath } from "../utils/lars.js";
|
|
7
|
+
import { respond } from "../utils/respond.js";
|
|
8
|
+
import { checkHttpHealth } from "../core/http.js";
|
|
9
|
+
export const lasalStatusSchema = {};
|
|
10
|
+
export async function lasalStatusHandler() {
|
|
11
|
+
const state = readState();
|
|
12
|
+
const projDir = state.currentProject;
|
|
13
|
+
const projectInfo = {
|
|
14
|
+
selected: projDir,
|
|
15
|
+
lcpPaths: projDir ? findLcpFiles(projDir) : [],
|
|
16
|
+
lvpPaths: projDir ? findLvpFiles(projDir) : [],
|
|
17
|
+
};
|
|
18
|
+
const stations = [];
|
|
19
|
+
if (projDir) {
|
|
20
|
+
const lsmPath = findLsmPath(projDir);
|
|
21
|
+
if (lsmPath) {
|
|
22
|
+
try {
|
|
23
|
+
const soln = parseSolution(lsmPath);
|
|
24
|
+
for (const stn of soln.stations) {
|
|
25
|
+
const ip = stn.ip ?? "";
|
|
26
|
+
let reachable = false;
|
|
27
|
+
if (ip) {
|
|
28
|
+
reachable = await pingHost(ip, parseInt(stn.port ?? "1954", 10) || 1954, 1000);
|
|
29
|
+
}
|
|
30
|
+
stations.push({
|
|
31
|
+
name: stn.name,
|
|
32
|
+
ip: stn.ip,
|
|
33
|
+
port: stn.port ?? "1954",
|
|
34
|
+
reachable,
|
|
35
|
+
lcp: stn.lcpPaths[0] ?? null,
|
|
36
|
+
lvp: stn.lvpPaths[0] ?? null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const dsResult = resolveDataServiceExe();
|
|
44
|
+
const engines = {
|
|
45
|
+
class2: { path: CLASS2_EXE, exists: existsSync(CLASS2_EXE) },
|
|
46
|
+
visuDesigner: { path: VISUDESIGNER_EXE, exists: existsSync(VISUDESIGNER_EXE) },
|
|
47
|
+
dataService: {
|
|
48
|
+
path: dsResult.path,
|
|
49
|
+
exists: dsResult.path ? existsSync(dsResult.path) : false,
|
|
50
|
+
resolvedVia: process.env.LASAL_DATASERVICE_EXE ? "env" : "glob",
|
|
51
|
+
...(dsResult.path === "" ? { searched: dsResult.searched } : {}),
|
|
52
|
+
},
|
|
53
|
+
lars: {
|
|
54
|
+
path: LARS_EXE,
|
|
55
|
+
exists: existsSync(LARS_EXE),
|
|
56
|
+
configPath: larsConfigPath(),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const processes = {
|
|
60
|
+
class2Running: isProcessRunning("Lasal2.exe"),
|
|
61
|
+
visuDesignerRunning: isProcessRunning("VISUDesigner.exe"),
|
|
62
|
+
dataServicePid: getProcessPid("LasalVISUDataService.exe"),
|
|
63
|
+
};
|
|
64
|
+
// Check HMI runtime health (per project)
|
|
65
|
+
const hmiRuntimeInfo = await (async () => {
|
|
66
|
+
const running = getHmiForProject(state, projDir ?? undefined);
|
|
67
|
+
if (running) {
|
|
68
|
+
const pid = running.pid;
|
|
69
|
+
const port = running.port;
|
|
70
|
+
const url = running.url;
|
|
71
|
+
const isRunning = processes.dataServicePid === pid;
|
|
72
|
+
let healthy = false;
|
|
73
|
+
if (isRunning && port) {
|
|
74
|
+
healthy = await checkHttpHealth(`http://127.0.0.1:${port}/`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
running: isRunning,
|
|
78
|
+
pid,
|
|
79
|
+
port,
|
|
80
|
+
url,
|
|
81
|
+
healthy,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (processes.dataServicePid) {
|
|
85
|
+
// Found untracked DataService running
|
|
86
|
+
return {
|
|
87
|
+
running: true,
|
|
88
|
+
pid: processes.dataServicePid,
|
|
89
|
+
healthy: await checkHttpHealth(`http://127.0.0.1:9980/`), // Try standard port
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { running: false };
|
|
93
|
+
})();
|
|
94
|
+
// LARS instances (from lasalos2.xml workspace config)
|
|
95
|
+
const larsWorkspaces = readLarsWorkspaces();
|
|
96
|
+
const larsInstances = state.larsInstances ?? {};
|
|
97
|
+
const lars = {
|
|
98
|
+
configured: await Promise.all(larsWorkspaces.map(async (w) => {
|
|
99
|
+
const inst = larsInstances[w.name];
|
|
100
|
+
const pids = getLarsPids(w.name);
|
|
101
|
+
const running = pids.length > 0;
|
|
102
|
+
return {
|
|
103
|
+
name: w.name,
|
|
104
|
+
onlinePort: w.onlinePort,
|
|
105
|
+
running,
|
|
106
|
+
pid: pids[0] ?? inst?.pid ?? null,
|
|
107
|
+
healthy: running ? await isLarsHealthy(w.onlinePort) : false,
|
|
108
|
+
stationName: inst?.stationName ?? null,
|
|
109
|
+
lcpPath: inst?.lcpPath ?? null,
|
|
110
|
+
targetedAtLars: inst?.originalIp ? true : false,
|
|
111
|
+
};
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
const hints = [];
|
|
115
|
+
if (!projDir) {
|
|
116
|
+
hints.push("No project is currently selected. Use select_project with the path to your project folder first.");
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (stations.length === 0) {
|
|
120
|
+
hints.push("No stations found. Check if the project is structured correctly with an .lsm file.");
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const unreachable = stations.filter((s) => !s.reachable);
|
|
124
|
+
if (unreachable.length > 0) {
|
|
125
|
+
const larsHints = lars.configured.filter((l) => l.running && l.stationName).map((l) => l.stationName);
|
|
126
|
+
hints.push(`Some stations are unreachable (${unreachable.map((u) => u.name).join(", ")}). ` +
|
|
127
|
+
(larsHints.length
|
|
128
|
+
? `LARS is running for: ${larsHints.join(", ")} — use lars_runtime set_station_target to point those stations at LARS.`
|
|
129
|
+
: "No real PLC/HMI on the network? Use lars_runtime setup + start to simulate stations locally."));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (processes.class2Running) {
|
|
133
|
+
hints.push("CLASS 2 IDE is open. Close it manually or call manage_class2 close before running batch operations (compile/download).");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return respond({
|
|
137
|
+
ok: true,
|
|
138
|
+
project: projectInfo,
|
|
139
|
+
stations,
|
|
140
|
+
engines,
|
|
141
|
+
processes,
|
|
142
|
+
hmiRuntime: hmiRuntimeInfo,
|
|
143
|
+
lars,
|
|
144
|
+
hints,
|
|
145
|
+
});
|
|
146
|
+
}
|