node-red-contrib-me-vplc 1.0.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/README.md +42 -0
- package/me-vplc.html +64 -0
- package/me-vplc.js +603 -0
- package/package.json +26 -0
- package/project/README.md +1052 -0
- package/project/START_ME_VPLC.cmd +176 -0
- package/project/backend/active_project.json +3 -0
- package/project/backend/app.py +839 -0
- package/project/backend/connector_runtime.py +585 -0
- package/project/backend/requirements.txt +3 -0
- package/project/backend/st_compiler.py +1415 -0
- package/project/frontend/index.html +12 -0
- package/project/frontend/package.json +18 -0
- package/project/frontend/src/App.jsx +631 -0
- package/project/frontend/src/style.css +964 -0
- package/project/frontend/vite.config.js +14 -0
- package/wheelhouse/Flask_Cors-4.0.1-py2.py3-none-any.whl +0 -0
- package/wheelhouse/blinker-1.9.0-py3-none-any.whl +0 -0
- package/wheelhouse/click-8.3.3-py3-none-any.whl +0 -0
- package/wheelhouse/colorama-0.4.6-py2.py3-none-any.whl +0 -0
- package/wheelhouse/flask-3.0.3-py3-none-any.whl +0 -0
- package/wheelhouse/itsdangerous-2.2.0-py3-none-any.whl +0 -0
- package/wheelhouse/jinja2-3.1.6-py3-none-any.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp310-cp310-win_amd64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp311-cp311-win_amd64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp312-cp312-win_amd64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp313-cp313-win_amd64.whl +0 -0
- package/wheelhouse/pymodbus-3.6.9-py3-none-any.whl +0 -0
- package/wheelhouse/werkzeug-3.1.8-py3-none-any.whl +0 -0
package/me-vplc.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
module.exports = function (RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const http = require("http");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const { spawn } = require("child_process");
|
|
9
|
+
|
|
10
|
+
function copyRecursive(src, dst) {
|
|
11
|
+
if (!fs.existsSync(src)) return;
|
|
12
|
+
const stat = fs.statSync(src);
|
|
13
|
+
if (stat.isDirectory()) {
|
|
14
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
15
|
+
for (const entry of fs.readdirSync(src)) {
|
|
16
|
+
if (entry === "__pycache__" || entry === "node_modules" || entry === "dist" || entry === ".venv") continue;
|
|
17
|
+
copyRecursive(path.join(src, entry), path.join(dst, entry));
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
21
|
+
fs.copyFileSync(src, dst);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeWindowsPathMaybe(p) {
|
|
26
|
+
if (process.platform !== "win32") return p;
|
|
27
|
+
if (!p) return p;
|
|
28
|
+
|
|
29
|
+
// Node-RED kann userDir in seltenen Fällen als \Users\name\.node-red
|
|
30
|
+
// statt C:\Users\name\.node-red liefern. child_process.spawn akzeptiert
|
|
31
|
+
// root-relative Windows-Pfade als Programm-Pfad nicht zuverlässig.
|
|
32
|
+
if (/^[\\\/][^\\\/]/.test(p) && !/^[a-zA-Z]:[\\\/]/.test(p)) {
|
|
33
|
+
const drive = process.env.HOMEDRIVE || process.env.SystemDrive || (path.parse(process.cwd()).root || "C:\\").slice(0, 2);
|
|
34
|
+
return drive + p;
|
|
35
|
+
}
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveUserDir() {
|
|
40
|
+
let userDir = RED.settings.userDir || process.env.NODE_RED_HOME || path.join(os.homedir(), ".node-red");
|
|
41
|
+
userDir = normalizeWindowsPathMaybe(userDir);
|
|
42
|
+
if (process.platform === "win32" && (!userDir || !/^[a-zA-Z]:[\\\/]/.test(userDir))) {
|
|
43
|
+
userDir = path.join(os.homedir(), ".node-red");
|
|
44
|
+
}
|
|
45
|
+
return userDir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isConnectionError(err) {
|
|
49
|
+
return err && ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "ENOENT"].includes(err.code);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tailFile(file, maxChars) {
|
|
53
|
+
try {
|
|
54
|
+
if (!fs.existsSync(file)) return "";
|
|
55
|
+
const text = fs.readFileSync(file, "utf8");
|
|
56
|
+
return text.slice(-(maxChars || 4000));
|
|
57
|
+
} catch (_) {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function requestJson(port, apiPath, method, body, done) {
|
|
63
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
64
|
+
const req = http.request({
|
|
65
|
+
hostname: "127.0.0.1",
|
|
66
|
+
port: Number(port || 5000),
|
|
67
|
+
path: apiPath,
|
|
68
|
+
method: method || "GET",
|
|
69
|
+
timeout: 2500,
|
|
70
|
+
headers: payload ? {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
73
|
+
} : {}
|
|
74
|
+
}, (res) => {
|
|
75
|
+
let data = "";
|
|
76
|
+
res.setEncoding("utf8");
|
|
77
|
+
res.on("data", (chunk) => data += chunk);
|
|
78
|
+
res.on("end", () => {
|
|
79
|
+
try {
|
|
80
|
+
const json = data ? JSON.parse(data) : {};
|
|
81
|
+
done(null, json, res.statusCode);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
err.message = `Ungültige JSON-Antwort von ME vPLC (${res.statusCode}): ${err.message}`;
|
|
84
|
+
done(err);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
req.on("timeout", () => req.destroy(Object.assign(new Error("ME vPLC Backend Timeout"), { code: "ETIMEDOUT" })));
|
|
89
|
+
req.on("error", done);
|
|
90
|
+
if (payload) req.write(payload);
|
|
91
|
+
req.end();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function MeVplcNode(config) {
|
|
95
|
+
RED.nodes.createNode(this, config);
|
|
96
|
+
const node = this;
|
|
97
|
+
|
|
98
|
+
node.name = config.name || "ME vPLC";
|
|
99
|
+
node.backendPort = Number(config.backendPort || 5000);
|
|
100
|
+
node.pythonPathConfigured = String(config.pythonPath || "").trim();
|
|
101
|
+
// Windows kennt in der Regel kein echtes "python3".
|
|
102
|
+
// Falls im UI noch python3 steht, wird es nur als erster Kandidat getestet
|
|
103
|
+
// und danach automatisch auf python / py -3 / py zurückgefallen.
|
|
104
|
+
node.pythonPath = node.pythonPathConfigured || (process.platform === "win32" ? "python" : "python3");
|
|
105
|
+
node.basePython = null;
|
|
106
|
+
node.autoStart = config.autoStart !== false && config.autoStart !== "false";
|
|
107
|
+
node.copyOnStart = config.copyOnStart === true || config.copyOnStart === "true";
|
|
108
|
+
node.autoInstallDeps = config.autoInstallDeps !== false && config.autoInstallDeps !== "false";
|
|
109
|
+
node.child = null;
|
|
110
|
+
node.childExited = null;
|
|
111
|
+
node.pollTimer = null;
|
|
112
|
+
node.starting = false;
|
|
113
|
+
node.runtimeDir = path.join(resolveUserDir(), "me-vplc-runtime", node.id.replace(/[^a-zA-Z0-9_-]/g, ""));
|
|
114
|
+
node.embeddedProjectDir = path.join(__dirname, "project");
|
|
115
|
+
node.logDir = path.join(node.runtimeDir, "logs");
|
|
116
|
+
node.stdoutLog = path.join(node.logDir, "backend_stdout.log");
|
|
117
|
+
node.stderrLog = path.join(node.logDir, "backend_stderr.log");
|
|
118
|
+
|
|
119
|
+
function ensureRuntimeProject(forceCopy) {
|
|
120
|
+
const marker = path.join(node.runtimeDir, "backend", "app.py");
|
|
121
|
+
if (forceCopy && fs.existsSync(node.runtimeDir)) fs.rmSync(node.runtimeDir, { recursive: true, force: true });
|
|
122
|
+
if (!fs.existsSync(marker)) copyRecursive(node.embeddedProjectDir, node.runtimeDir);
|
|
123
|
+
fs.mkdirSync(node.logDir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setStatus(fill, shape, text) {
|
|
127
|
+
node.status({ fill, shape, text });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function backendUrl(pathname) {
|
|
131
|
+
return `http://127.0.0.1:${node.backendPort}${pathname || ""}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function venvPythonPath() {
|
|
135
|
+
const venvDir = path.join(node.runtimeDir, ".venv");
|
|
136
|
+
if (process.platform === "win32") return path.join(venvDir, "Scripts", "python.exe");
|
|
137
|
+
return path.join(venvDir, "bin", "python");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function findExistingVenvPython() {
|
|
141
|
+
const venvDir = path.join(node.runtimeDir, ".venv");
|
|
142
|
+
const candidates = process.platform === "win32"
|
|
143
|
+
? [
|
|
144
|
+
path.join(venvDir, "Scripts", "python.exe"),
|
|
145
|
+
path.join(venvDir, "Scripts", "python3.exe"),
|
|
146
|
+
path.join(venvDir, "python.exe")
|
|
147
|
+
]
|
|
148
|
+
: [
|
|
149
|
+
path.join(venvDir, "bin", "python3"),
|
|
150
|
+
path.join(venvDir, "bin", "python")
|
|
151
|
+
];
|
|
152
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function spawnCollect(command, args, options, timeoutMs, callback) {
|
|
156
|
+
let stdout = "";
|
|
157
|
+
let stderr = "";
|
|
158
|
+
let finished = false;
|
|
159
|
+
let child;
|
|
160
|
+
try {
|
|
161
|
+
child = spawn(command, args, Object.assign({ windowsHide: true }, options || {}));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
callback(err, stdout, stderr);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
if (!finished) {
|
|
168
|
+
child.kill();
|
|
169
|
+
const err = new Error(`Timeout bei Kommando: ${command} ${args.join(" ")}`);
|
|
170
|
+
err.code = "ETIMEDOUT";
|
|
171
|
+
callback(err, stdout, stderr);
|
|
172
|
+
}
|
|
173
|
+
}, timeoutMs || 60000);
|
|
174
|
+
child.stdout && child.stdout.on("data", (d) => { stdout += String(d); });
|
|
175
|
+
child.stderr && child.stderr.on("data", (d) => { stderr += String(d); });
|
|
176
|
+
child.on("error", (err) => {
|
|
177
|
+
if (finished) return;
|
|
178
|
+
finished = true;
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
callback(err, stdout, stderr);
|
|
181
|
+
});
|
|
182
|
+
child.on("exit", (code) => {
|
|
183
|
+
if (finished) return;
|
|
184
|
+
finished = true;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
if (code === 0) callback(null, stdout, stderr);
|
|
187
|
+
else {
|
|
188
|
+
const err = new Error(`Kommando fehlgeschlagen (${code}): ${command} ${args.join(" ")}\n${stderr || stdout}`);
|
|
189
|
+
err.code = `EXIT_${code}`;
|
|
190
|
+
callback(err, stdout, stderr);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function pythonCandidates() {
|
|
196
|
+
const list = [];
|
|
197
|
+
function add(command, args) {
|
|
198
|
+
if (!command) return;
|
|
199
|
+
const key = command + "\u0000" + (args || []).join("\u0000");
|
|
200
|
+
if (!list.some((x) => x.key === key)) list.push({ key, command, args: args || [] });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Im UI konfigurierte Angabe zuerst testen. "py -3" ist erlaubt.
|
|
204
|
+
if (node.pythonPathConfigured) {
|
|
205
|
+
const parts = node.pythonPathConfigured.split(/\s+/).filter(Boolean);
|
|
206
|
+
add(parts[0], parts.slice(1));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (process.platform === "win32") {
|
|
210
|
+
add("python", []);
|
|
211
|
+
add("py", ["-3"]);
|
|
212
|
+
add("py", []);
|
|
213
|
+
add("python3", []);
|
|
214
|
+
} else {
|
|
215
|
+
add("python3", []);
|
|
216
|
+
add("python", []);
|
|
217
|
+
}
|
|
218
|
+
return list;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resolveBasePython(callback) {
|
|
222
|
+
if (node.basePython && fs.existsSync(node.basePython)) {
|
|
223
|
+
callback(null, node.basePython);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const candidates = pythonCandidates();
|
|
227
|
+
let index = 0;
|
|
228
|
+
const errors = [];
|
|
229
|
+
|
|
230
|
+
function tryNext() {
|
|
231
|
+
if (index >= candidates.length) {
|
|
232
|
+
const err = new Error("Keine funktionierende Python-Installation gefunden. Getestet: " + candidates.map((c) => [c.command].concat(c.args).join(" ")).join(", ") + "\n" + errors.join("\n"));
|
|
233
|
+
err.code = "PYTHON_NOT_FOUND";
|
|
234
|
+
callback(err);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const c = candidates[index++];
|
|
239
|
+
const label = [c.command].concat(c.args).join(" ");
|
|
240
|
+
setStatus("yellow", "ring", `Prüfe Python: ${label}`);
|
|
241
|
+
spawnCollect(c.command, c.args.concat(["-c", "import sys; print(sys.executable)"]), { cwd: node.runtimeDir }, 30000, (err, stdout, stderr) => {
|
|
242
|
+
if (err) {
|
|
243
|
+
errors.push(`${label}: ${err.message || stderr || stdout}`);
|
|
244
|
+
tryNext();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const exe = String(stdout || "").trim().split(/\r?\n/).pop();
|
|
248
|
+
if (!exe) {
|
|
249
|
+
errors.push(`${label}: kein sys.executable erhalten`);
|
|
250
|
+
tryNext();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
node.basePython = exe;
|
|
254
|
+
node.pythonPath = exe;
|
|
255
|
+
node.log(`Python erkannt: ${exe}`);
|
|
256
|
+
callback(null, exe);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
tryNext();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function preparePython(callback) {
|
|
263
|
+
ensureRuntimeProject(node.copyOnStart);
|
|
264
|
+
const backendDir = path.join(node.runtimeDir, "backend");
|
|
265
|
+
const requirements = path.join(backendDir, "requirements.txt");
|
|
266
|
+
const wheelhouse = path.join(__dirname, "wheelhouse");
|
|
267
|
+
|
|
268
|
+
const existingVenvPy = findExistingVenvPython();
|
|
269
|
+
const py = existingVenvPy || venvPythonPath();
|
|
270
|
+
if (existingVenvPy) {
|
|
271
|
+
node.pythonRuntime = existingVenvPy;
|
|
272
|
+
checkImports(existingVenvPy, backendDir, requirements, wheelhouse, callback);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
resolveBasePython((baseErr, basePy) => {
|
|
277
|
+
if (baseErr) {
|
|
278
|
+
callback(baseErr);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
setStatus("yellow", "ring", "Python venv wird erstellt");
|
|
283
|
+
spawnCollect(basePy, ["-m", "venv", path.join(node.runtimeDir, ".venv")], { cwd: node.runtimeDir }, 120000, (err, stdout, stderr) => {
|
|
284
|
+
if (err) {
|
|
285
|
+
node.warn(`Venv konnte nicht erstellt werden, nutze System-Python '${basePy}': ${err.message}`);
|
|
286
|
+
checkImports(basePy, backendDir, requirements, wheelhouse, callback);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const createdVenvPy = findExistingVenvPython();
|
|
290
|
+
if (!createdVenvPy) {
|
|
291
|
+
const venvErr = new Error(`Virtuelle Python-Umgebung wurde erstellt, aber python.exe wurde nicht gefunden. Erwartet unter: ${path.join(node.runtimeDir, ".venv")}. stdout=${stdout || ""} stderr=${stderr || ""}`);
|
|
292
|
+
venvErr.code = "VENV_PYTHON_MISSING";
|
|
293
|
+
callback(venvErr);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
node.pythonRuntime = createdVenvPy;
|
|
297
|
+
node.pythonPath = createdVenvPy;
|
|
298
|
+
checkImports(createdVenvPy, backendDir, requirements, wheelhouse, callback);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function checkImports(py, backendDir, requirements, wheelhouse, callback) {
|
|
304
|
+
const checkCode = "import flask, flask_cors; import pymodbus; print('ME_VPLC_PYTHON_OK')";
|
|
305
|
+
spawnCollect(py, ["-c", checkCode], { cwd: backendDir }, 30000, (checkErr) => {
|
|
306
|
+
if (!checkErr) {
|
|
307
|
+
node.pythonRuntime = py;
|
|
308
|
+
callback(null, py);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (!node.autoInstallDeps) {
|
|
312
|
+
const err = new Error(`Python-Abhängigkeiten fehlen. Bitte installiere: ${requirements}`);
|
|
313
|
+
err.code = "PY_DEPS_MISSING";
|
|
314
|
+
callback(err);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
installDeps(py, backendDir, requirements, wheelhouse, callback);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function installDeps(py, backendDir, requirements, wheelhouse, callback) {
|
|
322
|
+
setStatus("yellow", "ring", "Python Pakete werden installiert");
|
|
323
|
+
const args = ["-m", "pip", "install", "-r", requirements];
|
|
324
|
+
const hasLocalWheelhouse = fs.existsSync(wheelhouse) && fs.readdirSync(wheelhouse).some((f) => f.toLowerCase().endsWith(".whl"));
|
|
325
|
+
if (hasLocalWheelhouse) {
|
|
326
|
+
// Voller Offline-Modus: keine Verbindung zu PyPI, ausschließlich lokale Wheels aus dem Node-Paket.
|
|
327
|
+
args.splice(3, 0, "--no-index", "--find-links", wheelhouse);
|
|
328
|
+
node.log(`Nutze integriertes Offline-Wheelhouse: ${wheelhouse}`);
|
|
329
|
+
} else {
|
|
330
|
+
node.warn("Kein lokales Wheelhouse im Node-Paket gefunden. Pip versucht Online-Installation.");
|
|
331
|
+
}
|
|
332
|
+
node.log(`Installiere Python-Abhängigkeiten: ${py} ${args.join(" ")}`);
|
|
333
|
+
spawnCollect(py, args, { cwd: backendDir }, 180000, (err, stdout, stderr) => {
|
|
334
|
+
if (stdout.trim()) node.log(stdout.trim());
|
|
335
|
+
if (stderr.trim()) node.warn(stderr.trim());
|
|
336
|
+
if (err) {
|
|
337
|
+
err.message = `Python-Abhängigkeiten konnten nicht installiert werden. ${err.message}`;
|
|
338
|
+
callback(err);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
node.pythonRuntime = py;
|
|
342
|
+
callback(null, py);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function publishStatus() {
|
|
347
|
+
requestJson(node.backendPort, "/api/status", "GET", null, (err, data) => {
|
|
348
|
+
if (err) {
|
|
349
|
+
setStatus("red", "ring", isConnectionError(err) ? "Backend nicht erreichbar" : "Backend Fehler");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
setStatus(data.running ? "green" : "yellow", data.running ? "dot" : "ring", data.running ? `RUN ${data.cycle_time_ms || "?"} ms` : "Backend OK / PLC STOP");
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function startPolling() {
|
|
357
|
+
if (node.pollTimer) clearInterval(node.pollTimer);
|
|
358
|
+
node.pollTimer = setInterval(publishStatus, 2000);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function makeStartupError(err) {
|
|
362
|
+
const stderr = tailFile(node.stderrLog, 5000);
|
|
363
|
+
const stdout = tailFile(node.stdoutLog, 3000);
|
|
364
|
+
const detail = stderr || stdout;
|
|
365
|
+
if (detail && !String(err.message || "").includes(detail.slice(0, 80))) {
|
|
366
|
+
err.message = `${err.message}\nBackend-Log:\n${detail}`;
|
|
367
|
+
}
|
|
368
|
+
return err;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function startBackend(callback) {
|
|
372
|
+
requestJson(node.backendPort, "/api/status", "GET", null, (alreadyRunningErr) => {
|
|
373
|
+
if (!alreadyRunningErr) {
|
|
374
|
+
setStatus("yellow", "ring", "Backend bereits aktiv");
|
|
375
|
+
startPolling();
|
|
376
|
+
if (callback) callback(null, { alreadyRunning: true });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (node.child) {
|
|
381
|
+
if (callback) callback(null, { alreadyStarting: true });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (node.starting) {
|
|
385
|
+
waitForBackend(40, callback || function () {});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
node.starting = true;
|
|
390
|
+
node.childExited = null;
|
|
391
|
+
preparePython((prepErr, py) => {
|
|
392
|
+
if (prepErr) {
|
|
393
|
+
node.starting = false;
|
|
394
|
+
setStatus("red", "ring", "Python/Deps Fehler");
|
|
395
|
+
if (callback) callback(prepErr);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const backendDir = path.join(node.runtimeDir, "backend");
|
|
401
|
+
fs.writeFileSync(node.stdoutLog, "", "utf8");
|
|
402
|
+
fs.writeFileSync(node.stderrLog, "", "utf8");
|
|
403
|
+
const out = fs.createWriteStream(node.stdoutLog, { flags: "a" });
|
|
404
|
+
const errOut = fs.createWriteStream(node.stderrLog, { flags: "a" });
|
|
405
|
+
|
|
406
|
+
node.child = spawn(py, ["-u", "app.py"], {
|
|
407
|
+
cwd: backendDir,
|
|
408
|
+
env: Object.assign({}, process.env, {
|
|
409
|
+
ME_VPLC_PORT: String(node.backendPort),
|
|
410
|
+
PYTHONUNBUFFERED: "1",
|
|
411
|
+
FLASK_ENV: "production"
|
|
412
|
+
}),
|
|
413
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
414
|
+
windowsHide: true
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
setStatus("yellow", "ring", `Backend startet (${path.basename(py)})`);
|
|
418
|
+
node.log(`ME vPLC Runtime: ${node.runtimeDir}`);
|
|
419
|
+
node.log(`ME vPLC Backend: ${backendUrl()}`);
|
|
420
|
+
node.log(`ME vPLC Python: ${py}`);
|
|
421
|
+
|
|
422
|
+
node.child.stdout.on("data", (data) => {
|
|
423
|
+
out.write(data);
|
|
424
|
+
const text = String(data).trim();
|
|
425
|
+
if (text) node.log(text);
|
|
426
|
+
});
|
|
427
|
+
node.child.stderr.on("data", (data) => {
|
|
428
|
+
errOut.write(data);
|
|
429
|
+
const text = String(data).trim();
|
|
430
|
+
if (text) node.warn(text);
|
|
431
|
+
});
|
|
432
|
+
node.child.on("error", (err) => {
|
|
433
|
+
node.child = null;
|
|
434
|
+
node.starting = false;
|
|
435
|
+
setStatus("red", "ring", `Python Startfehler: ${err.code || err.message}`);
|
|
436
|
+
node.error(`ME vPLC Backend konnte nicht gestartet werden. Python='${py}'. ${err.message}`);
|
|
437
|
+
if (callback) callback(makeStartupError(err));
|
|
438
|
+
});
|
|
439
|
+
node.child.on("exit", (code, signal) => {
|
|
440
|
+
node.childExited = { code, signal, at: new Date().toISOString() };
|
|
441
|
+
node.child = null;
|
|
442
|
+
node.starting = false;
|
|
443
|
+
setStatus("red", "ring", `Backend beendet ${code !== null ? code : signal}`);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
startPolling();
|
|
447
|
+
waitForBackend(60, (waitErr, data, statusCode) => {
|
|
448
|
+
node.starting = false;
|
|
449
|
+
if (waitErr) {
|
|
450
|
+
if (callback) callback(makeStartupError(waitErr));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (callback) callback(null, { started: true, data, statusCode });
|
|
454
|
+
});
|
|
455
|
+
} catch (err) {
|
|
456
|
+
node.starting = false;
|
|
457
|
+
node.error(err);
|
|
458
|
+
setStatus("red", "ring", "Startfehler");
|
|
459
|
+
if (callback) callback(makeStartupError(err));
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function waitForBackend(maxAttempts, callback) {
|
|
466
|
+
let attempts = 0;
|
|
467
|
+
function check() {
|
|
468
|
+
attempts += 1;
|
|
469
|
+
requestJson(node.backendPort, "/api/status", "GET", null, (err, data, statusCode) => {
|
|
470
|
+
if (!err) {
|
|
471
|
+
callback(null, data, statusCode);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (node.childExited) {
|
|
475
|
+
const exit = node.childExited;
|
|
476
|
+
const exitErr = new Error(`ME vPLC Backend wurde direkt beendet (${exit.code !== null ? "Code " + exit.code : exit.signal}).`);
|
|
477
|
+
exitErr.code = "BACKEND_EXITED";
|
|
478
|
+
callback(exitErr);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (attempts >= maxAttempts) {
|
|
482
|
+
callback(err);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
setTimeout(check, 500);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
check();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function ensureBackendReady(callback) {
|
|
492
|
+
requestJson(node.backendPort, "/api/status", "GET", null, (err, data, statusCode) => {
|
|
493
|
+
if (!err) {
|
|
494
|
+
callback(null, data, statusCode);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
startBackend((startErr) => {
|
|
498
|
+
if (startErr) {
|
|
499
|
+
callback(startErr);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
requestJson(node.backendPort, "/api/status", "GET", null, callback);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function stopBackend() {
|
|
508
|
+
if (node.pollTimer) {
|
|
509
|
+
clearInterval(node.pollTimer);
|
|
510
|
+
node.pollTimer = null;
|
|
511
|
+
}
|
|
512
|
+
if (node.child) {
|
|
513
|
+
node.child.kill();
|
|
514
|
+
node.child = null;
|
|
515
|
+
}
|
|
516
|
+
setStatus("grey", "ring", "gestoppt");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function finishError(msg, send, done, err) {
|
|
520
|
+
const stderr = tailFile(node.stderrLog, 5000);
|
|
521
|
+
const stdout = tailFile(node.stdoutLog, 3000);
|
|
522
|
+
const payload = {
|
|
523
|
+
ok: false,
|
|
524
|
+
error: err.message,
|
|
525
|
+
code: err.code || null,
|
|
526
|
+
hint: isConnectionError(err)
|
|
527
|
+
? `Backend ist auf ${backendUrl()} nicht erreichbar. Der Node startet es automatisch; falls es sofort beendet wird, siehe backendLog.`
|
|
528
|
+
: "Details stehen im Node-RED-Log und in backendLog.",
|
|
529
|
+
backendUrl: backendUrl(),
|
|
530
|
+
runtimeDir: node.runtimeDir,
|
|
531
|
+
python: node.pythonRuntime || node.basePython || node.pythonPath,
|
|
532
|
+
backendLog: {
|
|
533
|
+
stderr,
|
|
534
|
+
stdout
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
setStatus("red", "ring", isConnectionError(err) ? "Backend nicht erreichbar" : "Fehler");
|
|
538
|
+
msg.payload = payload;
|
|
539
|
+
msg.error = payload;
|
|
540
|
+
send(msg);
|
|
541
|
+
if (done) done();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
node.on("input", function (msg, send, done) {
|
|
545
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
546
|
+
const command = String(msg.command || msg.payload || "status").toLowerCase();
|
|
547
|
+
|
|
548
|
+
if (command === "start-backend" || command === "backend-start") {
|
|
549
|
+
startBackend((err) => {
|
|
550
|
+
if (err) { finishError(msg, send, done, err); return; }
|
|
551
|
+
requestJson(node.backendPort, "/api/status", "GET", null, (statusErr, data, statusCode) => {
|
|
552
|
+
if (statusErr) { finishError(msg, send, done, statusErr); return; }
|
|
553
|
+
msg.statusCode = statusCode;
|
|
554
|
+
msg.payload = { ok: true, backend: backendUrl(), runtimeDir: node.runtimeDir, python: node.pythonRuntime || node.basePython || node.pythonPath, status: data };
|
|
555
|
+
send(msg);
|
|
556
|
+
publishStatus();
|
|
557
|
+
if (done) done();
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (command === "stop-backend" || command === "backend-stop") {
|
|
564
|
+
stopBackend();
|
|
565
|
+
msg.payload = { ok: true, backend: "stopped" };
|
|
566
|
+
send(msg);
|
|
567
|
+
if (done) done();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const endpoint = (command === "start" || command === "plc-start") ? "/api/start"
|
|
572
|
+
: (command === "stop" || command === "plc-stop") ? "/api/stop"
|
|
573
|
+
: "/api/status";
|
|
574
|
+
const method = endpoint === "/api/status" ? "GET" : "POST";
|
|
575
|
+
|
|
576
|
+
ensureBackendReady((readyErr) => {
|
|
577
|
+
if (readyErr) { finishError(msg, send, done, readyErr); return; }
|
|
578
|
+
requestJson(node.backendPort, endpoint, method, null, (err, data, statusCode) => {
|
|
579
|
+
if (err) { finishError(msg, send, done, err); return; }
|
|
580
|
+
msg.statusCode = statusCode;
|
|
581
|
+
msg.payload = data;
|
|
582
|
+
msg.meVplc = { backendUrl: backendUrl(), runtimeDir: node.runtimeDir, python: node.pythonRuntime || node.basePython || node.pythonPath };
|
|
583
|
+
send(msg);
|
|
584
|
+
publishStatus();
|
|
585
|
+
if (done) done();
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
node.on("close", function (removed, done) {
|
|
591
|
+
stopBackend();
|
|
592
|
+
done();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
if (node.autoStart) {
|
|
596
|
+
startBackend(() => setTimeout(publishStatus, 500));
|
|
597
|
+
} else {
|
|
598
|
+
setStatus("grey", "ring", "bereit");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
RED.nodes.registerType("me-vplc", MeVplcNode);
|
|
603
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-me-vplc",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Node-RED Node zur Integration der ME vPLC Flask/React Runtime mit eingebettetem Projekt, automatischem Backend-Start und voll integriertem Python-Wheelhouse fuer Offline-Installation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"plc",
|
|
8
|
+
"structured-text",
|
|
9
|
+
"me-vplc",
|
|
10
|
+
"flask",
|
|
11
|
+
"react"
|
|
12
|
+
],
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
|
+
"node-red": {
|
|
15
|
+
"nodes": {
|
|
16
|
+
"me-vplc": "me-vplc.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"me-vplc.js",
|
|
21
|
+
"me-vplc.html",
|
|
22
|
+
"project/**/*",
|
|
23
|
+
"README.md",
|
|
24
|
+
"wheelhouse/**/*"
|
|
25
|
+
]
|
|
26
|
+
}
|