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
|
@@ -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)
|