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,585 @@
1
+ """Connector-Runtime für ME vPLC.
2
+
3
+ Aktuell umgesetzt: Modbus TCP Connector gemäß JSON-Struktur aus example_connector.zip.
4
+ - READ-Variablen werden vor EXECUTE in das ST-Prozessabbild geschrieben.
5
+ - WRITE-Variablen werden nach EXECUTE aus dem ST-Prozessabbild gelesen und an Modbus geschrieben.
6
+ - Modbus-Requests werden nach Registertyp und zusammenhängenden Adressen gebündelt.
7
+ - Coils werden nur geschrieben, wenn sich Werte geändert haben.
8
+ - Bei Kommunikationsfehlern wird nicht der PLC-Zyklus beendet; der Connector geht offline und versucht nach Backoff erneut zu verbinden.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import struct
15
+ import threading
16
+ import time
17
+ from dataclasses import dataclass, field, asdict
18
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
19
+
20
+ try: # pymodbus >= 3.x
21
+ from pymodbus.client import ModbusTcpClient # type: ignore
22
+ except Exception: # pragma: no cover - fallback für ältere pymodbus-Versionen
23
+ try:
24
+ from pymodbus.client.sync import ModbusTcpClient # type: ignore
25
+ except Exception: # pragma: no cover
26
+ ModbusTcpClient = None # type: ignore
27
+
28
+
29
+ class ConnectorError(RuntimeError):
30
+ pass
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ConnectorVariable:
35
+ name: str
36
+ direction: str
37
+ register_type: str
38
+ data_type: str
39
+ address: int
40
+ word_count: int = 1
41
+
42
+
43
+ @dataclass
44
+ class ConnectorStatus:
45
+ configured: bool = False
46
+ type: str = "-"
47
+ name: str = "-"
48
+ endpoint: str = "-"
49
+ connected: bool = False
50
+ mode: str = "-"
51
+ read_variables: int = 0
52
+ write_variables: int = 0
53
+ read_requests: int = 0 # letzter Zyklus
54
+ write_requests: int = 0 # letzter Zyklus
55
+ skipped_writes: int = 0 # letzter Zyklus
56
+ total_read_requests: int = 0
57
+ total_write_requests: int = 0
58
+ total_skipped_writes: int = 0
59
+ last_write_changed: bool = False
60
+ last_write_reason: str = "-"
61
+ write_resync_ms: int = 1000
62
+ error_count: int = 0
63
+ last_error: Optional[str] = None
64
+ last_read_ms: float = 0.0
65
+ last_write_ms: float = 0.0
66
+
67
+ def to_dict(self) -> Dict[str, Any]:
68
+ data = asdict(self)
69
+ data["last_read_ms"] = round(float(self.last_read_ms), 3)
70
+ data["last_write_ms"] = round(float(self.last_write_ms), 3)
71
+ return data
72
+
73
+
74
+ class STPathAccessor:
75
+ """Löst Connector-Namen wie CSC_BCM_BatteryTemp1 auf ST-Pfade wie CSC.BCM.BatteryTemp1 auf."""
76
+
77
+ @staticmethod
78
+ def _keys(obj: Any) -> Optional[Dict[str, Any]]:
79
+ if isinstance(obj, dict):
80
+ return obj
81
+ vars_dict = getattr(obj, "_vars", None)
82
+ if isinstance(vars_dict, dict):
83
+ return vars_dict
84
+ # STStruct aus dem ST-Compiler speichert seine Felder in _values.
85
+ # Damit können Connector-Pfade wie CSC_EFuseControl_CTL1 auch auf
86
+ # reine PROGRAM/TYPE-Strukturen ohne FunctionBlocks aufgelöst werden.
87
+ values_dict = getattr(obj, "_values", None)
88
+ if isinstance(values_dict, dict):
89
+ return values_dict
90
+ return None
91
+
92
+ @staticmethod
93
+ def _find_key(obj: Any, key: str) -> Optional[str]:
94
+ data = STPathAccessor._keys(obj)
95
+ if data is None:
96
+ return None
97
+ key_upper = key.upper()
98
+ for existing in data.keys():
99
+ if existing.upper() == key_upper:
100
+ return existing
101
+ return None
102
+
103
+ @staticmethod
104
+ def _get_child(obj: Any, key: str) -> Any:
105
+ data = STPathAccessor._keys(obj)
106
+ real_key = STPathAccessor._find_key(obj, key)
107
+ if data is not None and real_key is not None:
108
+ return data[real_key]
109
+ return getattr(obj, key)
110
+
111
+ @staticmethod
112
+ def _set_child(obj: Any, key: str, value: Any) -> None:
113
+ data = STPathAccessor._keys(obj)
114
+ real_key = STPathAccessor._find_key(obj, key)
115
+ if data is not None:
116
+ data[real_key or key] = value
117
+ # Falls das Objekt eine vorbereitete ST-Runtime-Umgebung besitzt, synchron halten.
118
+ runtime = getattr(obj, "_runtime", None)
119
+ env = getattr(runtime, "env", None)
120
+ if isinstance(env, dict):
121
+ env[real_key or key] = value
122
+ env[(real_key or key).upper()] = value
123
+ return
124
+ setattr(obj, real_key or key, value)
125
+
126
+ @staticmethod
127
+ def _resolve_parent(program: Any, flat_name: str) -> Tuple[Any, str]:
128
+ root = getattr(program, "variables", program)
129
+ parts = [p for p in flat_name.split("_") if p]
130
+ if not parts:
131
+ raise ConnectorError(f"Leerer Connector-Variablenname: {flat_name}")
132
+ current: Any = root
133
+ index = 0
134
+ while index < len(parts) - 1:
135
+ # Greedy: unterstützt auch ST-Namen mit Unterstrichen.
136
+ found = None
137
+ found_end = None
138
+ for end in range(len(parts), index, -1):
139
+ candidate = "_".join(parts[index:end])
140
+ if STPathAccessor._find_key(current, candidate) is not None:
141
+ found = candidate
142
+ found_end = end
143
+ break
144
+ if found is None or found_end is None:
145
+ raise ConnectorError(f"ST-Pfad nicht gefunden für Connector-Variable '{flat_name}' bei '{'_'.join(parts[index:])}'.")
146
+ current = STPathAccessor._get_child(current, found)
147
+ index = found_end
148
+ return current, parts[-1]
149
+
150
+ @staticmethod
151
+ def set_value(program: Any, flat_name: str, value: Any) -> None:
152
+ parent, leaf = STPathAccessor._resolve_parent(program, flat_name)
153
+ STPathAccessor._set_child(parent, leaf, value)
154
+
155
+ @staticmethod
156
+ def get_value(program: Any, flat_name: str) -> Any:
157
+ parent, leaf = STPathAccessor._resolve_parent(program, flat_name)
158
+ return STPathAccessor._get_child(parent, leaf)
159
+
160
+
161
+ def _word_count(data_type: str) -> int:
162
+ dt = data_type.upper()
163
+ if dt in {"DINT", "UDINT", "DWORD", "REAL", "LREAL"}:
164
+ return 2
165
+ return 1
166
+
167
+
168
+ def _bool(value: Any) -> bool:
169
+ if isinstance(value, str):
170
+ return value.strip().upper() in {"1", "TRUE", "ON", "YES"}
171
+ return bool(value)
172
+
173
+
174
+ def _signed16(value: int) -> int:
175
+ value &= 0xFFFF
176
+ return value - 0x10000 if value & 0x8000 else value
177
+
178
+
179
+ def _signed32(value: int) -> int:
180
+ value &= 0xFFFFFFFF
181
+ return value - 0x100000000 if value & 0x80000000 else value
182
+
183
+
184
+ def _decode_registers(registers: Sequence[int], data_type: str, byte_order: str = "BigEndian", word_swap: bool = False) -> Any:
185
+ dt = data_type.upper()
186
+ regs = [int(r) & 0xFFFF for r in registers]
187
+ if word_swap and len(regs) >= 2:
188
+ regs = list(reversed(regs))
189
+ if dt in {"INT", "SINT"}:
190
+ return _signed16(regs[0])
191
+ if dt in {"UINT", "WORD", "BYTE"}:
192
+ return regs[0]
193
+ if dt in {"DINT"}:
194
+ return _signed32((regs[0] << 16) | regs[1])
195
+ if dt in {"UDINT", "DWORD"}:
196
+ return ((regs[0] << 16) | regs[1]) & 0xFFFFFFFF
197
+ if dt in {"REAL", "LREAL"}:
198
+ raw = struct.pack(">HH", regs[0], regs[1])
199
+ return float(struct.unpack(">f", raw)[0])
200
+ return regs[0]
201
+
202
+
203
+ def _encode_registers(value: Any, data_type: str, word_swap: bool = False) -> List[int]:
204
+ dt = data_type.upper()
205
+ if dt in {"INT", "UINT", "WORD", "BYTE", "SINT"}:
206
+ regs = [int(value) & 0xFFFF]
207
+ elif dt in {"DINT", "UDINT", "DWORD"}:
208
+ raw = int(value) & 0xFFFFFFFF
209
+ regs = [(raw >> 16) & 0xFFFF, raw & 0xFFFF]
210
+ elif dt in {"REAL", "LREAL"}:
211
+ raw = struct.pack(">f", float(value))
212
+ regs = list(struct.unpack(">HH", raw))
213
+ else:
214
+ regs = [int(value) & 0xFFFF]
215
+ if word_swap and len(regs) >= 2:
216
+ regs = list(reversed(regs))
217
+ return regs
218
+
219
+
220
+ def _make_variables(config: Dict[str, Any]) -> List[ConnectorVariable]:
221
+ result: List[ConnectorVariable] = []
222
+ for item in config.get("variables", []):
223
+ name = str(item.get("name", "")).strip()
224
+ if not name:
225
+ continue
226
+ data_type = str(item.get("dataType", "BOOL")).strip().upper()
227
+ result.append(
228
+ ConnectorVariable(
229
+ name=name,
230
+ direction=str(item.get("direction", "READ")).strip().upper(),
231
+ register_type=str(item.get("registerType", "Coil")).strip(),
232
+ data_type=data_type,
233
+ address=int(item.get("address", 0)),
234
+ word_count=_word_count(data_type),
235
+ )
236
+ )
237
+ return result
238
+
239
+
240
+ def _group_contiguous(variables: Iterable[ConnectorVariable]) -> List[List[ConnectorVariable]]:
241
+ ordered = sorted(variables, key=lambda item: item.address)
242
+ groups: List[List[ConnectorVariable]] = []
243
+ current: List[ConnectorVariable] = []
244
+ next_addr: Optional[int] = None
245
+ for var in ordered:
246
+ if not current or next_addr is None or var.address == next_addr:
247
+ current.append(var)
248
+ else:
249
+ groups.append(current)
250
+ current = [var]
251
+ next_addr = var.address + max(1, var.word_count)
252
+ if current:
253
+ groups.append(current)
254
+ return groups
255
+
256
+
257
+ class ModbusTCPConnector:
258
+ """Modbus TCP Connector mit entkoppeltem Worker.
259
+
260
+ Wichtiger Grundsatz:
261
+ - PLC READ/WRITE greifen nur auf lokale Caches zu und blockieren nicht.
262
+ - Alle echten Modbus/TCP-Zugriffe laufen ausschließlich im Worker-Thread.
263
+ """
264
+
265
+ def __init__(self, config: Dict[str, Any]) -> None:
266
+ self.config = config
267
+ self.ip = str(config.get("ip") or config.get("host") or "127.0.0.1")
268
+ self.port = int(config.get("port", 502))
269
+ self.timeout = max(0.05, float(config.get("requestTimeoutMs", 250)) / 1000.0)
270
+ self.poll_period_ms = max(10, int(config.get("pollPeriodMs", 100)))
271
+ unit = config.get("unit", {}) if isinstance(config.get("unit"), dict) else {}
272
+ self.unit_id = int(unit.get("address", config.get("unitId", 1)))
273
+ self.byte_order = str(unit.get("byteOrder", "BigEndian"))
274
+ self.word_swap = bool(unit.get("wordSwap", False))
275
+ self.mode = str(config.get("mode", "REAL_MODBUS_TCP"))
276
+ self.variables = _make_variables(config)
277
+ self.read_variables = [v for v in self.variables if v.direction == "READ"]
278
+ self.write_variables = [v for v in self.variables if v.direction == "WRITE"]
279
+ self.client: Any = None
280
+ self.last_write_values: Dict[Tuple[str, int], Any] = {}
281
+ self.force_next_write = True
282
+ self.write_resync_ms = int(config.get("writeResyncMs", config.get("writeResyncPeriodMs", 1000)))
283
+ self.last_write_resync_at = 0.0
284
+ self.next_retry_at = 0.0
285
+ self.offline_backoff_s = 1.0
286
+ self.max_offline_backoff_s = 10.0
287
+ self.status = ConnectorStatus(
288
+ configured=True,
289
+ type=str(config.get("type", "ModbusTCP")),
290
+ name=str(config.get("name", "ModbusTCP")),
291
+ endpoint=f"{self.ip}:{self.port}/unit {self.unit_id}",
292
+ mode=self.mode,
293
+ read_variables=len(self.read_variables),
294
+ write_variables=len(self.write_variables),
295
+ write_resync_ms=self.write_resync_ms,
296
+ )
297
+ self.lock = threading.RLock()
298
+ self.input_cache: Dict[str, Any] = {}
299
+ self.output_cache: Dict[str, Any] = {}
300
+ self.output_dirty = True
301
+ self.worker_read_requests = 0
302
+ self.worker_write_requests = 0
303
+ self.worker_skipped_writes = 0
304
+ self.worker_stop = threading.Event()
305
+ self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True, name="ME-vPLC-Modbus-Worker")
306
+ self.worker_thread.start()
307
+
308
+ def close(self) -> None:
309
+ if self.client is not None:
310
+ try:
311
+ self.client.close()
312
+ except Exception:
313
+ pass
314
+ self.client = None
315
+ with self.lock:
316
+ self.status.connected = False
317
+
318
+ def shutdown(self) -> None:
319
+ self.worker_stop.set()
320
+ self.close()
321
+
322
+ def _mark_error(self, message: str) -> None:
323
+ with self.lock:
324
+ self.status.error_count += 1
325
+ self.status.last_error = message
326
+ self.status.connected = False
327
+ self.force_next_write = True
328
+ self.next_retry_at = time.monotonic() + self.offline_backoff_s
329
+ self.offline_backoff_s = min(self.max_offline_backoff_s, self.offline_backoff_s * 2.0)
330
+ self.close()
331
+
332
+ def _ensure_connected(self) -> bool:
333
+ if ModbusTcpClient is None:
334
+ self._mark_error("pymodbus ist nicht installiert. Bitte Backend-Requirements installieren.")
335
+ return False
336
+ if str(self.mode).upper() in {"SIM", "SIMULATED", "OFFLINE"}:
337
+ with self.lock:
338
+ self.status.connected = False
339
+ return False
340
+ if self.client is not None:
341
+ try:
342
+ if getattr(self.client, "connected", True):
343
+ with self.lock:
344
+ self.status.connected = True
345
+ return True
346
+ except Exception:
347
+ return True
348
+ with self.lock:
349
+ if time.monotonic() < self.next_retry_at:
350
+ return False
351
+ try:
352
+ self.client = ModbusTcpClient(self.ip, port=self.port, timeout=self.timeout)
353
+ ok = bool(self.client.connect())
354
+ with self.lock:
355
+ self.status.connected = ok
356
+ if ok:
357
+ with self.lock:
358
+ self.offline_backoff_s = 1.0
359
+ self.status.last_error = None
360
+ self.force_next_write = True
361
+ return True
362
+ self._mark_error(f"Modbus-Verbindung fehlgeschlagen: {self.ip}:{self.port}")
363
+ return False
364
+ except Exception as exc:
365
+ self._mark_error(str(exc))
366
+ return False
367
+
368
+ def _call(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
369
+ if self.client is None:
370
+ raise ConnectorError("Modbus-Client ist nicht verbunden.")
371
+ method = getattr(self.client, method_name)
372
+ try:
373
+ return method(*args, slave=self.unit_id, **kwargs)
374
+ except TypeError:
375
+ return method(*args, unit=self.unit_id, **kwargs)
376
+
377
+ @staticmethod
378
+ def _response_error(response: Any) -> Optional[str]:
379
+ if response is None:
380
+ return "Keine Modbus-Antwort."
381
+ is_error = getattr(response, "isError", None)
382
+ if callable(is_error) and is_error():
383
+ return str(response)
384
+ return None
385
+
386
+ def read_cycle(self, program: Any) -> None:
387
+ """PLC READ-Phase: nur Cache in das ST-Prozessabbild kopieren."""
388
+ start = time.perf_counter()
389
+ with self.lock:
390
+ cache = dict(self.input_cache)
391
+ for name, value in cache.items():
392
+ try:
393
+ STPathAccessor.set_value(program, name, value)
394
+ except Exception:
395
+ # Mappingfehler dürfen den PLC-Zyklus nicht blockieren.
396
+ pass
397
+ with self.lock:
398
+ # PLC-Phase zeigt bewusst keine Modbus-Requests; diese laufen im Worker.
399
+ self.status.last_read_ms = (time.perf_counter() - start) * 1000.0
400
+
401
+ def write_cycle(self, program: Any) -> None:
402
+ """PLC WRITE-Phase: nur ST-Ausgänge in den Ausgangs-Cache übernehmen."""
403
+ start = time.perf_counter()
404
+ next_outputs: Dict[str, Any] = {}
405
+ for var in self.write_variables:
406
+ try:
407
+ next_outputs[var.name] = STPathAccessor.get_value(program, var.name)
408
+ except Exception:
409
+ pass
410
+ with self.lock:
411
+ if next_outputs != self.output_cache:
412
+ self.output_cache = next_outputs
413
+ self.output_dirty = True
414
+ self.status.last_write_ms = (time.perf_counter() - start) * 1000.0
415
+
416
+ def _worker_loop(self) -> None:
417
+ while not self.worker_stop.is_set():
418
+ loop_start = time.perf_counter()
419
+ if self._ensure_connected():
420
+ self._worker_read()
421
+ self._worker_write()
422
+ elapsed_ms = (time.perf_counter() - loop_start) * 1000.0
423
+ sleep_ms = max(10.0, float(self.poll_period_ms) - elapsed_ms)
424
+ self.worker_stop.wait(sleep_ms / 1000.0)
425
+
426
+ def _worker_read(self) -> None:
427
+ start = time.perf_counter()
428
+ local_cache: Dict[str, Any] = {}
429
+ read_requests = 0
430
+ if not self.read_variables:
431
+ with self.lock:
432
+ self.status.read_requests = 0
433
+ return
434
+ try:
435
+ by_type: Dict[str, List[ConnectorVariable]] = {}
436
+ for var in self.read_variables:
437
+ by_type.setdefault(var.register_type.lower(), []).append(var)
438
+ for reg_type, vars_of_type in by_type.items():
439
+ for group in _group_contiguous(vars_of_type):
440
+ first = group[0].address
441
+ last = max(v.address + max(1, v.word_count) for v in group)
442
+ count = last - first
443
+ if reg_type == "discreteinput":
444
+ response = self._call("read_discrete_inputs", first, count)
445
+ err = self._response_error(response)
446
+ if err:
447
+ raise ConnectorError(err)
448
+ bits = list(getattr(response, "bits", []))
449
+ for var in group:
450
+ local_cache[var.name] = bool(bits[var.address - first])
451
+ elif reg_type == "coil":
452
+ response = self._call("read_coils", first, count)
453
+ err = self._response_error(response)
454
+ if err:
455
+ raise ConnectorError(err)
456
+ bits = list(getattr(response, "bits", []))
457
+ for var in group:
458
+ local_cache[var.name] = bool(bits[var.address - first])
459
+ elif reg_type == "inputregister":
460
+ response = self._call("read_input_registers", first, count)
461
+ err = self._response_error(response)
462
+ if err:
463
+ raise ConnectorError(err)
464
+ regs = list(getattr(response, "registers", []))
465
+ for var in group:
466
+ offset = var.address - first
467
+ local_cache[var.name] = _decode_registers(regs[offset:offset + var.word_count], var.data_type, self.byte_order, self.word_swap)
468
+ elif reg_type == "holdingregister":
469
+ response = self._call("read_holding_registers", first, count)
470
+ err = self._response_error(response)
471
+ if err:
472
+ raise ConnectorError(err)
473
+ regs = list(getattr(response, "registers", []))
474
+ for var in group:
475
+ offset = var.address - first
476
+ local_cache[var.name] = _decode_registers(regs[offset:offset + var.word_count], var.data_type, self.byte_order, self.word_swap)
477
+ else:
478
+ raise ConnectorError(f"Nicht unterstützter Registertyp für READ: {reg_type}")
479
+ read_requests += 1
480
+ with self.lock:
481
+ self.input_cache.update(local_cache)
482
+ self.status.read_requests = read_requests
483
+ self.status.total_read_requests += read_requests
484
+ self.status.last_read_ms = (time.perf_counter() - start) * 1000.0
485
+ self.status.connected = True
486
+ self.status.last_error = None
487
+ except Exception as exc:
488
+ self._mark_error(str(exc))
489
+ with self.lock:
490
+ self.status.last_read_ms = (time.perf_counter() - start) * 1000.0
491
+
492
+ def _worker_write(self) -> None:
493
+ start = time.perf_counter()
494
+ with self.lock:
495
+ outputs = dict(self.output_cache)
496
+ dirty = bool(self.output_dirty or self.force_next_write)
497
+ write_requests = 0
498
+ skipped_writes = 0
499
+ if not self.write_variables:
500
+ return
501
+ try:
502
+ now = time.monotonic()
503
+ due_resync = False
504
+ if self.write_resync_ms > 0:
505
+ due_resync = (now - self.last_write_resync_at) * 1000.0 >= self.write_resync_ms
506
+ force_write = dirty or due_resync
507
+ last_write_changed = False
508
+ last_write_reason = "-"
509
+
510
+ by_type: Dict[str, List[ConnectorVariable]] = {}
511
+ for var in self.write_variables:
512
+ by_type.setdefault(var.register_type.lower(), []).append(var)
513
+ for reg_type, vars_of_type in by_type.items():
514
+ for group in _group_contiguous(vars_of_type):
515
+ first = group[0].address
516
+ if reg_type == "coil":
517
+ values = [_bool(outputs.get(var.name, False)) for var in group]
518
+ key = ("coil", first)
519
+ changed = self.last_write_values.get(key) != values
520
+ if not changed and not force_write:
521
+ skipped_writes += 1
522
+ continue
523
+ response = self._call("write_coils", first, values)
524
+ err = self._response_error(response)
525
+ if err:
526
+ raise ConnectorError(err)
527
+ self.last_write_values[key] = list(values)
528
+ last_write_changed = changed
529
+ last_write_reason = "changed" if changed else "resync"
530
+ elif reg_type == "holdingregister":
531
+ regs: List[int] = []
532
+ for var in group:
533
+ regs.extend(_encode_registers(outputs.get(var.name, 0), var.data_type, self.word_swap))
534
+ key = ("holdingregister", first)
535
+ changed = self.last_write_values.get(key) != regs
536
+ if not changed and not force_write:
537
+ skipped_writes += 1
538
+ continue
539
+ response = self._call("write_registers", first, regs)
540
+ err = self._response_error(response)
541
+ if err:
542
+ raise ConnectorError(err)
543
+ self.last_write_values[key] = list(regs)
544
+ last_write_changed = changed
545
+ last_write_reason = "changed" if changed else "resync"
546
+ else:
547
+ raise ConnectorError(f"Nicht unterstützter Registertyp für WRITE: {reg_type}")
548
+ write_requests += 1
549
+ with self.lock:
550
+ self.status.write_requests = write_requests
551
+ self.status.skipped_writes = skipped_writes
552
+ self.status.total_write_requests += write_requests
553
+ self.status.total_skipped_writes += skipped_writes
554
+ self.status.last_write_changed = last_write_changed
555
+ self.status.last_write_reason = last_write_reason
556
+ self.status.last_write_ms = (time.perf_counter() - start) * 1000.0
557
+ self.status.connected = True
558
+ self.status.last_error = None
559
+ if force_write:
560
+ self.force_next_write = False
561
+ self.output_dirty = False
562
+ self.last_write_resync_at = now
563
+ except Exception as exc:
564
+ self._mark_error(str(exc))
565
+ with self.lock:
566
+ self.status.last_write_ms = (time.perf_counter() - start) * 1000.0
567
+
568
+ def to_dict(self) -> Dict[str, Any]:
569
+ with self.lock:
570
+ data = self.status.to_dict()
571
+ data["worker_decoupled"] = True
572
+ data["poll_period_ms"] = self.poll_period_ms
573
+ data["input_cache_values"] = len(self.input_cache)
574
+ data["output_cache_values"] = len(self.output_cache)
575
+ retry_in = max(0.0, self.next_retry_at - time.monotonic())
576
+ data["retry_in_s"] = round(retry_in, 1)
577
+ return data
578
+
579
+ def create_connector(config: Dict[str, Any]) -> Optional[ModbusTCPConnector]:
580
+ if not isinstance(config, dict) or not config:
581
+ return None
582
+ connector_type = str(config.get("type", "")).strip().lower()
583
+ if connector_type in {"modbustcp", "modbus_tcp", "modbus-tcp", ""}:
584
+ return ModbusTCPConnector(config)
585
+ raise ConnectorError(f"Connector-Typ nicht unterstützt: {config.get('type')}")
@@ -0,0 +1,3 @@
1
+ Flask==3.0.3
2
+ flask-cors==4.0.1
3
+ pymodbus==3.6.9