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,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')}")
|