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,194 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { XMLParser } from "fast-xml-parser";
|
|
4
|
+
import * as net from "net";
|
|
5
|
+
// Helper to find .lss file from .lcp path
|
|
6
|
+
export function findLssPath(lcpPath) {
|
|
7
|
+
const lcpDir = dirname(lcpPath);
|
|
8
|
+
try {
|
|
9
|
+
const files = readdirSync(lcpDir);
|
|
10
|
+
for (const f of files) {
|
|
11
|
+
if (f.endsWith(".lss"))
|
|
12
|
+
return join(lcpDir, f);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
const parentDir = dirname(lcpDir);
|
|
17
|
+
try {
|
|
18
|
+
const parentFiles = readdirSync(parentDir);
|
|
19
|
+
for (const f of parentFiles) {
|
|
20
|
+
if (f.endsWith(".lss"))
|
|
21
|
+
return join(parentDir, f);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/** True when the connection targets a local LARS (or other local) runtime. */
|
|
28
|
+
export function isLoopbackTarget(conn) {
|
|
29
|
+
const ip = conn.ip;
|
|
30
|
+
if (!ip)
|
|
31
|
+
return false;
|
|
32
|
+
return ip === "127.0.0.1" || ip === "localhost" || ip.startsWith("127.");
|
|
33
|
+
}
|
|
34
|
+
/** Extract the IP and optional port from a connection string like 'TCPIP:10.0.0.5:1964'. */
|
|
35
|
+
export function parseConnectionTarget(conn) {
|
|
36
|
+
let target = conn;
|
|
37
|
+
const m = conn.match(/TCPIP:(.+)/i);
|
|
38
|
+
if (m)
|
|
39
|
+
target = m[1];
|
|
40
|
+
if (target.includes(":")) {
|
|
41
|
+
const [ipPart, portPart] = target.split(":");
|
|
42
|
+
if (ipPart && portPart) {
|
|
43
|
+
const port = parseInt(portPart, 10);
|
|
44
|
+
if (!isNaN(port) && port > 0 && port <= 65535) {
|
|
45
|
+
return { ip: ipPart, port };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { ip: target.split(":")[0] };
|
|
49
|
+
}
|
|
50
|
+
if (target.includes("."))
|
|
51
|
+
return { ip: target };
|
|
52
|
+
return { ip: target };
|
|
53
|
+
}
|
|
54
|
+
export function resolveConnection(lcpPath, explicit) {
|
|
55
|
+
if (explicit) {
|
|
56
|
+
const { ip, port } = parseConnectionTarget(explicit);
|
|
57
|
+
return { connection: explicit, ip, port, source: "explicit" };
|
|
58
|
+
}
|
|
59
|
+
const lssPath = findLssPath(lcpPath);
|
|
60
|
+
if (!lssPath) {
|
|
61
|
+
return { connection: "", source: "lss", warning: `No .lss file found near ${lcpPath}` };
|
|
62
|
+
}
|
|
63
|
+
if (!existsSync(lssPath)) {
|
|
64
|
+
return { connection: "", source: "lss", warning: `LSS file not found at ${lssPath}` };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(lssPath, "latin1");
|
|
68
|
+
const xmlParser = new XMLParser({ ignoreAttributes: false, parseAttributeValue: false });
|
|
69
|
+
const doc = xmlParser.parse(raw);
|
|
70
|
+
const tcpip = doc.SlnStation?.OnlineConnectionInfo?.TCPIP;
|
|
71
|
+
if (tcpip) {
|
|
72
|
+
const ip = tcpip["@_IP"];
|
|
73
|
+
const port = tcpip["@_PORT"] ?? "1954";
|
|
74
|
+
const portNum = parseInt(port, 10);
|
|
75
|
+
return {
|
|
76
|
+
connection: `TCPIP:${ip}${port && port !== "1954" ? `:${port}` : ""}`,
|
|
77
|
+
ip,
|
|
78
|
+
port: !isNaN(portNum) ? portNum : undefined,
|
|
79
|
+
source: "lss",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { connection: "", source: "lss", warning: `LSS file ${lssPath} has no <TCPIP> element` };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
86
|
+
return { connection: "", source: "lss", warning: `Failed to parse .lss at ${lssPath}: ${msg}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function pingHost(ip, port = 1954, timeoutMs = 1000) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const socket = new net.Socket();
|
|
92
|
+
let resolved = false;
|
|
93
|
+
socket.setTimeout(timeoutMs);
|
|
94
|
+
socket.on("connect", () => {
|
|
95
|
+
if (!resolved) {
|
|
96
|
+
resolved = true;
|
|
97
|
+
socket.destroy();
|
|
98
|
+
resolve(true);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const onError = () => {
|
|
102
|
+
if (!resolved) {
|
|
103
|
+
resolved = true;
|
|
104
|
+
socket.destroy();
|
|
105
|
+
resolve(false);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
socket.on("error", onError);
|
|
109
|
+
socket.on("timeout", onError);
|
|
110
|
+
socket.connect(port, ip);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export async function preflightPlc(lcpPath, explicitConn) {
|
|
114
|
+
const problems = [];
|
|
115
|
+
if (!existsSync(lcpPath)) {
|
|
116
|
+
problems.push({
|
|
117
|
+
code: "LCP_NOT_FOUND",
|
|
118
|
+
message: `Project LCP file does not exist at ${lcpPath}`,
|
|
119
|
+
fix: "Select a valid project using select_project or specify a correct lcp_path.",
|
|
120
|
+
});
|
|
121
|
+
return { ok: false, problems, connection: "" };
|
|
122
|
+
}
|
|
123
|
+
const connInfo = resolveConnection(lcpPath, explicitConn);
|
|
124
|
+
if (!connInfo.ip) {
|
|
125
|
+
problems.push({
|
|
126
|
+
code: "NO_IP_RESOLVED",
|
|
127
|
+
message: "Could not resolve an IP address for the connection.",
|
|
128
|
+
fix: "Provide an explicit connection string (e.g. TCPIP:10.195.0.50) or set the target IP using set_target_ip.",
|
|
129
|
+
});
|
|
130
|
+
return { ok: false, problems, connection: connInfo.connection };
|
|
131
|
+
}
|
|
132
|
+
const port = connInfo.port ?? 1954;
|
|
133
|
+
const reachable = await pingHost(connInfo.ip, port, 2000);
|
|
134
|
+
if (!reachable) {
|
|
135
|
+
problems.push({
|
|
136
|
+
code: "HOST_UNREACHABLE",
|
|
137
|
+
message: `PLC host at ${connInfo.ip} is unreachable on port ${port}.`,
|
|
138
|
+
fix: "Ensure the PLC is powered on and connected to the network. Verify the IP using lasal_status or set the correct IP.",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ok: problems.length === 0,
|
|
143
|
+
problems,
|
|
144
|
+
connection: connInfo.connection,
|
|
145
|
+
ip: connInfo.ip,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export async function preflightHmi(lvpPath, explicitConn) {
|
|
149
|
+
const problems = [];
|
|
150
|
+
if (!existsSync(lvpPath)) {
|
|
151
|
+
problems.push({
|
|
152
|
+
code: "LVP_NOT_FOUND",
|
|
153
|
+
message: `VISUDesigner LVP file does not exist at ${lvpPath}`,
|
|
154
|
+
fix: "Verify that the VISUDesigner project path is correct.",
|
|
155
|
+
});
|
|
156
|
+
return { ok: false, problems, connection: "" };
|
|
157
|
+
}
|
|
158
|
+
if (!explicitConn) {
|
|
159
|
+
problems.push({
|
|
160
|
+
code: "NO_CONN_SPECIFIED",
|
|
161
|
+
message: "No connection string specified for HMI download.",
|
|
162
|
+
fix: "Specify a visu_connection parameter.",
|
|
163
|
+
});
|
|
164
|
+
return { ok: false, problems, connection: "" };
|
|
165
|
+
}
|
|
166
|
+
let ip;
|
|
167
|
+
let port = 1954;
|
|
168
|
+
const { ip: parsedIp, port: parsedPort } = parseConnectionTarget(explicitConn);
|
|
169
|
+
ip = parsedIp;
|
|
170
|
+
if (parsedPort)
|
|
171
|
+
port = parsedPort;
|
|
172
|
+
if (!ip) {
|
|
173
|
+
problems.push({
|
|
174
|
+
code: "INVALID_HMI_CONN",
|
|
175
|
+
message: `Invalid HMI connection string: ${explicitConn}`,
|
|
176
|
+
fix: "Provide a valid HMI connection string, e.g. 'TCPIP:10.195.0.51'.",
|
|
177
|
+
});
|
|
178
|
+
return { ok: false, problems, connection: explicitConn };
|
|
179
|
+
}
|
|
180
|
+
const reachable = await pingHost(ip, port, 2000);
|
|
181
|
+
if (!reachable) {
|
|
182
|
+
problems.push({
|
|
183
|
+
code: "HMI_UNREACHABLE",
|
|
184
|
+
message: `HMI host at ${ip} is unreachable on port ${port}.`,
|
|
185
|
+
fix: "Ensure the HMI is powered on and connected to the network. Verify the IP using lasal_status.",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
ok: problems.length === 0,
|
|
190
|
+
problems,
|
|
191
|
+
connection: explicitConn,
|
|
192
|
+
ip,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync, readFileSync } from "fs";
|
|
2
|
+
import { join, dirname, resolve, basename } from "path";
|
|
3
|
+
import { XMLParser } from "fast-xml-parser";
|
|
4
|
+
const parser = new XMLParser({
|
|
5
|
+
ignoreAttributes: false,
|
|
6
|
+
attributeNamePrefix: "@_",
|
|
7
|
+
parseAttributeValue: false,
|
|
8
|
+
isArray: (name) => name === "File" || name === "SlnClassProject" || name === "SlnVISUDesignerProject",
|
|
9
|
+
});
|
|
10
|
+
function readLatin1(path) {
|
|
11
|
+
return readFileSync(path, "latin1");
|
|
12
|
+
}
|
|
13
|
+
/** Parse an .lsm file and all referenced .lss files to build a solution map. */
|
|
14
|
+
export function parseSolution(lsmPath) {
|
|
15
|
+
const lsmDir = dirname(lsmPath);
|
|
16
|
+
const lsmContent = readLatin1(lsmPath);
|
|
17
|
+
const doc = parser.parse(lsmContent);
|
|
18
|
+
const solution = doc.Solution ?? {};
|
|
19
|
+
const stationFiles = solution.SlnStationFiles?.File ?? [];
|
|
20
|
+
const stations = [];
|
|
21
|
+
for (const sf of stationFiles) {
|
|
22
|
+
if (!sf["@_Path"])
|
|
23
|
+
continue;
|
|
24
|
+
const lssPath = resolve(lsmDir, sf["@_Path"].replace(/\\/g, "/"));
|
|
25
|
+
if (!existsSync(lssPath))
|
|
26
|
+
continue;
|
|
27
|
+
const station = parseLss(lssPath);
|
|
28
|
+
stations.push(station);
|
|
29
|
+
}
|
|
30
|
+
return { lsmPath, stations };
|
|
31
|
+
}
|
|
32
|
+
/** Parse a .lss file to extract connection info and project paths. */
|
|
33
|
+
export function parseLss(lssPath) {
|
|
34
|
+
const lssDir = dirname(lssPath);
|
|
35
|
+
const content = readLatin1(lssPath);
|
|
36
|
+
const doc = parser.parse(content);
|
|
37
|
+
const stn = doc.SlnStation ?? {};
|
|
38
|
+
const tcpip = stn.OnlineConnectionInfo?.TCPIP ?? {};
|
|
39
|
+
const lcpPaths = [];
|
|
40
|
+
const lvpPaths = [];
|
|
41
|
+
const projects = stn.SlnProjects ?? {};
|
|
42
|
+
for (const cp of (projects.SlnClassProject ?? [])) {
|
|
43
|
+
if (cp["@_File"]) {
|
|
44
|
+
const abs = resolve(lssDir, cp["@_File"].replace(/\\/g, "/"));
|
|
45
|
+
if (existsSync(abs))
|
|
46
|
+
lcpPaths.push(abs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const vp of (projects.SlnVISUDesignerProject ?? [])) {
|
|
50
|
+
if (vp["@_File"]) {
|
|
51
|
+
const abs = resolve(lssDir, vp["@_File"].replace(/\\/g, "/"));
|
|
52
|
+
if (existsSync(abs))
|
|
53
|
+
lvpPaths.push(abs);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
name: stn["@_Name"] ?? basename(lssPath, ".lss"),
|
|
58
|
+
lssPath,
|
|
59
|
+
ip: tcpip["@_IP"],
|
|
60
|
+
port: tcpip["@_PORT"],
|
|
61
|
+
ssltls: tcpip["@_SSLTLS"],
|
|
62
|
+
lcpPaths,
|
|
63
|
+
lvpPaths,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Locate the .lsm file for a solution directory. */
|
|
67
|
+
export function findLsmPath(solutionDir) {
|
|
68
|
+
const name = basename(solutionDir);
|
|
69
|
+
const direct = join(solutionDir, `${name}.lsm`);
|
|
70
|
+
if (existsSync(direct))
|
|
71
|
+
return direct;
|
|
72
|
+
// Fall back: search for any .lsm in the directory
|
|
73
|
+
try {
|
|
74
|
+
for (const f of readdirSync(solutionDir)) {
|
|
75
|
+
if (f.endsWith(".lsm"))
|
|
76
|
+
return join(solutionDir, f);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** Resolve all .lcp files in a solution directory (via .lsm + .lss), or fall back to filesystem scan. */
|
|
85
|
+
export function findLcpFiles(solutionDir) {
|
|
86
|
+
const lsmPath = findLsmPath(solutionDir);
|
|
87
|
+
if (lsmPath) {
|
|
88
|
+
const solution = parseSolution(lsmPath);
|
|
89
|
+
const paths = solution.stations.flatMap((s) => s.lcpPaths);
|
|
90
|
+
if (paths.length > 0)
|
|
91
|
+
return paths;
|
|
92
|
+
}
|
|
93
|
+
// Fallback: filesystem search under Stations/
|
|
94
|
+
return findFilesDeep(join(solutionDir, "Stations"), ".lcp");
|
|
95
|
+
}
|
|
96
|
+
/** Resolve all .lvp files in a solution directory (via .lsm + .lss), or fall back to filesystem scan. */
|
|
97
|
+
export function findLvpFiles(solutionDir) {
|
|
98
|
+
const lsmPath = findLsmPath(solutionDir);
|
|
99
|
+
if (lsmPath) {
|
|
100
|
+
const solution = parseSolution(lsmPath);
|
|
101
|
+
const paths = solution.stations.flatMap((s) => s.lvpPaths);
|
|
102
|
+
if (paths.length > 0)
|
|
103
|
+
return paths;
|
|
104
|
+
}
|
|
105
|
+
return findFilesDeep(join(solutionDir, "Stations"), ".lvp");
|
|
106
|
+
}
|
|
107
|
+
function findFilesDeep(dir, ext) {
|
|
108
|
+
const results = [];
|
|
109
|
+
if (!existsSync(dir))
|
|
110
|
+
return results;
|
|
111
|
+
function walk(d) {
|
|
112
|
+
for (const entry of readdirSync(d)) {
|
|
113
|
+
const full = join(d, entry);
|
|
114
|
+
try {
|
|
115
|
+
if (statSync(full).isDirectory())
|
|
116
|
+
walk(full);
|
|
117
|
+
else if (entry.endsWith(ext))
|
|
118
|
+
results.push(full);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* skip inaccessible */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
walk(dir);
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Read the VISUDesigner station manager mapping (name -> stationId) from a .lvp's
|
|
130
|
+
* Stations/Stations.json. Returns e.g. { PLC: 10, HMI: 255, Local: 0 }.
|
|
131
|
+
*/
|
|
132
|
+
export function readVisuStationIds(lvpPath) {
|
|
133
|
+
const result = {};
|
|
134
|
+
try {
|
|
135
|
+
const lvpDir = dirname(lvpPath);
|
|
136
|
+
const file = join(lvpDir, "Stations", "Stations.json");
|
|
137
|
+
if (!existsSync(file))
|
|
138
|
+
return result;
|
|
139
|
+
const doc = JSON.parse(readFileSync(file, "utf-8"));
|
|
140
|
+
if (!Array.isArray(doc?.stations))
|
|
141
|
+
return result;
|
|
142
|
+
for (const st of doc.stations) {
|
|
143
|
+
if (!st || typeof st !== "object")
|
|
144
|
+
continue;
|
|
145
|
+
const stRec = st;
|
|
146
|
+
const name = typeof stRec.name === "string" ? stRec.name : undefined;
|
|
147
|
+
const id = typeof stRec.stationId === "number"
|
|
148
|
+
? stRec.stationId
|
|
149
|
+
: typeof stRec.stationId === "string"
|
|
150
|
+
? parseInt(stRec.stationId, 10)
|
|
151
|
+
: NaN;
|
|
152
|
+
if (name && !isNaN(id))
|
|
153
|
+
result[name] = id;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch { }
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
/** Read the current TCPIP connection from a .lss file (IP + port). */
|
|
160
|
+
export function readLssConnection(lssPath) {
|
|
161
|
+
if (!existsSync(lssPath))
|
|
162
|
+
return { error: `LSS file not found: ${lssPath}` };
|
|
163
|
+
try {
|
|
164
|
+
const content = readLatin1(lssPath);
|
|
165
|
+
const m = content.match(/<TCPIP\s[^>]*?IP="([^"]*)"[^>]*?PORT="([^"]*)"/);
|
|
166
|
+
if (m?.[1] && m?.[2])
|
|
167
|
+
return { ip: m[1], port: m[2] };
|
|
168
|
+
const m2 = content.match(/<TCPIP\s[^>]*?IP="([^"]*)"/);
|
|
169
|
+
if (m2?.[1])
|
|
170
|
+
return { ip: m2[1], port: "1954" };
|
|
171
|
+
return { error: `No <TCPIP .../> element found in ${lssPath}` };
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
175
|
+
return { error: `Failed to parse ${lssPath}: ${msg}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Surgically update the TCPIP IP (and optionally port/ssltls) in a .lss file, byte-preserving everything else. */
|
|
179
|
+
export function updateLssConnection(lssPath, updates) {
|
|
180
|
+
let content = readLatin1(lssPath);
|
|
181
|
+
const tcpipRe = /(<TCPIP\s[^>]*?>)/s;
|
|
182
|
+
const m = tcpipRe.exec(content);
|
|
183
|
+
if (!m) {
|
|
184
|
+
// No <TCPIP> element yet (e.g. a Local/simulation station) — insert one
|
|
185
|
+
// right after the <SlnStation ...> opening tag.
|
|
186
|
+
const stationRe = /(<SlnStation\s[^>]*>)/s;
|
|
187
|
+
const sm = stationRe.exec(content);
|
|
188
|
+
if (!sm)
|
|
189
|
+
throw new Error(`No <SlnStation ...> element found in ${lssPath}`);
|
|
190
|
+
const tag = `<OnlineConnectionInfo>\n\t\t<TCPIP ConfigName="MCP" BUS="3" Password="" IP="${updates.ip ?? "127.0.0.1"}" PORT="${updates.port ?? "1954"}" SomeFlags="0" PLCID="" Repeater="0" SSLTLS="${updates.ssltls ?? "0"}" Favorite="0"/>\n\t</OnlineConnectionInfo>`;
|
|
191
|
+
content = content.slice(0, sm.index + sm[1].length) + "\n\t" + tag + content.slice(sm.index + sm[1].length);
|
|
192
|
+
writeFileSync(lssPath, content, "latin1");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
let tag = m[1];
|
|
196
|
+
if (updates.ip !== undefined)
|
|
197
|
+
tag = setAttr(tag, "IP", updates.ip);
|
|
198
|
+
if (updates.port !== undefined)
|
|
199
|
+
tag = setAttr(tag, "PORT", updates.port);
|
|
200
|
+
if (updates.ssltls !== undefined)
|
|
201
|
+
tag = setAttr(tag, "SSLTLS", updates.ssltls);
|
|
202
|
+
content = content.slice(0, m.index) + tag + content.slice(m.index + m[1].length);
|
|
203
|
+
writeFileSync(lssPath, content, "latin1");
|
|
204
|
+
}
|
|
205
|
+
function setAttr(tag, name, value) {
|
|
206
|
+
const re = new RegExp(`(\\b${name}\\s*=\\s*")[^"]*(")`);
|
|
207
|
+
if (re.test(tag))
|
|
208
|
+
return tag.replace(re, `$1${value}$2`);
|
|
209
|
+
// Attribute not present — insert before the closing >
|
|
210
|
+
return tag.replace(/(\/?>)$/, ` ${name}="${value}"$1`);
|
|
211
|
+
}
|
|
212
|
+
import { writeFileSync } from "fs";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { join, basename } from "path";
|
|
3
|
+
import { readState } from "../state.js";
|
|
4
|
+
import { findLcpFiles, findLvpFiles } from "./projectScanner.js";
|
|
5
|
+
export function resolveLcpPath(lcpPath) {
|
|
6
|
+
if (lcpPath) {
|
|
7
|
+
if (!existsSync(lcpPath))
|
|
8
|
+
return { error: `File not found: ${lcpPath}` };
|
|
9
|
+
return { path: lcpPath };
|
|
10
|
+
}
|
|
11
|
+
const state = readState();
|
|
12
|
+
if (!state.currentProject)
|
|
13
|
+
return { error: "No project selected. Call select_project first." };
|
|
14
|
+
// Legacy flat layout: {dir}/{name}.lcp
|
|
15
|
+
const name = basename(state.currentProject);
|
|
16
|
+
const direct = join(state.currentProject, `${name}.lcp`);
|
|
17
|
+
if (existsSync(direct))
|
|
18
|
+
return { path: direct };
|
|
19
|
+
// Multi-station layout: search via .lsm → .lss
|
|
20
|
+
const found = findLcpFiles(state.currentProject);
|
|
21
|
+
if (found.length === 0)
|
|
22
|
+
return { error: `No .lcp files found in ${state.currentProject}` };
|
|
23
|
+
if (found.length === 1)
|
|
24
|
+
return { path: found[0] };
|
|
25
|
+
return {
|
|
26
|
+
error: `Multiple .lcp stations found — specify lcp_path:\n` + found.map((f) => ` ${f}`).join("\n"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function resolveLvpPath(lvpPath) {
|
|
30
|
+
if (lvpPath) {
|
|
31
|
+
if (!existsSync(lvpPath))
|
|
32
|
+
return { error: `File not found: ${lvpPath}` };
|
|
33
|
+
return { path: lvpPath };
|
|
34
|
+
}
|
|
35
|
+
const state = readState();
|
|
36
|
+
if (!state.currentProject)
|
|
37
|
+
return { error: "No project selected. Call select_project first." };
|
|
38
|
+
const found = findLvpFiles(state.currentProject);
|
|
39
|
+
if (found.length === 0)
|
|
40
|
+
return { error: `No .lvp files found in ${state.currentProject}` };
|
|
41
|
+
if (found.length === 1)
|
|
42
|
+
return { path: found[0] };
|
|
43
|
+
return {
|
|
44
|
+
error: `Multiple .lvp stations found — specify lvp_path:\n` + found.map((f) => ` ${f}`).join("\n"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function respond(body) {
|
|
2
|
+
return {
|
|
3
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
4
|
+
...(body.ok ? {} : { isError: true }),
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export function fail(message, hints, extra) {
|
|
8
|
+
return respond({
|
|
9
|
+
ok: false,
|
|
10
|
+
error: message,
|
|
11
|
+
hints,
|
|
12
|
+
...extra,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
const HINT_TABLE = [
|
|
4
|
+
{
|
|
5
|
+
pattern: /connect|timeout|1954|offline/i,
|
|
6
|
+
hint: "PLC or HMI is unreachable. Check network/power, target IP with lasal_status, or set it via set_target_ip.",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
pattern: /no project|load project|failed to load/i,
|
|
10
|
+
hint: "Project failed to load. Verify the project path or call select_project.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
pattern: /lock|locked|sharing violation|permission denied/i,
|
|
14
|
+
hint: "Project files or engine locked. Close CLASS 2 or VISUDesigner via manage_class2/manage_visudesigner close.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
pattern: /compile|syntax error|declaration/i,
|
|
18
|
+
hint: "Compilation failed. Check the compiler log for syntax errors.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
pattern: /channel not found|not exist|unknown channel/i,
|
|
22
|
+
hint: "Channel not found. Check ObjectName.ChannelName spelling/casing via inspect_project.",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
function executeEngine(exe, args, timeoutMs) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const child = execFile(exe, args, {
|
|
28
|
+
timeout: timeoutMs,
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
31
|
+
}, (error, _stdout, stderr) => {
|
|
32
|
+
if (error) {
|
|
33
|
+
const timedOut = error.killed || error.code === "ETIMEDOUT";
|
|
34
|
+
resolve({
|
|
35
|
+
exitCode: error.code !== undefined && typeof error.code === "number" ? error.code : (child.exitCode ?? 1),
|
|
36
|
+
timedOut,
|
|
37
|
+
stderr: stderr?.toString() ?? "",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
resolve({ exitCode: 0, timedOut: false, stderr: "" });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function parseLog(logPath, encoding, hasFailed) {
|
|
47
|
+
const errors = [];
|
|
48
|
+
const warnings = [];
|
|
49
|
+
const logTail = [];
|
|
50
|
+
if (!existsSync(logPath))
|
|
51
|
+
return { errors, warnings, logTail, logContent: "" };
|
|
52
|
+
let logContent = "";
|
|
53
|
+
try {
|
|
54
|
+
logContent = readFileSync(logPath, encoding);
|
|
55
|
+
const lines = logContent.split(/\r?\n/);
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed)
|
|
59
|
+
continue;
|
|
60
|
+
if (trimmed.includes("(ERROR)") || trimmed.includes("(FATAL)")) {
|
|
61
|
+
errors.push(trimmed);
|
|
62
|
+
}
|
|
63
|
+
else if (trimmed.includes("(WARN)")) {
|
|
64
|
+
warnings.push(trimmed);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (hasFailed) {
|
|
68
|
+
logTail.push(...lines
|
|
69
|
+
.filter((l) => l.trim())
|
|
70
|
+
.slice(-15)
|
|
71
|
+
.map((l) => l.trim()));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
return { errors, warnings, logTail, logContent };
|
|
76
|
+
}
|
|
77
|
+
function parseSteps(stepsPath, expectedSteps) {
|
|
78
|
+
const confirmed = new Set();
|
|
79
|
+
if (existsSync(stepsPath)) {
|
|
80
|
+
try {
|
|
81
|
+
const stepsContent = readFileSync(stepsPath, "utf-8");
|
|
82
|
+
for (const line of stepsContent.split(/\r?\n/)) {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
if (trimmed.startsWith("STEP ") && trimmed.endsWith(" OK")) {
|
|
85
|
+
confirmed.add(trimmed.slice(5, -3).trim());
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
}
|
|
91
|
+
const steps = [];
|
|
92
|
+
let hasFailed = false;
|
|
93
|
+
for (const label of expectedSteps) {
|
|
94
|
+
if (confirmed.has(label)) {
|
|
95
|
+
steps.push({ label, status: "ok" });
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
steps.push({ label, status: hasFailed ? "not_reached" : "failed" });
|
|
99
|
+
hasFailed = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { steps, allConfirmed: expectedSteps.every((s) => confirmed.has(s)) };
|
|
103
|
+
}
|
|
104
|
+
export async function runEngineScript(scriptPath, opts, logPath) {
|
|
105
|
+
const start = Date.now();
|
|
106
|
+
const args = opts.argsFor(scriptPath);
|
|
107
|
+
const { exitCode, timedOut, stderr } = await executeEngine(opts.exe, args, opts.timeoutMs);
|
|
108
|
+
if (timedOut || exitCode !== 0) {
|
|
109
|
+
opts.killOnFailure();
|
|
110
|
+
}
|
|
111
|
+
const errors = [];
|
|
112
|
+
if (timedOut) {
|
|
113
|
+
errors.push(`Engine execution timed out after ${opts.timeoutMs / 1000}s.`);
|
|
114
|
+
}
|
|
115
|
+
else if (stderr.trim()) {
|
|
116
|
+
errors.push(...stderr
|
|
117
|
+
.split("\n")
|
|
118
|
+
.map((l) => l.trim())
|
|
119
|
+
.filter(Boolean));
|
|
120
|
+
}
|
|
121
|
+
const durationMs = Date.now() - start;
|
|
122
|
+
const hasFailed = exitCode !== 0 || errors.length > 0 || timedOut;
|
|
123
|
+
const log = parseLog(logPath, opts.logEncoding, hasFailed);
|
|
124
|
+
errors.push(...log.errors);
|
|
125
|
+
const { steps, allConfirmed } = parseSteps(opts.stepsPath, opts.expectedSteps);
|
|
126
|
+
if (!allConfirmed && log.errors.length === 0 && !timedOut) {
|
|
127
|
+
const lastConfirmed = [...opts.expectedSteps]
|
|
128
|
+
.reverse()
|
|
129
|
+
.find((s) => steps.find((st) => st.label === s && st.status === "ok"));
|
|
130
|
+
const firstMissing = opts.expectedSteps.find((s) => !steps.find((st) => st.label === s && st.status === "ok"));
|
|
131
|
+
errors.push(`Engine exited but not all expected operations completed. ` +
|
|
132
|
+
`Last confirmed: ${lastConfirmed ?? "(none)"}. First missing: ${firstMissing ?? "(unknown)"}.`);
|
|
133
|
+
}
|
|
134
|
+
const hints = [];
|
|
135
|
+
if (timedOut) {
|
|
136
|
+
hints.push("Timed out — for large projects raise the timeout via the tool's timeout_s argument or LASAL_MCP_TIMEOUT_* environment variables.");
|
|
137
|
+
}
|
|
138
|
+
const hasFailed2 = exitCode !== 0 || errors.length > 0 || timedOut || !allConfirmed;
|
|
139
|
+
if (hasFailed2) {
|
|
140
|
+
for (const mapping of HINT_TABLE) {
|
|
141
|
+
const matched = errors.some((err) => mapping.pattern.test(err)) ||
|
|
142
|
+
log.warnings.some((warn) => mapping.pattern.test(warn)) ||
|
|
143
|
+
log.logTail.some((line) => mapping.pattern.test(line));
|
|
144
|
+
if (matched && !hints.includes(mapping.hint)) {
|
|
145
|
+
hints.push(mapping.hint);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
ok: exitCode === 0 && errors.length === 0 && !timedOut && allConfirmed,
|
|
151
|
+
exitCode,
|
|
152
|
+
timedOut,
|
|
153
|
+
steps,
|
|
154
|
+
errors,
|
|
155
|
+
warnings: log.warnings,
|
|
156
|
+
logTail: log.logTail,
|
|
157
|
+
logPath,
|
|
158
|
+
durationMs,
|
|
159
|
+
hints,
|
|
160
|
+
};
|
|
161
|
+
}
|