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.
Files changed (37) hide show
  1. package/README.md +42 -0
  2. package/me-vplc.html +64 -0
  3. package/me-vplc.js +603 -0
  4. package/package.json +26 -0
  5. package/project/README.md +1052 -0
  6. package/project/START_ME_VPLC.cmd +176 -0
  7. package/project/backend/active_project.json +3 -0
  8. package/project/backend/app.py +839 -0
  9. package/project/backend/connector_runtime.py +585 -0
  10. package/project/backend/requirements.txt +3 -0
  11. package/project/backend/st_compiler.py +1415 -0
  12. package/project/frontend/index.html +12 -0
  13. package/project/frontend/package.json +18 -0
  14. package/project/frontend/src/App.jsx +631 -0
  15. package/project/frontend/src/style.css +964 -0
  16. package/project/frontend/vite.config.js +14 -0
  17. package/wheelhouse/Flask_Cors-4.0.1-py2.py3-none-any.whl +0 -0
  18. package/wheelhouse/blinker-1.9.0-py3-none-any.whl +0 -0
  19. package/wheelhouse/click-8.3.3-py3-none-any.whl +0 -0
  20. package/wheelhouse/colorama-0.4.6-py2.py3-none-any.whl +0 -0
  21. package/wheelhouse/flask-3.0.3-py3-none-any.whl +0 -0
  22. package/wheelhouse/itsdangerous-2.2.0-py3-none-any.whl +0 -0
  23. package/wheelhouse/jinja2-3.1.6-py3-none-any.whl +0 -0
  24. package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  25. package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  26. package/wheelhouse/markupsafe-3.0.3-cp310-cp310-win_amd64.whl +0 -0
  27. package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  28. package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  29. package/wheelhouse/markupsafe-3.0.3-cp311-cp311-win_amd64.whl +0 -0
  30. package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  31. package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  32. package/wheelhouse/markupsafe-3.0.3-cp312-cp312-win_amd64.whl +0 -0
  33. package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  34. package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  35. package/wheelhouse/markupsafe-3.0.3-cp313-cp313-win_amd64.whl +0 -0
  36. package/wheelhouse/pymodbus-3.6.9-py3-none-any.whl +0 -0
  37. package/wheelhouse/werkzeug-3.1.8-py3-none-any.whl +0 -0
@@ -0,0 +1,839 @@
1
+ import copy
2
+ import json
3
+ import math
4
+ import zipfile
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Optional
10
+
11
+ from flask import Flask, jsonify, request
12
+ from flask_cors import CORS
13
+
14
+ from st_compiler import STCompiler, STProgram
15
+ from connector_runtime import create_connector
16
+
17
+ BASE_DIR = Path(__file__).resolve().parent
18
+ PROJECTS_ROOT = BASE_DIR / "projects"
19
+ ACTIVE_PROJECT_FILE = BASE_DIR / "active_project.json"
20
+ DEFAULT_PROJECT_NAMES: list[str] = []
21
+ PROJECTS_ROOT.mkdir(exist_ok=True)
22
+ LOG_DIR = BASE_DIR / "logs"
23
+ LOG_DIR.mkdir(exist_ok=True)
24
+ COMPILER_LOG_FILE = LOG_DIR / "st_compiler.log"
25
+
26
+ PHASES = ["READ", "EXECUTE", "WRITE", "SYNC"]
27
+ EDITOR_TEXT_EXTENSIONS = {".st", ".json", ".txt", ".xml", ".csv", ".md", ".cfg", ".ini"}
28
+ MAX_EDITOR_FILE_BYTES = 2_000_000
29
+
30
+
31
+ def _safe_project_name(name: str) -> str:
32
+ clean = "".join(ch for ch in str(name or "").strip() if ch.isalnum() or ch in (" ", "_", "-"))
33
+ clean = clean.strip(" ._-")
34
+ if not clean:
35
+ clean = "Original"
36
+ return clean[:80]
37
+
38
+
39
+ def _project_base_dir(project_name: str) -> Path:
40
+ return PROJECTS_ROOT / _safe_project_name(project_name)
41
+
42
+
43
+ def _project_st_dir(project_name: str) -> Path:
44
+ return _project_base_dir(project_name) / "st_project"
45
+
46
+
47
+ def _project_connector_dir(project_name: str) -> Path:
48
+ return _project_base_dir(project_name) / "connector"
49
+
50
+
51
+ def _ensure_project_dirs(project_name: str) -> None:
52
+ _project_st_dir(project_name).mkdir(parents=True, exist_ok=True)
53
+ _project_connector_dir(project_name).mkdir(parents=True, exist_ok=True)
54
+
55
+
56
+ def _list_projects() -> list[str]:
57
+ """Listet ausschließlich echte Projektordner unter backend/projects.
58
+
59
+ Es werden keine Default-Projekte mehr automatisch angelegt. Dadurch zeigt
60
+ das Frontend-Dropdown nur Projekte, die wirklich als Ordner im Verzeichnis
61
+ backend/projects vorhanden sind.
62
+ """
63
+ if not PROJECTS_ROOT.exists():
64
+ return []
65
+ return sorted([p.name for p in PROJECTS_ROOT.iterdir() if p.is_dir()], key=str.lower)
66
+
67
+
68
+ def _bootstrap_projects() -> None:
69
+ # Keine künstlichen Default-Projektordner erzeugen.
70
+ # Quelle der Projektauswahl ist ausschließlich backend/projects/<Projektname>.
71
+ PROJECTS_ROOT.mkdir(exist_ok=True)
72
+
73
+
74
+ def _read_active_project_name() -> str:
75
+ projects = _list_projects()
76
+ if ACTIVE_PROJECT_FILE.exists():
77
+ try:
78
+ data = json.loads(ACTIVE_PROJECT_FILE.read_text(encoding="utf-8"))
79
+ name = _safe_project_name(data.get("active_project", ""))
80
+ if name in projects:
81
+ return name
82
+ except Exception:
83
+ pass
84
+ if "Optimized_EFUSE-Function" in projects:
85
+ return "Optimized_EFUSE-Function"
86
+ if projects:
87
+ return projects[0]
88
+ return "Original"
89
+
90
+
91
+ def _write_active_project_name(name: str) -> None:
92
+ ACTIVE_PROJECT_FILE.write_text(json.dumps({"active_project": _safe_project_name(name)}, indent=2), encoding="utf-8")
93
+
94
+
95
+ def _first_file(directory: Path, suffixes: tuple[str, ...]) -> Optional[Path]:
96
+ files = sorted([p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in suffixes], key=lambda item: item.name.lower())
97
+ return files[0] if files else None
98
+
99
+
100
+ def _is_safe_archive_name(name: str) -> bool:
101
+ normalized = name.replace("\\", "/").strip()
102
+ return bool(normalized) and not normalized.startswith("/") and ".." not in normalized.split("/")
103
+
104
+
105
+ def _read_editable_files(path: Path) -> list[dict[str, str]]:
106
+ if not path.exists():
107
+ raise FileNotFoundError(f"Datei nicht gefunden: {path.name}")
108
+
109
+ files: list[dict[str, str]] = []
110
+ if path.suffix.lower() == ".zip":
111
+ with zipfile.ZipFile(path, "r") as archive:
112
+ for info in sorted(archive.infolist(), key=lambda item: item.filename.lower()):
113
+ if info.is_dir():
114
+ continue
115
+ filename = info.filename.replace("\\", "/")
116
+ if not _is_safe_archive_name(filename):
117
+ continue
118
+ if Path(filename).suffix.lower() not in EDITOR_TEXT_EXTENSIONS:
119
+ continue
120
+ if info.file_size > MAX_EDITOR_FILE_BYTES:
121
+ continue
122
+ content = archive.read(info.filename).decode("utf-8", errors="replace")
123
+ files.append({"path": filename, "content": content})
124
+ else:
125
+ if path.stat().st_size > MAX_EDITOR_FILE_BYTES:
126
+ raise ValueError("Datei ist für den Editor zu groß.")
127
+ files.append({"path": path.name, "content": path.read_text(encoding="utf-8", errors="replace")})
128
+
129
+ if not files:
130
+ raise ValueError("Keine editierbaren Textdateien gefunden.")
131
+ return files
132
+
133
+
134
+ def _write_editable_files(path: Path, files: list[dict[str, str]]) -> None:
135
+ clean_files: list[tuple[str, str]] = []
136
+ for item in files:
137
+ name = str(item.get("path", "")).replace("\\", "/").strip()
138
+ if not _is_safe_archive_name(name):
139
+ raise ValueError(f"Ungültiger Dateipfad: {name}")
140
+ if Path(name).suffix.lower() not in EDITOR_TEXT_EXTENSIONS:
141
+ raise ValueError(f"Dateityp darf nicht bearbeitet werden: {name}")
142
+ content = str(item.get("content", ""))
143
+ if len(content.encode("utf-8")) > MAX_EDITOR_FILE_BYTES:
144
+ raise ValueError(f"Datei ist zu groß: {name}")
145
+ clean_files.append((name, content))
146
+
147
+ if not clean_files:
148
+ raise ValueError("Keine Dateien zum Speichern erhalten.")
149
+
150
+ if path.suffix.lower() == ".zip":
151
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
152
+ with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
153
+ for name, content in clean_files:
154
+ archive.writestr(name, content)
155
+ tmp_path.replace(path)
156
+ else:
157
+ # Bei Einzeldateien wird nur der erste Inhalt gespeichert.
158
+ path.write_text(clean_files[0][1], encoding="utf-8")
159
+
160
+
161
+
162
+ @dataclass
163
+ class PhaseStat:
164
+ min_ms: float = 0.0
165
+ avg_ms: float = 0.0
166
+ max_ms: float = 0.0
167
+ last_ms: float = 0.0
168
+ count: int = 0
169
+ window_size: int = 100
170
+ _samples: list[float] = field(default_factory=list, repr=False)
171
+
172
+ def update(self, value_ms: float) -> None:
173
+ """Aktualisiert Last/Min/Avg/Max live auf Basis der letzten 100 Zyklen.
174
+
175
+ Dadurch ändern sich Min/Avg/Max in jedem Zyklus mit und werden nicht
176
+ nur blockweise nach 100 Zyklen übernommen. Alte Ausreißer, z. B. frühere
177
+ Modbus-Timeouts, fallen nach spätestens 100 neuen Zyklen automatisch aus
178
+ der Anzeige heraus.
179
+ """
180
+ value_ms = round(max(0.0, float(value_ms)), 3)
181
+ self.last_ms = value_ms
182
+ self.count += 1
183
+ self._samples.append(value_ms)
184
+ if len(self._samples) > self.window_size:
185
+ del self._samples[0:len(self._samples) - self.window_size]
186
+
187
+ if self._samples:
188
+ self.min_ms = round(min(self._samples), 3)
189
+ self.max_ms = round(max(self._samples), 3)
190
+ self.avg_ms = round(sum(self._samples) / len(self._samples), 3)
191
+ else:
192
+ self.min_ms = 0.0
193
+ self.avg_ms = 0.0
194
+ self.max_ms = 0.0
195
+
196
+ def sync_window(self) -> None:
197
+ # Min/Avg/Max werden live rollierend berechnet. Diese Methode bleibt
198
+ # als Marker für die Anzeige der 100-Zyklen-Synchronisierung bestehen.
199
+ return
200
+
201
+ def reset(self) -> None:
202
+ self.min_ms = 0.0
203
+ self.avg_ms = 0.0
204
+ self.max_ms = 0.0
205
+ self.last_ms = 0.0
206
+ self.count = 0
207
+ self._samples.clear()
208
+
209
+ def to_dict(self) -> dict[str, float | int | bool]:
210
+ return {
211
+ "min_ms": self.min_ms,
212
+ "avg_ms": self.avg_ms,
213
+ "max_ms": self.max_ms,
214
+ "last_ms": self.last_ms,
215
+ "count": self.count,
216
+ "window_size": self.window_size,
217
+ "window_fill": len(self._samples),
218
+ "has_full_window": len(self._samples) >= self.window_size,
219
+ }
220
+
221
+
222
+ class MEVPLC:
223
+ def __init__(self) -> None:
224
+ _bootstrap_projects()
225
+ self.lock = threading.RLock()
226
+ self.running = False
227
+ self.cycle_time_ms = 200
228
+ self.auto_cycle_enabled = False
229
+ self.auto_cycle_margin_percent = 20
230
+ self.auto_cycle_margin_ms = 2
231
+ self.cycle_count = 0
232
+ self.last_cycle_ms = 0.0
233
+ self.thread: Optional[threading.Thread] = None
234
+ self.stop_event = threading.Event()
235
+ self.stats_window_size = 100
236
+ self.last_stats_sync_cycle = 0
237
+ self.last_stats_sync_time = None
238
+ self.phase_stats: Dict[str, PhaseStat] = {phase: PhaseStat(window_size=self.stats_window_size) for phase in PHASES}
239
+ self.active_project = _read_active_project_name()
240
+ self.available_projects = _list_projects()
241
+ self.imported_st_project: Optional[str] = None
242
+ self.imported_connector: Optional[str] = None
243
+ self.compiled_program: Optional[STProgram] = None
244
+ self.st_compile_ok = False
245
+ self.st_compile_message = "Kein ST-Projekt importiert."
246
+ self.st_compiled_project: Optional[str] = None
247
+ self.st_compiled_file: Optional[str] = None
248
+ self.st_compiled_at: Optional[str] = None
249
+ self.compiler_log = []
250
+ self.connector_data: Dict[str, Any] = {}
251
+ self.connector_runtime = None
252
+ self.last_error: Optional[str] = None
253
+ self._load_project_assets_unlocked(self.active_project, publish=False)
254
+ self._snapshot: Dict[str, Any] = self._build_snapshot_unlocked()
255
+
256
+ def _active_st_dir_unlocked(self) -> Path:
257
+ _ensure_project_dirs(self.active_project)
258
+ return _project_st_dir(self.active_project)
259
+
260
+ def _active_connector_dir_unlocked(self) -> Path:
261
+ _ensure_project_dirs(self.active_project)
262
+ return _project_connector_dir(self.active_project)
263
+
264
+ def _clear_runtime_unlocked(self) -> None:
265
+ if self.connector_runtime is not None and hasattr(self.connector_runtime, "shutdown"):
266
+ try:
267
+ self.connector_runtime.shutdown()
268
+ except Exception:
269
+ pass
270
+ self.cycle_count = 0
271
+ self.last_cycle_ms = 0.0
272
+ self.last_stats_sync_cycle = 0
273
+ self.last_stats_sync_time = None
274
+ self.phase_stats = {phase: PhaseStat(window_size=self.stats_window_size) for phase in PHASES}
275
+ self.imported_st_project = None
276
+ self.imported_connector = None
277
+ self.compiled_program = None
278
+ self.st_compile_ok = False
279
+ self.st_compile_message = "Kein ST-Projekt importiert."
280
+ self.st_compiled_project = None
281
+ self.st_compiled_file = None
282
+ self.st_compiled_at = None
283
+ self.compiler_log = []
284
+ self.connector_data = {}
285
+ self.connector_runtime = None
286
+ self.last_error = None
287
+
288
+ def _compile_st_file_unlocked(self, st_file: Path) -> None:
289
+ # Wichtig für den Multiprojekt-Betrieb:
290
+ # Beim Projektwechsel darf keine alte Runtime/kein alter AST weiterverwendet werden.
291
+ # Deshalb wird das ST-Projekt des aktiven Projektordners immer frisch kompiliert
292
+ # und danach eindeutig mit Projektname + Dateiname im Status markiert.
293
+ self.imported_st_project = st_file.name
294
+ try:
295
+ program = STCompiler.compile_path(st_file)
296
+ compiler_log = list(getattr(program.compiler, "compile_log", []))
297
+ self._write_compiler_log_file(f"{self.active_project}/{st_file.name}", compiler_log)
298
+ self.compiled_program = program
299
+ self.st_compile_ok = True
300
+ self.compiler_log = compiler_log
301
+ self.st_compiled_project = self.active_project
302
+ self.st_compiled_file = st_file.name
303
+ self.st_compiled_at = time.strftime("%Y-%m-%d %H:%M:%S")
304
+ self.st_compile_message = (
305
+ f"IEC 61131-3 ST kompiliert: PROGRAM {program.name}, "
306
+ f"{len(program.variables)} Variable(n), {len(program.statements)} Anweisung(en). "
307
+ f"Projekt: {self.active_project}. Optimierte Runtime aktiv."
308
+ )
309
+ except Exception as exc:
310
+ self.compiled_program = None
311
+ self.st_compile_ok = False
312
+ self.st_compiled_project = self.active_project
313
+ self.st_compiled_file = st_file.name
314
+ self.st_compiled_at = time.strftime("%Y-%m-%d %H:%M:%S")
315
+ self.st_compile_message = f"ST Compile Fehler in Projekt {self.active_project}: {exc}"
316
+ self.compiler_log = [{"time": time.strftime("%H:%M:%S"), "level": "ERROR", "message": str(exc)}]
317
+
318
+ def _load_project_assets_unlocked(self, project_name: str, publish: bool = True) -> None:
319
+ self.active_project = _safe_project_name(project_name)
320
+ _ensure_project_dirs(self.active_project)
321
+ self.available_projects = _list_projects()
322
+ self._clear_runtime_unlocked()
323
+
324
+ st_file = _first_file(_project_st_dir(self.active_project), (".zip", ".st", ".txt", ".json"))
325
+ if st_file is not None:
326
+ self._compile_st_file_unlocked(st_file)
327
+
328
+ connector_file = _first_file(_project_connector_dir(self.active_project), (".zip", ".json", ".txt"))
329
+ if connector_file is not None:
330
+ try:
331
+ connector_data = self._load_connector_file(connector_file)
332
+ self.imported_connector = connector_file.name
333
+ self.connector_data = connector_data
334
+ self.connector_runtime = create_connector(connector_data)
335
+ except Exception as exc:
336
+ self.imported_connector = connector_file.name
337
+ self.connector_runtime = None
338
+ self.last_error = f"Connector Import Fehler: {exc}"
339
+
340
+ _write_active_project_name(self.active_project)
341
+ if publish:
342
+ self._publish_snapshot_unlocked()
343
+
344
+ def select_project(self, project_name: str) -> tuple[bool, str]:
345
+ clean_name = _safe_project_name(project_name)
346
+ with self.lock:
347
+ if self.running:
348
+ return False, "Projektwechsel ist nur bei PLC STOP möglich."
349
+ if clean_name not in _list_projects():
350
+ return False, f"Projekt nicht gefunden: {clean_name}"
351
+ self._load_project_assets_unlocked(clean_name, publish=True)
352
+ if self.imported_st_project and self.st_compile_ok:
353
+ return True, f"Projekt gewechselt und ST-Projekt frisch kompiliert: {clean_name}"
354
+ if self.imported_st_project and not self.st_compile_ok:
355
+ return False, f"Projekt gewechselt, aber ST-Kompilierung fehlgeschlagen: {clean_name}"
356
+ return True, f"Projekt gewechselt: {clean_name}"
357
+
358
+ def _load_connector_file(self, path: Path) -> Dict[str, Any]:
359
+ if path.suffix.lower() == ".json":
360
+ return json.loads(path.read_text(encoding="utf-8", errors="replace"))
361
+ if path.suffix.lower() == ".zip":
362
+ with zipfile.ZipFile(path, "r") as archive:
363
+ json_files = [name for name in archive.namelist() if name.lower().endswith(".json") and not name.endswith("/")]
364
+ if not json_files:
365
+ raise ValueError("Keine .json Datei im Connector-ZIP gefunden.")
366
+ return json.loads(archive.read(json_files[0]).decode("utf-8", errors="replace"))
367
+ return json.loads(path.read_text(encoding="utf-8", errors="replace"))
368
+
369
+ def _real_cycle_avg_unlocked(self) -> float:
370
+ read_avg = float(self.phase_stats["READ"].avg_ms or 0.0)
371
+ execute_avg = float(self.phase_stats["EXECUTE"].avg_ms or 0.0)
372
+ write_avg = float(self.phase_stats["WRITE"].avg_ms or 0.0)
373
+ return round(read_avg + execute_avg + write_avg, 3)
374
+
375
+ def _suggested_cycle_time_unlocked(self) -> int:
376
+ real_avg = self._real_cycle_avg_unlocked()
377
+ if real_avg <= 0:
378
+ return int(self.cycle_time_ms)
379
+ margin_factor = 1.0 + (float(self.auto_cycle_margin_percent) / 100.0)
380
+ suggested = int(math.ceil((real_avg * margin_factor) + float(self.auto_cycle_margin_ms)))
381
+ return max(1, min(60000, suggested))
382
+
383
+ def _build_snapshot_unlocked(self) -> Dict[str, Any]:
384
+ return {
385
+ "active_project": self.active_project,
386
+ "projects": self.available_projects,
387
+ "running": self.running,
388
+ "can_start": self.can_start_unlocked(),
389
+ "cycle_time_ms": self.cycle_time_ms,
390
+ "auto_cycle_enabled": self.auto_cycle_enabled,
391
+ "auto_cycle_margin_percent": self.auto_cycle_margin_percent,
392
+ "real_cycle_avg_ms": self._real_cycle_avg_unlocked(),
393
+ "suggested_cycle_time_ms": self._suggested_cycle_time_unlocked(),
394
+ "cycle_count": self.cycle_count,
395
+ "last_cycle_ms": self.last_cycle_ms,
396
+ "phases": {name: stat.to_dict() for name, stat in self.phase_stats.items()},
397
+ "stats_window": {
398
+ "window_size": self.stats_window_size,
399
+ "last_sync_cycle": self.last_stats_sync_cycle,
400
+ "last_sync_time": self.last_stats_sync_time,
401
+ "current_window_count": min(self.cycle_count, self.stats_window_size),
402
+ "next_sync_cycle": ((self.cycle_count // self.stats_window_size) + 1) * self.stats_window_size,
403
+ "rolling_live": True,
404
+ },
405
+ "imported_st_project": self.imported_st_project,
406
+ "imported_connector": self.imported_connector,
407
+ "st_compile_ok": self.st_compile_ok,
408
+ "st_compile_message": self.st_compile_message,
409
+ "st_compiled_project": self.st_compiled_project,
410
+ "st_compiled_file": self.st_compiled_file,
411
+ "st_compiled_at": self.st_compiled_at,
412
+ "compiler_log": self.compiler_log[-250:],
413
+ "last_error": self.last_error,
414
+ "connector_status": self.connector_runtime.to_dict() if self.connector_runtime else None,
415
+ }
416
+
417
+ def _publish_snapshot_unlocked(self) -> None:
418
+ # Frontend/API liest nur diese fertige Kopie; keine Berechnung im Status-Request.
419
+ self._snapshot = self._build_snapshot_unlocked()
420
+
421
+ def can_start_unlocked(self) -> bool:
422
+ # Start nur sperren, wenn weder ST noch Connector vorhanden ist.
423
+ return self.imported_st_project is not None or self.imported_connector is not None
424
+
425
+ def can_start(self) -> bool:
426
+ with self.lock:
427
+ return self.can_start_unlocked()
428
+
429
+ def start(self) -> tuple[bool, str]:
430
+ with self.lock:
431
+ if self.running:
432
+ return True, "PLC läuft bereits."
433
+ if not self.can_start_unlocked():
434
+ return False, "Start nicht möglich: Kein ST-Projekt und kein Connector importiert."
435
+ if self.imported_st_project and not self.compiled_program:
436
+ return False, "Start nicht möglich: ST-Projekt ist nicht kompiliert."
437
+ # Statistik bei jedem PLC-Start bewusst zurücksetzen, damit alte
438
+ # Offline-/Timeout-Werte die aktuelle Anzeige nicht verfälschen.
439
+ self.cycle_count = 0
440
+ self.last_cycle_ms = 0.0
441
+ self.last_stats_sync_cycle = 0
442
+ self.last_stats_sync_time = None
443
+ self.phase_stats = {phase: PhaseStat(window_size=self.stats_window_size) for phase in PHASES}
444
+ if self.connector_runtime is not None and hasattr(self.connector_runtime, "force_next_write"):
445
+ # Beim Start Ausgänge einmal aktiv synchronisieren, auch wenn sich ST-Werte noch nicht geändert haben.
446
+ self.connector_runtime.force_next_write = True
447
+ self.running = True
448
+ self.last_error = None
449
+ self.stop_event.clear()
450
+ self._publish_snapshot_unlocked()
451
+ self.thread = threading.Thread(target=self._run_loop, daemon=True, name="ME-vPLC-Cycle")
452
+ self.thread.start()
453
+ return True, "PLC gestartet."
454
+
455
+ def stop(self) -> None:
456
+ with self.lock:
457
+ self.running = False
458
+ self.stop_event.set()
459
+ self._publish_snapshot_unlocked()
460
+
461
+ def set_cycle_time(self, value_ms: int) -> None:
462
+ value_ms = max(1, min(60000, int(value_ms)))
463
+ with self.lock:
464
+ self.auto_cycle_enabled = False
465
+ self.cycle_time_ms = value_ms
466
+ self._publish_snapshot_unlocked()
467
+
468
+ def set_auto_cycle(self, enabled: bool) -> None:
469
+ with self.lock:
470
+ self.auto_cycle_enabled = bool(enabled)
471
+ if self.auto_cycle_enabled:
472
+ self.cycle_time_ms = self._suggested_cycle_time_unlocked()
473
+ self._publish_snapshot_unlocked()
474
+
475
+ def reset_stats(self) -> None:
476
+ with self.lock:
477
+ self.cycle_count = 0
478
+ self.last_cycle_ms = 0.0
479
+ self.last_stats_sync_cycle = 0
480
+ self.last_stats_sync_time = None
481
+ self.phase_stats = {phase: PhaseStat(window_size=self.stats_window_size) for phase in PHASES}
482
+ self._publish_snapshot_unlocked()
483
+
484
+ def _write_compiler_log_file(self, filename: str, entries: list) -> None:
485
+ header = f"\n===== {time.strftime('%Y-%m-%d %H:%M:%S')} | {filename} =====\n"
486
+ lines = [header]
487
+ for entry in entries:
488
+ details = entry.get("details")
489
+ suffix = f" | {json.dumps(details, ensure_ascii=False)}" if details else ""
490
+ lines.append(f"[{entry.get('time', '-')}] {entry.get('level', 'INFO')}: {entry.get('message', '')}{suffix}\n")
491
+ COMPILER_LOG_FILE.parent.mkdir(exist_ok=True)
492
+ with COMPILER_LOG_FILE.open("a", encoding="utf-8") as handle:
493
+ handle.writelines(lines)
494
+
495
+ def import_st_project(self, path: Path, filename: str) -> None:
496
+ # Import/Compile außerhalb des Runtime-Zyklus. ST wird einmal kompiliert und im Zyklus nur ausgeführt.
497
+ try:
498
+ program = STCompiler.compile_path(path)
499
+ compiler_log = list(getattr(program.compiler, "compile_log", []))
500
+ self._write_compiler_log_file(filename, compiler_log)
501
+ except Exception as exc:
502
+ compiler_log = list(getattr(exc, "compile_log", []))
503
+ if compiler_log:
504
+ self._write_compiler_log_file(filename, compiler_log)
505
+ with self.lock:
506
+ self.imported_st_project = filename
507
+ self.compiled_program = None
508
+ self.st_compile_ok = False
509
+ self.st_compile_message = f"ST Compile Fehler: {exc}"
510
+ self.compiler_log = compiler_log or [{
511
+ "time": time.strftime("%H:%M:%S"),
512
+ "level": "ERROR",
513
+ "message": str(exc),
514
+ }]
515
+ self._publish_snapshot_unlocked()
516
+ raise
517
+ with self.lock:
518
+ self.imported_st_project = filename
519
+ self.compiled_program = program
520
+ self.st_compile_ok = True
521
+ self.st_compiled_project = self.active_project
522
+ self.st_compiled_file = filename
523
+ self.st_compiled_at = time.strftime("%Y-%m-%d %H:%M:%S")
524
+ self.compiler_log = compiler_log
525
+ self.st_compile_message = (
526
+ f"IEC 61131-3 ST kompiliert: PROGRAM {program.name}, "
527
+ f"{len(program.variables)} Variable(n), {len(program.statements)} Anweisung(en). "
528
+ f"Projekt: {self.active_project}. Optimierte Runtime aktiv."
529
+ )
530
+ self._publish_snapshot_unlocked()
531
+
532
+ def import_connector(self, path: Path, filename: str) -> None:
533
+ connector_data = self._load_connector_file(path)
534
+ with self.lock:
535
+ if self.connector_runtime is not None and hasattr(self.connector_runtime, "shutdown"):
536
+ try:
537
+ self.connector_runtime.shutdown()
538
+ except Exception:
539
+ pass
540
+ self.imported_connector = filename
541
+ self.connector_data = connector_data
542
+ self.connector_runtime = create_connector(connector_data)
543
+ self._publish_snapshot_unlocked()
544
+
545
+ def _measure_read(self, connector_runtime: Any, program: Optional[STProgram]) -> float:
546
+ if connector_runtime is None or program is None:
547
+ return 0.0
548
+ start = time.perf_counter()
549
+ connector_runtime.read_cycle(program)
550
+ return (time.perf_counter() - start) * 1000.0
551
+
552
+ def _measure_execute(self, program: Optional[STProgram]) -> float:
553
+ if program is None:
554
+ return 0.0
555
+ start = time.perf_counter()
556
+ program.execute()
557
+ return (time.perf_counter() - start) * 1000.0
558
+
559
+ def _measure_write(self, connector_runtime: Any, program: Optional[STProgram]) -> float:
560
+ if connector_runtime is None or program is None:
561
+ return 0.0
562
+ start = time.perf_counter()
563
+ connector_runtime.write_cycle(program)
564
+ return (time.perf_counter() - start) * 1000.0
565
+
566
+ def _run_loop(self) -> None:
567
+ while not self.stop_event.is_set():
568
+ cycle_start = time.perf_counter()
569
+ with self.lock:
570
+ connector_runtime = self.connector_runtime
571
+ program = self.compiled_program
572
+ target_ms = self.cycle_time_ms
573
+
574
+ try:
575
+ read_ms = self._measure_read(connector_runtime, program)
576
+ execute_ms = self._measure_execute(program)
577
+ write_ms = self._measure_write(connector_runtime, program)
578
+
579
+ elapsed_before_sync_ms = (time.perf_counter() - cycle_start) * 1000.0
580
+ requested_sync_ms = max(0.0, target_ms - elapsed_before_sync_ms)
581
+ sync_start = time.perf_counter()
582
+ if requested_sync_ms > 0:
583
+ time.sleep(requested_sync_ms / 1000.0)
584
+ sync_ms = (time.perf_counter() - sync_start) * 1000.0
585
+
586
+ cycle_elapsed_ms = (time.perf_counter() - cycle_start) * 1000.0
587
+ with self.lock:
588
+ self.phase_stats["READ"].update(read_ms)
589
+ self.phase_stats["EXECUTE"].update(execute_ms)
590
+ self.phase_stats["WRITE"].update(write_ms)
591
+ self.phase_stats["SYNC"].update(sync_ms)
592
+ next_cycle = self.cycle_count + 1
593
+ if next_cycle % self.stats_window_size == 0:
594
+ for stat in self.phase_stats.values():
595
+ stat.sync_window()
596
+ self.last_stats_sync_cycle = next_cycle
597
+ self.last_stats_sync_time = time.strftime("%H:%M:%S")
598
+ self.cycle_count = next_cycle
599
+ self.last_cycle_ms = round(cycle_elapsed_ms, 3)
600
+ if self.auto_cycle_enabled and self.cycle_count >= 5:
601
+ self.cycle_time_ms = self._suggested_cycle_time_unlocked()
602
+ self._publish_snapshot_unlocked()
603
+ except Exception as exc:
604
+ with self.lock:
605
+ self.last_error = str(exc)
606
+ self.compiler_log.append({
607
+ "time": time.strftime("%H:%M:%S"),
608
+ "level": "ERROR",
609
+ "message": "Runtime-Fehler im PLC-Zyklus",
610
+ "details": {"error": str(exc), "cycle": self.cycle_count},
611
+ })
612
+ self.running = False
613
+ self.stop_event.set()
614
+ self._publish_snapshot_unlocked()
615
+
616
+ def status(self) -> Dict[str, Any]:
617
+ with self.lock:
618
+ return copy.deepcopy(self._snapshot)
619
+
620
+
621
+ plc = MEVPLC()
622
+ app = Flask(__name__)
623
+ CORS(app)
624
+
625
+
626
+ def _reject_when_running(action: str):
627
+ payload = plc.status()
628
+ if payload.get("running"):
629
+ payload["error"] = f"{action} ist nur bei PLC STOP möglich."
630
+ return jsonify(payload), 409
631
+ return None
632
+
633
+
634
+ @app.get("/api/status")
635
+ def api_status():
636
+ return jsonify(plc.status())
637
+
638
+
639
+ @app.post("/api/start")
640
+ def api_start():
641
+ ok, message = plc.start()
642
+ payload = plc.status()
643
+ payload["message"] = message
644
+ if not ok:
645
+ return jsonify(payload), 400
646
+ return jsonify(payload)
647
+
648
+
649
+ @app.post("/api/stop")
650
+ def api_stop():
651
+ plc.stop()
652
+ return jsonify(plc.status())
653
+
654
+
655
+ @app.post("/api/cycle-time")
656
+ def api_cycle_time():
657
+ blocked = _reject_when_running("PLC Cycle Time ändern")
658
+ if blocked:
659
+ return blocked
660
+ data = request.get_json(force=True, silent=True) or {}
661
+ plc.set_cycle_time(int(data.get("cycle_time_ms", 200)))
662
+ return jsonify(plc.status())
663
+
664
+
665
+ @app.post("/api/reset-stats")
666
+ def api_reset_stats():
667
+ blocked = _reject_when_running("Statistik zurücksetzen")
668
+ if blocked:
669
+ return blocked
670
+ plc.reset_stats()
671
+ return jsonify(plc.status())
672
+
673
+
674
+ @app.post("/api/auto-cycle")
675
+ def api_auto_cycle():
676
+ blocked = _reject_when_running("Auto-Zyklus ändern")
677
+ if blocked:
678
+ return blocked
679
+ data = request.get_json(force=True, silent=True) or {}
680
+ plc.set_auto_cycle(bool(data.get("enabled", False)))
681
+ return jsonify(plc.status())
682
+
683
+
684
+ @app.post("/api/import/st-project")
685
+ def api_import_st_project():
686
+ blocked = _reject_when_running("ST-Projekt importieren")
687
+ if blocked:
688
+ return blocked
689
+ file = request.files.get("file")
690
+ if not file:
691
+ return jsonify({"error": "Keine Datei hochgeladen."}), 400
692
+ with plc.lock:
693
+ target_dir = plc._active_st_dir_unlocked()
694
+ for existing in target_dir.iterdir():
695
+ if existing.is_file():
696
+ existing.unlink()
697
+ target = target_dir / file.filename
698
+ file.save(target)
699
+ try:
700
+ plc.import_st_project(target, file.filename)
701
+ except Exception as exc:
702
+ return jsonify({"error": f"ST Import/Compile Fehler: {exc}"}), 400
703
+ return jsonify(plc.status())
704
+
705
+
706
+ @app.post("/api/import/connector")
707
+ def api_import_connector():
708
+ blocked = _reject_when_running("PLC Connector importieren")
709
+ if blocked:
710
+ return blocked
711
+ file = request.files.get("file")
712
+ if not file:
713
+ return jsonify({"error": "Keine Datei hochgeladen."}), 400
714
+ with plc.lock:
715
+ target_dir = plc._active_connector_dir_unlocked()
716
+ for existing in target_dir.iterdir():
717
+ if existing.is_file():
718
+ existing.unlink()
719
+ target = target_dir / file.filename
720
+ file.save(target)
721
+ try:
722
+ plc.import_connector(target, file.filename)
723
+ except Exception as exc:
724
+ return jsonify({"error": f"Connector Import Fehler: {exc}"}), 400
725
+ return jsonify(plc.status())
726
+
727
+
728
+ @app.get("/api/editor/st-project")
729
+ def api_editor_get_st_project():
730
+ with plc.lock:
731
+ if plc.running:
732
+ return jsonify({"error": "ST-Projekt kann nur bei PLC STOP editiert werden."}), 409
733
+ filename = plc.imported_st_project
734
+ if not filename:
735
+ return jsonify({"error": "Kein ST-Projekt importiert."}), 404
736
+ try:
737
+ path = plc._active_st_dir_unlocked() / filename
738
+ files = _read_editable_files(path)
739
+ except Exception as exc:
740
+ return jsonify({"error": str(exc)}), 400
741
+ return jsonify({"kind": "st-project", "filename": filename, "files": files})
742
+
743
+
744
+ @app.post("/api/editor/st-project")
745
+ def api_editor_save_st_project():
746
+ with plc.lock:
747
+ if plc.running:
748
+ return jsonify({"error": "ST-Projekt kann nur bei PLC STOP gespeichert werden."}), 409
749
+ filename = plc.imported_st_project
750
+ if not filename:
751
+ return jsonify({"error": "Kein ST-Projekt importiert."}), 404
752
+ data = request.get_json(force=True, silent=True) or {}
753
+ with plc.lock:
754
+ target = plc._active_st_dir_unlocked() / filename
755
+ try:
756
+ _write_editable_files(target, data.get("files", []))
757
+ plc.import_st_project(target, filename)
758
+ except Exception as exc:
759
+ return jsonify({"error": f"ST-Projekt konnte nicht gespeichert/kompiliert werden: {exc}"}), 400
760
+ return jsonify(plc.status())
761
+
762
+
763
+ @app.get("/api/editor/connector")
764
+ def api_editor_get_connector():
765
+ with plc.lock:
766
+ if plc.running:
767
+ return jsonify({"error": "PLC Connector kann nur bei PLC STOP editiert werden."}), 409
768
+ filename = plc.imported_connector
769
+ if not filename:
770
+ return jsonify({"error": "Kein PLC Connector importiert."}), 404
771
+ try:
772
+ path = plc._active_connector_dir_unlocked() / filename
773
+ files = _read_editable_files(path)
774
+ except Exception as exc:
775
+ return jsonify({"error": str(exc)}), 400
776
+ return jsonify({"kind": "connector", "filename": filename, "files": files})
777
+
778
+
779
+ @app.post("/api/editor/connector")
780
+ def api_editor_save_connector():
781
+ with plc.lock:
782
+ if plc.running:
783
+ return jsonify({"error": "PLC Connector kann nur bei PLC STOP gespeichert werden."}), 409
784
+ filename = plc.imported_connector
785
+ if not filename:
786
+ return jsonify({"error": "Kein PLC Connector importiert."}), 404
787
+ data = request.get_json(force=True, silent=True) or {}
788
+ with plc.lock:
789
+ target = plc._active_connector_dir_unlocked() / filename
790
+ try:
791
+ _write_editable_files(target, data.get("files", []))
792
+ plc.import_connector(target, filename)
793
+ except Exception as exc:
794
+ return jsonify({"error": f"PLC Connector konnte nicht gespeichert werden: {exc}"}), 400
795
+ return jsonify(plc.status())
796
+
797
+
798
+ @app.get("/api/compiler-log")
799
+ def api_compiler_log():
800
+ return jsonify({"compiler_log": plc.status().get("compiler_log", [])})
801
+
802
+
803
+ @app.get("/api/projects")
804
+ def api_projects():
805
+ payload = plc.status()
806
+ return jsonify({
807
+ "active_project": payload.get("active_project"),
808
+ "projects": payload.get("projects", []),
809
+ })
810
+
811
+
812
+ @app.post("/api/projects/select")
813
+ def api_select_project():
814
+ data = request.get_json(force=True, silent=True) or {}
815
+ ok, message = plc.select_project(data.get("project", ""))
816
+ payload = plc.status()
817
+ payload["message"] = message
818
+ if not ok:
819
+ return jsonify(payload), 400
820
+ return jsonify(payload)
821
+
822
+
823
+ @app.get("/api/imported")
824
+ def api_imported():
825
+ with plc.lock:
826
+ st_dir = plc._active_st_dir_unlocked()
827
+ connector_dir = plc._active_connector_dir_unlocked()
828
+ return jsonify({
829
+ "active_project": plc.status().get("active_project"),
830
+ "projects": plc.status().get("projects", []),
831
+ "st_project_files": sorted([p.name for p in st_dir.iterdir() if p.is_file()]),
832
+ "connector_files": sorted([p.name for p in connector_dir.iterdir() if p.is_file()]),
833
+ })
834
+
835
+
836
+ if __name__ == "__main__":
837
+ # Debug bewusst deaktiviert, damit Flask-ReLoader/Trace den PLC-Zyklus nicht verfälscht.
838
+ import os
839
+ app.run(host="0.0.0.0", port=int(os.environ.get("ME_VPLC_PORT", "5000")), debug=False, threaded=True)