plexus-python 0.1.0__py3-none-any.whl
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.
- plexus/__init__.py +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modbus Adapter - Modbus TCP/RTU protocol support for industrial devices
|
|
3
|
+
|
|
4
|
+
This adapter reads Modbus registers (holding, input, coil, discrete) from
|
|
5
|
+
a Modbus slave device and emits scaled, typed metric values.
|
|
6
|
+
|
|
7
|
+
Requirements:
|
|
8
|
+
pip install plexus-python[modbus]
|
|
9
|
+
# or
|
|
10
|
+
pip install pymodbus
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from plexus.adapters import ModbusAdapter
|
|
14
|
+
|
|
15
|
+
# TCP connection to a PLC
|
|
16
|
+
adapter = ModbusAdapter(
|
|
17
|
+
host="192.168.1.100",
|
|
18
|
+
port=502,
|
|
19
|
+
unit_id=1,
|
|
20
|
+
registers=[
|
|
21
|
+
{"address": 0, "name": "temperature", "type": "holding",
|
|
22
|
+
"data_type": "float32", "scale": 0.1, "unit": "°C"},
|
|
23
|
+
{"address": 2, "name": "pressure", "type": "input",
|
|
24
|
+
"data_type": "uint16", "scale": 0.01, "unit": "bar"},
|
|
25
|
+
{"address": 10, "name": "pump_running", "type": "coil"},
|
|
26
|
+
],
|
|
27
|
+
poll_interval=1.0,
|
|
28
|
+
)
|
|
29
|
+
adapter.connect()
|
|
30
|
+
for metric in adapter.poll():
|
|
31
|
+
print(f"{metric.name}: {metric.value}")
|
|
32
|
+
|
|
33
|
+
# RTU connection over serial
|
|
34
|
+
adapter = ModbusAdapter(
|
|
35
|
+
host="/dev/ttyUSB0",
|
|
36
|
+
mode="rtu",
|
|
37
|
+
baudrate=9600,
|
|
38
|
+
unit_id=1,
|
|
39
|
+
registers=[
|
|
40
|
+
{"address": 100, "name": "flow_rate", "type": "holding",
|
|
41
|
+
"data_type": "int32", "scale": 0.001, "unit": "m³/h"},
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
Supported register types:
|
|
46
|
+
- holding: Holding registers (function code 3)
|
|
47
|
+
- input: Input registers (function code 4)
|
|
48
|
+
- coil: Coils / discrete outputs (function code 1)
|
|
49
|
+
- discrete: Discrete inputs (function code 2)
|
|
50
|
+
|
|
51
|
+
Supported data types (for holding/input registers):
|
|
52
|
+
- uint16: Unsigned 16-bit integer (1 register)
|
|
53
|
+
- int16: Signed 16-bit integer (1 register)
|
|
54
|
+
- uint32: Unsigned 32-bit integer (2 registers)
|
|
55
|
+
- int32: Signed 32-bit integer (2 registers)
|
|
56
|
+
- float32: IEEE 754 32-bit float (2 registers)
|
|
57
|
+
|
|
58
|
+
Emitted metrics:
|
|
59
|
+
- {prefix}{name} - Scaled register value (e.g., "modbus.temperature")
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from typing import Any, Dict, List, Optional
|
|
63
|
+
import struct
|
|
64
|
+
import time
|
|
65
|
+
import logging
|
|
66
|
+
|
|
67
|
+
from plexus.adapters.base import (
|
|
68
|
+
ProtocolAdapter,
|
|
69
|
+
AdapterConfig,
|
|
70
|
+
AdapterState,
|
|
71
|
+
Metric,
|
|
72
|
+
ConnectionError,
|
|
73
|
+
ProtocolError,
|
|
74
|
+
)
|
|
75
|
+
from plexus.adapters.registry import register_adapter
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
# Optional dependency — imported at module level so it can be
|
|
80
|
+
# mocked in tests with @patch("plexus.adapters.modbus.pymodbus_client")
|
|
81
|
+
try:
|
|
82
|
+
from pymodbus.client import ModbusTcpClient, ModbusSerialClient
|
|
83
|
+
pymodbus_client = True
|
|
84
|
+
except ImportError:
|
|
85
|
+
pymodbus_client = None # type: ignore[assignment]
|
|
86
|
+
ModbusTcpClient = None # type: ignore[assignment,misc]
|
|
87
|
+
ModbusSerialClient = None # type: ignore[assignment,misc]
|
|
88
|
+
|
|
89
|
+
# Data type definitions: (struct format, register count)
|
|
90
|
+
_DATA_TYPES = {
|
|
91
|
+
"uint16": (">H", 1),
|
|
92
|
+
"int16": (">h", 1),
|
|
93
|
+
"uint32": (">I", 2),
|
|
94
|
+
"int32": (">i", 2),
|
|
95
|
+
"float32": (">f", 2),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Valid register types
|
|
99
|
+
_REGISTER_TYPES = {"holding", "input", "coil", "discrete"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@register_adapter(
|
|
103
|
+
"modbus",
|
|
104
|
+
description="Modbus TCP/RTU adapter for industrial devices",
|
|
105
|
+
author="Plexus",
|
|
106
|
+
version="1.0.0",
|
|
107
|
+
requires=["pymodbus"],
|
|
108
|
+
)
|
|
109
|
+
class ModbusAdapter(ProtocolAdapter):
|
|
110
|
+
"""
|
|
111
|
+
Modbus protocol adapter for industrial devices.
|
|
112
|
+
|
|
113
|
+
Reads holding registers, input registers, coils, and discrete inputs
|
|
114
|
+
from a Modbus slave via TCP or RTU (serial). Register values are
|
|
115
|
+
converted according to their configured data type, then scaled and
|
|
116
|
+
offset before being emitted as Plexus metrics.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
host: TCP hostname/IP or serial port path (e.g., "192.168.1.100"
|
|
120
|
+
or "/dev/ttyUSB0")
|
|
121
|
+
port: TCP port number (default: 502, ignored for RTU)
|
|
122
|
+
mode: Connection mode — "tcp" or "rtu" (default: "tcp")
|
|
123
|
+
unit_id: Modbus slave/unit ID (default: 1)
|
|
124
|
+
baudrate: Serial baudrate for RTU mode (default: 9600)
|
|
125
|
+
registers: List of register definitions. Each is a dict with:
|
|
126
|
+
- address (int): Register start address
|
|
127
|
+
- count (int): Number of registers to read (default: 1)
|
|
128
|
+
- name (str): Metric name suffix
|
|
129
|
+
- type (str): "holding", "input", "coil", or "discrete"
|
|
130
|
+
(default: "holding")
|
|
131
|
+
- data_type (str): "uint16", "int16", "uint32", "int32",
|
|
132
|
+
or "float32" (default: "uint16")
|
|
133
|
+
- scale (float): Multiply raw value by this (default: 1.0)
|
|
134
|
+
- offset (float): Add to scaled value (default: 0.0)
|
|
135
|
+
- unit (str): Engineering unit string for tags (optional)
|
|
136
|
+
poll_interval: Seconds between polls (default: 1.0)
|
|
137
|
+
prefix: Metric name prefix (default: "modbus.")
|
|
138
|
+
source_id: Source ID for metrics (optional)
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
adapter = ModbusAdapter(
|
|
142
|
+
host="192.168.1.100",
|
|
143
|
+
unit_id=1,
|
|
144
|
+
registers=[
|
|
145
|
+
{"address": 0, "name": "temperature", "data_type": "float32",
|
|
146
|
+
"scale": 0.1, "unit": "°C"},
|
|
147
|
+
{"address": 2, "name": "pressure", "data_type": "uint16",
|
|
148
|
+
"scale": 0.01, "unit": "bar"},
|
|
149
|
+
{"address": 10, "name": "pump_on", "type": "coil"},
|
|
150
|
+
],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
with adapter:
|
|
154
|
+
while True:
|
|
155
|
+
for metric in adapter.poll():
|
|
156
|
+
print(f"{metric.name} = {metric.value}")
|
|
157
|
+
time.sleep(adapter.poll_interval)
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
host: str = "127.0.0.1",
|
|
163
|
+
port: int = 502,
|
|
164
|
+
mode: str = "tcp",
|
|
165
|
+
unit_id: int = 1,
|
|
166
|
+
baudrate: int = 9600,
|
|
167
|
+
registers: Optional[List[Dict[str, Any]]] = None,
|
|
168
|
+
poll_interval: float = 1.0,
|
|
169
|
+
prefix: str = "modbus.",
|
|
170
|
+
source_id: Optional[str] = None,
|
|
171
|
+
**kwargs,
|
|
172
|
+
):
|
|
173
|
+
config = AdapterConfig(
|
|
174
|
+
name="modbus",
|
|
175
|
+
params={
|
|
176
|
+
"host": host,
|
|
177
|
+
"port": port,
|
|
178
|
+
"mode": mode,
|
|
179
|
+
"unit_id": unit_id,
|
|
180
|
+
"baudrate": baudrate,
|
|
181
|
+
**kwargs,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
super().__init__(config)
|
|
185
|
+
|
|
186
|
+
self.host = host
|
|
187
|
+
self.port = port
|
|
188
|
+
self.mode = mode.lower()
|
|
189
|
+
self.unit_id = unit_id
|
|
190
|
+
self.baudrate = baudrate
|
|
191
|
+
self.poll_interval = poll_interval
|
|
192
|
+
self.prefix = prefix
|
|
193
|
+
self._source_id = source_id
|
|
194
|
+
|
|
195
|
+
# Parse and validate register definitions
|
|
196
|
+
self._registers = self._parse_registers(registers or [])
|
|
197
|
+
|
|
198
|
+
self._client: Optional[Any] = None # pymodbus client instance
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _parse_registers(
|
|
202
|
+
raw_registers: List[Dict[str, Any]],
|
|
203
|
+
) -> List[Dict[str, Any]]:
|
|
204
|
+
"""
|
|
205
|
+
Validate and normalise register definitions.
|
|
206
|
+
|
|
207
|
+
Each entry must have at least ``address`` (int) and ``name`` (str).
|
|
208
|
+
Missing optional fields are filled with defaults.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
ValueError: On invalid register configuration.
|
|
212
|
+
"""
|
|
213
|
+
parsed: List[Dict[str, Any]] = []
|
|
214
|
+
|
|
215
|
+
for i, reg in enumerate(raw_registers):
|
|
216
|
+
if "address" not in reg:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"Register {i}: 'address' is required"
|
|
219
|
+
)
|
|
220
|
+
if "name" not in reg:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"Register {i}: 'name' is required"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
reg_type = reg.get("type", "holding")
|
|
226
|
+
if reg_type not in _REGISTER_TYPES:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"Register {i} ({reg['name']}): invalid type "
|
|
229
|
+
f"'{reg_type}'. Must be one of: "
|
|
230
|
+
f"{', '.join(sorted(_REGISTER_TYPES))}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
data_type = reg.get("data_type", "uint16")
|
|
234
|
+
# Coil/discrete are always boolean — ignore data_type for them
|
|
235
|
+
if reg_type in ("coil", "discrete"):
|
|
236
|
+
data_type = "bool"
|
|
237
|
+
elif data_type not in _DATA_TYPES:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"Register {i} ({reg['name']}): invalid data_type "
|
|
240
|
+
f"'{data_type}'. Must be one of: "
|
|
241
|
+
f"{', '.join(sorted(_DATA_TYPES))}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# For register types, determine count from data_type if not given
|
|
245
|
+
if reg_type in ("holding", "input"):
|
|
246
|
+
default_count = _DATA_TYPES[data_type][1]
|
|
247
|
+
else:
|
|
248
|
+
default_count = reg.get("count", 1)
|
|
249
|
+
|
|
250
|
+
parsed.append({
|
|
251
|
+
"address": int(reg["address"]),
|
|
252
|
+
"count": int(reg.get("count", default_count)),
|
|
253
|
+
"name": str(reg["name"]),
|
|
254
|
+
"type": reg_type,
|
|
255
|
+
"data_type": data_type,
|
|
256
|
+
"scale": float(reg.get("scale", 1.0)),
|
|
257
|
+
"offset": float(reg.get("offset", 0.0)),
|
|
258
|
+
"unit": reg.get("unit"),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
return parsed
|
|
262
|
+
|
|
263
|
+
def validate_config(self) -> bool:
|
|
264
|
+
"""Validate adapter configuration."""
|
|
265
|
+
if self.mode not in ("tcp", "rtu"):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"Invalid mode '{self.mode}'. Must be 'tcp' or 'rtu'"
|
|
268
|
+
)
|
|
269
|
+
if not self.host:
|
|
270
|
+
raise ValueError("Host is required")
|
|
271
|
+
if not self._registers:
|
|
272
|
+
logger.warning("No registers configured — poll() will return empty")
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
def connect(self) -> bool:
|
|
276
|
+
"""
|
|
277
|
+
Connect to the Modbus device.
|
|
278
|
+
|
|
279
|
+
Creates a ``ModbusTcpClient`` (TCP mode) or ``ModbusSerialClient``
|
|
280
|
+
(RTU mode) and opens the connection.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True if connection successful.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ConnectionError: If pymodbus is not installed or connection fails.
|
|
287
|
+
"""
|
|
288
|
+
if pymodbus_client is None:
|
|
289
|
+
self._set_state(AdapterState.ERROR, "pymodbus not installed")
|
|
290
|
+
raise ConnectionError(
|
|
291
|
+
"pymodbus is required. Install with: "
|
|
292
|
+
"pip install plexus-python[modbus]"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
self._set_state(AdapterState.CONNECTING)
|
|
297
|
+
|
|
298
|
+
if self.mode == "tcp":
|
|
299
|
+
logger.info(
|
|
300
|
+
f"Connecting to Modbus TCP {self.host}:{self.port} "
|
|
301
|
+
f"(unit {self.unit_id})"
|
|
302
|
+
)
|
|
303
|
+
self._client = ModbusTcpClient(
|
|
304
|
+
host=self.host,
|
|
305
|
+
port=self.port,
|
|
306
|
+
)
|
|
307
|
+
elif self.mode == "rtu":
|
|
308
|
+
logger.info(
|
|
309
|
+
f"Connecting to Modbus RTU {self.host} "
|
|
310
|
+
f"at {self.baudrate} baud (unit {self.unit_id})"
|
|
311
|
+
)
|
|
312
|
+
self._client = ModbusSerialClient(
|
|
313
|
+
port=self.host,
|
|
314
|
+
baudrate=self.baudrate,
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
raise ValueError(f"Invalid mode: {self.mode}")
|
|
318
|
+
|
|
319
|
+
connected = self._client.connect()
|
|
320
|
+
if not connected:
|
|
321
|
+
self._set_state(
|
|
322
|
+
AdapterState.ERROR,
|
|
323
|
+
f"Failed to connect to {self.host}",
|
|
324
|
+
)
|
|
325
|
+
raise ConnectionError(
|
|
326
|
+
f"Modbus connection failed: {self.host}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
self._set_state(AdapterState.CONNECTED)
|
|
330
|
+
logger.info(f"Connected to Modbus device at {self.host}")
|
|
331
|
+
return True
|
|
332
|
+
|
|
333
|
+
except ConnectionError:
|
|
334
|
+
raise
|
|
335
|
+
except Exception as e:
|
|
336
|
+
self._set_state(AdapterState.ERROR, str(e))
|
|
337
|
+
logger.error(f"Failed to connect to Modbus device: {e}")
|
|
338
|
+
raise ConnectionError(f"Modbus connection failed: {e}")
|
|
339
|
+
|
|
340
|
+
def disconnect(self) -> None:
|
|
341
|
+
"""Close the Modbus connection and release resources."""
|
|
342
|
+
if self._client:
|
|
343
|
+
try:
|
|
344
|
+
self._client.close()
|
|
345
|
+
logger.info("Disconnected from Modbus device")
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.warning(f"Error closing Modbus connection: {e}")
|
|
348
|
+
finally:
|
|
349
|
+
self._client = None
|
|
350
|
+
|
|
351
|
+
self._set_state(AdapterState.DISCONNECTED)
|
|
352
|
+
|
|
353
|
+
def poll(self) -> List[Metric]:
|
|
354
|
+
"""
|
|
355
|
+
Read all configured registers and return metrics.
|
|
356
|
+
|
|
357
|
+
For each register definition the appropriate Modbus function code
|
|
358
|
+
is used. Raw register values are converted according to
|
|
359
|
+
``data_type``, then ``scale`` and ``offset`` are applied:
|
|
360
|
+
|
|
361
|
+
value = (raw * scale) + offset
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
List of Metric objects — one per configured register.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
ConnectionError/OSError: On connection loss (triggers auto-reconnect).
|
|
368
|
+
ProtocolError: If a Modbus read fails.
|
|
369
|
+
"""
|
|
370
|
+
if not self._client:
|
|
371
|
+
return []
|
|
372
|
+
|
|
373
|
+
metrics: List[Metric] = []
|
|
374
|
+
timestamp = time.time()
|
|
375
|
+
|
|
376
|
+
for reg in self._registers:
|
|
377
|
+
try:
|
|
378
|
+
value = self._read_register(reg)
|
|
379
|
+
if value is None:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
tags: Dict[str, str] = {
|
|
383
|
+
"address": str(reg["address"]),
|
|
384
|
+
"register_type": reg["type"],
|
|
385
|
+
"unit_id": str(self.unit_id),
|
|
386
|
+
}
|
|
387
|
+
if reg["unit"]:
|
|
388
|
+
tags["unit"] = reg["unit"]
|
|
389
|
+
if reg["data_type"] != "bool":
|
|
390
|
+
tags["data_type"] = reg["data_type"]
|
|
391
|
+
|
|
392
|
+
metrics.append(
|
|
393
|
+
Metric(
|
|
394
|
+
name=f"{self.prefix}{reg['name']}",
|
|
395
|
+
value=value,
|
|
396
|
+
timestamp=timestamp,
|
|
397
|
+
tags=tags,
|
|
398
|
+
source_id=self._source_id,
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
except OSError:
|
|
403
|
+
raise # Let run loop handle disconnect/reconnect
|
|
404
|
+
except Exception as e:
|
|
405
|
+
logger.error(
|
|
406
|
+
f"Error reading register '{reg['name']}' "
|
|
407
|
+
f"at address {reg['address']}: {e}"
|
|
408
|
+
)
|
|
409
|
+
raise ProtocolError(
|
|
410
|
+
f"Modbus read error for '{reg['name']}' "
|
|
411
|
+
f"at address {reg['address']}: {e}"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return metrics
|
|
415
|
+
|
|
416
|
+
def _read_register(
|
|
417
|
+
self, reg: Dict[str, Any]
|
|
418
|
+
) -> Optional[Any]:
|
|
419
|
+
"""
|
|
420
|
+
Read a single register definition and return the converted value.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
The converted, scaled value — or None if the read failed
|
|
424
|
+
with a Modbus exception.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
ProtocolError: On communication errors.
|
|
428
|
+
"""
|
|
429
|
+
address = reg["address"]
|
|
430
|
+
count = reg["count"]
|
|
431
|
+
reg_type = reg["type"]
|
|
432
|
+
|
|
433
|
+
# --- Coil / discrete reads (boolean) ---
|
|
434
|
+
if reg_type == "coil":
|
|
435
|
+
result = self._client.read_coils(
|
|
436
|
+
address, count=count, slave=self.unit_id,
|
|
437
|
+
)
|
|
438
|
+
elif reg_type == "discrete":
|
|
439
|
+
result = self._client.read_discrete_inputs(
|
|
440
|
+
address, count=count, slave=self.unit_id,
|
|
441
|
+
)
|
|
442
|
+
elif reg_type == "input":
|
|
443
|
+
result = self._client.read_input_registers(
|
|
444
|
+
address, count=count, slave=self.unit_id,
|
|
445
|
+
)
|
|
446
|
+
elif reg_type == "holding":
|
|
447
|
+
result = self._client.read_holding_registers(
|
|
448
|
+
address, count=count, slave=self.unit_id,
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
raise ProtocolError(f"Unknown register type: {reg_type}")
|
|
452
|
+
|
|
453
|
+
# Check for errors
|
|
454
|
+
if result.isError():
|
|
455
|
+
logger.warning(
|
|
456
|
+
f"Modbus error reading {reg_type} register "
|
|
457
|
+
f"at address {address}: {result}"
|
|
458
|
+
)
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
# --- Boolean registers ---
|
|
462
|
+
if reg_type in ("coil", "discrete"):
|
|
463
|
+
# Return first bit value as bool
|
|
464
|
+
return bool(result.bits[0])
|
|
465
|
+
|
|
466
|
+
# --- Numeric registers ---
|
|
467
|
+
raw_registers = result.registers
|
|
468
|
+
value = self._convert_registers(
|
|
469
|
+
raw_registers, reg["data_type"]
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Apply scale and offset
|
|
473
|
+
return (value * reg["scale"]) + reg["offset"]
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
def _convert_registers(
|
|
477
|
+
registers: List[int], data_type: str
|
|
478
|
+
) -> float:
|
|
479
|
+
"""
|
|
480
|
+
Convert raw 16-bit register values to a typed numeric value.
|
|
481
|
+
|
|
482
|
+
For 32-bit types, two consecutive registers are combined
|
|
483
|
+
(big-endian / high word first) and unpacked with ``struct``.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
registers: List of raw 16-bit register values.
|
|
487
|
+
data_type: One of "uint16", "int16", "uint32", "int32",
|
|
488
|
+
"float32".
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
The numeric value as a float.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
ProtocolError: If there are not enough registers for the
|
|
495
|
+
requested data type.
|
|
496
|
+
"""
|
|
497
|
+
fmt, expected_count = _DATA_TYPES[data_type]
|
|
498
|
+
|
|
499
|
+
if len(registers) < expected_count:
|
|
500
|
+
raise ProtocolError(
|
|
501
|
+
f"Expected {expected_count} register(s) for {data_type}, "
|
|
502
|
+
f"got {len(registers)}"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
if expected_count == 1:
|
|
506
|
+
# Pack single 16-bit register as unsigned, then unpack as target type
|
|
507
|
+
raw_bytes = struct.pack(">H", registers[0])
|
|
508
|
+
(value,) = struct.unpack(fmt, raw_bytes)
|
|
509
|
+
else:
|
|
510
|
+
# Pack two 16-bit registers (high word first) into 4 bytes
|
|
511
|
+
raw_bytes = struct.pack(">HH", registers[0], registers[1])
|
|
512
|
+
(value,) = struct.unpack(fmt, raw_bytes)
|
|
513
|
+
|
|
514
|
+
return float(value)
|
|
515
|
+
|
|
516
|
+
def write_register(
|
|
517
|
+
self,
|
|
518
|
+
address: int,
|
|
519
|
+
value: int,
|
|
520
|
+
register_type: str = "holding",
|
|
521
|
+
) -> bool:
|
|
522
|
+
"""
|
|
523
|
+
Write a value to a Modbus register.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
address: Register address.
|
|
527
|
+
value: Value to write (int for registers, bool-ish for coils).
|
|
528
|
+
register_type: "holding" or "coil".
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
True if the write succeeded.
|
|
532
|
+
|
|
533
|
+
Raises:
|
|
534
|
+
ProtocolError: If not connected or write fails.
|
|
535
|
+
"""
|
|
536
|
+
if not self._client:
|
|
537
|
+
raise ProtocolError("Not connected to Modbus device")
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
if register_type == "coil":
|
|
541
|
+
result = self._client.write_coil(
|
|
542
|
+
address, bool(value), slave=self.unit_id,
|
|
543
|
+
)
|
|
544
|
+
elif register_type == "holding":
|
|
545
|
+
result = self._client.write_register(
|
|
546
|
+
address, value, slave=self.unit_id,
|
|
547
|
+
)
|
|
548
|
+
else:
|
|
549
|
+
raise ProtocolError(
|
|
550
|
+
f"Cannot write to '{register_type}' registers"
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if result.isError():
|
|
554
|
+
logger.error(
|
|
555
|
+
f"Modbus write error at address {address}: {result}"
|
|
556
|
+
)
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
logger.debug(
|
|
560
|
+
f"Wrote {value} to {register_type} register {address}"
|
|
561
|
+
)
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
except ProtocolError:
|
|
565
|
+
raise
|
|
566
|
+
except Exception as e:
|
|
567
|
+
logger.error(f"Failed to write Modbus register: {e}")
|
|
568
|
+
raise ProtocolError(f"Modbus write error: {e}")
|
|
569
|
+
|
|
570
|
+
def write_registers(
|
|
571
|
+
self,
|
|
572
|
+
address: int,
|
|
573
|
+
values: List[int],
|
|
574
|
+
) -> bool:
|
|
575
|
+
"""
|
|
576
|
+
Write multiple holding register values starting at an address.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
address: Starting register address.
|
|
580
|
+
values: List of 16-bit integer values to write.
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
True if the write succeeded.
|
|
584
|
+
|
|
585
|
+
Raises:
|
|
586
|
+
ProtocolError: If not connected or write fails.
|
|
587
|
+
"""
|
|
588
|
+
if not self._client:
|
|
589
|
+
raise ProtocolError("Not connected to Modbus device")
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
result = self._client.write_registers(
|
|
593
|
+
address, values, slave=self.unit_id,
|
|
594
|
+
)
|
|
595
|
+
if result.isError():
|
|
596
|
+
logger.error(
|
|
597
|
+
f"Modbus write error at address {address}: {result}"
|
|
598
|
+
)
|
|
599
|
+
return False
|
|
600
|
+
|
|
601
|
+
logger.debug(
|
|
602
|
+
f"Wrote {len(values)} registers starting at {address}"
|
|
603
|
+
)
|
|
604
|
+
return True
|
|
605
|
+
|
|
606
|
+
except Exception as e:
|
|
607
|
+
logger.error(f"Failed to write Modbus registers: {e}")
|
|
608
|
+
raise ProtocolError(f"Modbus write error: {e}")
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def stats(self) -> Dict[str, Any]:
|
|
612
|
+
"""Get adapter statistics including Modbus-specific info."""
|
|
613
|
+
base_stats = super().stats
|
|
614
|
+
base_stats.update({
|
|
615
|
+
"host": self.host,
|
|
616
|
+
"port": self.port,
|
|
617
|
+
"mode": self.mode,
|
|
618
|
+
"unit_id": self.unit_id,
|
|
619
|
+
"register_count": len(self._registers),
|
|
620
|
+
"poll_interval": self.poll_interval,
|
|
621
|
+
})
|
|
622
|
+
return base_stats
|