mbtoolcli 0.3.1__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.
- mbtoolcli-0.3.1.dist-info/METADATA +473 -0
- mbtoolcli-0.3.1.dist-info/RECORD +16 -0
- mbtoolcli-0.3.1.dist-info/WHEEL +5 -0
- mbtoolcli-0.3.1.dist-info/entry_points.txt +4 -0
- mbtoolcli-0.3.1.dist-info/licenses/LICENSE +21 -0
- mbtoolcli-0.3.1.dist-info/top_level.txt +1 -0
- modbus_slave_sim/__init__.py +42 -0
- modbus_slave_sim/cli.py +903 -0
- modbus_slave_sim/colors.py +57 -0
- modbus_slave_sim/datatypes.py +386 -0
- modbus_slave_sim/gui_server.py +1049 -0
- modbus_slave_sim/master.py +647 -0
- modbus_slave_sim/registers.py +288 -0
- modbus_slave_sim/rtu_server.py +158 -0
- modbus_slave_sim/simulation.py +318 -0
- modbus_slave_sim/tcp_server.py +178 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modbus Master client implementation.
|
|
3
|
+
|
|
4
|
+
This module provides a Modbus Master client that can read and write
|
|
5
|
+
to Modbus slave devices. Supports TCP, RTU, and ASCII protocols.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from typing import List, Optional, Union
|
|
11
|
+
|
|
12
|
+
from pymodbus.client import ModbusTcpClient, ModbusSerialClient
|
|
13
|
+
from pymodbus.exceptions import ModbusException
|
|
14
|
+
from pymodbus.framer import Framer
|
|
15
|
+
|
|
16
|
+
from .datatypes import (
|
|
17
|
+
ByteOrder,
|
|
18
|
+
DataType,
|
|
19
|
+
DATA_TYPE_REGISTERS,
|
|
20
|
+
convert_registers,
|
|
21
|
+
format_value,
|
|
22
|
+
int16_to_registers,
|
|
23
|
+
int32_to_registers,
|
|
24
|
+
float32_to_registers,
|
|
25
|
+
)
|
|
26
|
+
from .colors import c, CYAN_BOLD, MAGENTA, RED_BOLD, DIM, GREEN_BOLD, YELLOW, WHITE_BOLD, RED, BOLD
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ModbusMaster:
|
|
32
|
+
"""
|
|
33
|
+
Modbus Master client.
|
|
34
|
+
|
|
35
|
+
Provides read/write operations for Modbus slave devices with
|
|
36
|
+
support for various data types and protocols.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
host: str = "127.0.0.1",
|
|
42
|
+
port: int = 502,
|
|
43
|
+
mode: str = "tcp",
|
|
44
|
+
slave_id: int = 1,
|
|
45
|
+
timeout: float = 3.0,
|
|
46
|
+
# RTU/ASCII parameters
|
|
47
|
+
serial_port: Optional[str] = None,
|
|
48
|
+
baudrate: int = 9600,
|
|
49
|
+
parity: str = "N",
|
|
50
|
+
stopbits: int = 1,
|
|
51
|
+
bytesize: int = 8,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize the Modbus Master client.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
host: TCP host address
|
|
58
|
+
port: TCP port or serial port
|
|
59
|
+
mode: Protocol mode ('tcp', 'rtu', 'ascii')
|
|
60
|
+
slave_id: Target slave device ID
|
|
61
|
+
timeout: Communication timeout in seconds
|
|
62
|
+
serial_port: Serial port path (for RTU/ASCII)
|
|
63
|
+
baudrate: Serial baud rate
|
|
64
|
+
parity: Serial parity (N, E, O)
|
|
65
|
+
stopbits: Serial stop bits
|
|
66
|
+
bytesize: Serial data bits
|
|
67
|
+
"""
|
|
68
|
+
self.host = host
|
|
69
|
+
self.port = port
|
|
70
|
+
self.mode = mode.lower()
|
|
71
|
+
self.slave_id = slave_id
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
self.serial_port = serial_port or str(port)
|
|
74
|
+
self.baudrate = baudrate
|
|
75
|
+
self.parity = parity
|
|
76
|
+
self.stopbits = stopbits
|
|
77
|
+
self.bytesize = bytesize
|
|
78
|
+
self._client = None
|
|
79
|
+
|
|
80
|
+
def connect(self) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Connect to the Modbus slave device.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if connection successful, False otherwise
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
if self.mode == "tcp":
|
|
89
|
+
self._client = ModbusTcpClient(
|
|
90
|
+
host=self.host,
|
|
91
|
+
port=self.port,
|
|
92
|
+
timeout=self.timeout,
|
|
93
|
+
)
|
|
94
|
+
elif self.mode in ("rtu", "ascii"):
|
|
95
|
+
# Map mode to framer type
|
|
96
|
+
framer = Framer.RTU if self.mode == "rtu" else Framer.ASCII
|
|
97
|
+
|
|
98
|
+
self._client = ModbusSerialClient(
|
|
99
|
+
port=self.serial_port,
|
|
100
|
+
framer=framer,
|
|
101
|
+
baudrate=self.baudrate,
|
|
102
|
+
parity=self.parity,
|
|
103
|
+
stopbits=self.stopbits,
|
|
104
|
+
bytesize=self.bytesize,
|
|
105
|
+
timeout=self.timeout,
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(f"Unsupported mode: {self.mode}")
|
|
109
|
+
|
|
110
|
+
connected = self._client.connect()
|
|
111
|
+
if connected:
|
|
112
|
+
logger.info(f"Connected to {self.mode.upper()} device at {self._get_address()}")
|
|
113
|
+
else:
|
|
114
|
+
logger.error(f"Failed to connect to {self._get_address()}")
|
|
115
|
+
return connected
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Connection error: {e}")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def disconnect(self):
|
|
122
|
+
"""Disconnect from the Modbus slave device."""
|
|
123
|
+
if self._client:
|
|
124
|
+
self._client.close()
|
|
125
|
+
self._client = None
|
|
126
|
+
logger.info("Disconnected")
|
|
127
|
+
|
|
128
|
+
def _get_address(self) -> str:
|
|
129
|
+
"""Get address string for logging."""
|
|
130
|
+
if self.mode == "tcp":
|
|
131
|
+
return f"{self.host}:{self.port}"
|
|
132
|
+
return self.serial_port
|
|
133
|
+
|
|
134
|
+
def scan_slaves(
|
|
135
|
+
self,
|
|
136
|
+
start: int = 1,
|
|
137
|
+
end: int = 247,
|
|
138
|
+
register: int = 0,
|
|
139
|
+
count: int = 1,
|
|
140
|
+
timeout_per: float = 0.5,
|
|
141
|
+
) -> List[int]:
|
|
142
|
+
"""Scan a range of slave IDs to discover active devices.
|
|
143
|
+
|
|
144
|
+
Reads a holding register from each slave ID to determine if it responds.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
start: Starting slave ID (default 1)
|
|
148
|
+
end: Ending slave ID (default 247)
|
|
149
|
+
register: Register address to read (default 0)
|
|
150
|
+
count: Number of registers to read (default 1)
|
|
151
|
+
timeout_per: Timeout per slave in seconds (default 0.5)
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of slave IDs that responded successfully
|
|
155
|
+
"""
|
|
156
|
+
if not self._client:
|
|
157
|
+
logger.error("Not connected")
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
if not self._client.is_socket_open():
|
|
161
|
+
logger.error("Connection is not open")
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
found_slaves = []
|
|
165
|
+
total = end - start + 1
|
|
166
|
+
self._client.timeout = timeout_per
|
|
167
|
+
|
|
168
|
+
for sid in range(start, end + 1):
|
|
169
|
+
self._client.comm_params.slave_id = sid
|
|
170
|
+
try:
|
|
171
|
+
result = self._client.read_holding_registers(address=register, count=count)
|
|
172
|
+
if not result.isError():
|
|
173
|
+
found_slaves.append(sid)
|
|
174
|
+
progress = c(f"[{sid - start + 1}/{total}]", DIM)
|
|
175
|
+
print(f" {progress} Slave ID {c(str(sid), GREEN_BOLD)} - {c('Found', GREEN_BOLD)}")
|
|
176
|
+
else:
|
|
177
|
+
print(f" {c(f'[{sid - start + 1}/{total}]', DIM)} Slave ID {c(str(sid), DIM)} - {c('No response', DIM)}", end="\r")
|
|
178
|
+
except Exception:
|
|
179
|
+
print(f" {c(f'[{sid - start + 1}/{total}]', DIM)} Slave ID {c(str(sid), DIM)} - {c('No response', DIM)}", end="\r")
|
|
180
|
+
|
|
181
|
+
# Restore original slave_id
|
|
182
|
+
self._client.comm_params.slave_id = self.slave_id
|
|
183
|
+
self._client.timeout = self.timeout
|
|
184
|
+
print()
|
|
185
|
+
|
|
186
|
+
return found_slaves
|
|
187
|
+
|
|
188
|
+
def read_coils(
|
|
189
|
+
self,
|
|
190
|
+
address: int,
|
|
191
|
+
count: int = 1,
|
|
192
|
+
) -> Optional[List[bool]]:
|
|
193
|
+
"""
|
|
194
|
+
Read coils (FC01).
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
address: Starting address
|
|
198
|
+
count: Number of coils to read
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of coil values or None on error
|
|
202
|
+
"""
|
|
203
|
+
if not self._client:
|
|
204
|
+
logger.error("Not connected")
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = self._client.read_coils(address=address, count=count)
|
|
209
|
+
if result.isError():
|
|
210
|
+
logger.error(f"Read coils error: {result}")
|
|
211
|
+
return None
|
|
212
|
+
return result.bits[:count]
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.error(f"Read coils exception: {e}")
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
def read_discrete_inputs(
|
|
218
|
+
self,
|
|
219
|
+
address: int,
|
|
220
|
+
count: int = 1,
|
|
221
|
+
) -> Optional[List[bool]]:
|
|
222
|
+
"""
|
|
223
|
+
Read discrete inputs (FC02).
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
address: Starting address
|
|
227
|
+
count: Number of discrete inputs to read
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
List of discrete input values or None on error
|
|
231
|
+
"""
|
|
232
|
+
if not self._client:
|
|
233
|
+
logger.error("Not connected")
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
result = self._client.read_discrete_inputs(address=address, count=count)
|
|
238
|
+
if result.isError():
|
|
239
|
+
logger.error(f"Read discrete inputs error: {result}")
|
|
240
|
+
return None
|
|
241
|
+
return result.bits[:count]
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error(f"Read discrete inputs exception: {e}")
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def read_holding_registers(
|
|
247
|
+
self,
|
|
248
|
+
address: int,
|
|
249
|
+
count: int = 1,
|
|
250
|
+
) -> Optional[List[int]]:
|
|
251
|
+
"""
|
|
252
|
+
Read holding registers (FC03).
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
address: Starting address
|
|
256
|
+
count: Number of registers to read
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of register values or None on error
|
|
260
|
+
"""
|
|
261
|
+
if not self._client:
|
|
262
|
+
logger.error("Not connected")
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
result = self._client.read_holding_registers(address=address, count=count)
|
|
267
|
+
if result.isError():
|
|
268
|
+
logger.error(f"Read holding registers error: {result}")
|
|
269
|
+
return None
|
|
270
|
+
return result.registers
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.error(f"Read holding registers exception: {e}")
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
def read_input_registers(
|
|
276
|
+
self,
|
|
277
|
+
address: int,
|
|
278
|
+
count: int = 1,
|
|
279
|
+
) -> Optional[List[int]]:
|
|
280
|
+
"""
|
|
281
|
+
Read input registers (FC04).
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
address: Starting address
|
|
285
|
+
count: Number of registers to read
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
List of register values or None on error
|
|
289
|
+
"""
|
|
290
|
+
if not self._client:
|
|
291
|
+
logger.error("Not connected")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
result = self._client.read_input_registers(address=address, count=count)
|
|
296
|
+
if result.isError():
|
|
297
|
+
logger.error(f"Read input registers error: {result}")
|
|
298
|
+
return None
|
|
299
|
+
return result.registers
|
|
300
|
+
except Exception as e:
|
|
301
|
+
logger.error(f"Read input registers exception: {e}")
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
def write_single_coil(
|
|
305
|
+
self,
|
|
306
|
+
address: int,
|
|
307
|
+
value: bool,
|
|
308
|
+
) -> bool:
|
|
309
|
+
"""
|
|
310
|
+
Write single coil (FC05).
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
address: Coil address
|
|
314
|
+
value: Coil value (True/False)
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
True if successful, False otherwise
|
|
318
|
+
"""
|
|
319
|
+
if not self._client:
|
|
320
|
+
logger.error("Not connected")
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
result = self._client.write_coil(address=address, value=value)
|
|
325
|
+
if result.isError():
|
|
326
|
+
logger.error(f"Write single coil error: {result}")
|
|
327
|
+
return False
|
|
328
|
+
logger.info(f"Coil {address} written: {value}")
|
|
329
|
+
return True
|
|
330
|
+
except Exception as e:
|
|
331
|
+
logger.error(f"Write single coil exception: {e}")
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
def write_single_register(
|
|
335
|
+
self,
|
|
336
|
+
address: int,
|
|
337
|
+
value: int,
|
|
338
|
+
) -> bool:
|
|
339
|
+
"""
|
|
340
|
+
Write single register (FC06).
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
address: Register address
|
|
344
|
+
value: Register value (0-65535)
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
True if successful, False otherwise
|
|
348
|
+
"""
|
|
349
|
+
if not self._client:
|
|
350
|
+
logger.error("Not connected")
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
result = self._client.write_register(address=address, value=value)
|
|
355
|
+
if result.isError():
|
|
356
|
+
logger.error(f"Write single register error: {result}")
|
|
357
|
+
return False
|
|
358
|
+
logger.info(f"Register {address} written: {value}")
|
|
359
|
+
return True
|
|
360
|
+
except Exception as e:
|
|
361
|
+
logger.error(f"Write single register exception: {e}")
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
def write_multiple_coils(
|
|
365
|
+
self,
|
|
366
|
+
address: int,
|
|
367
|
+
values: List[bool],
|
|
368
|
+
) -> bool:
|
|
369
|
+
"""
|
|
370
|
+
Write multiple coils (FC15).
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
address: Starting address
|
|
374
|
+
values: List of coil values
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
True if successful, False otherwise
|
|
378
|
+
"""
|
|
379
|
+
if not self._client:
|
|
380
|
+
logger.error("Not connected")
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
result = self._client.write_coils(address=address, values=values)
|
|
385
|
+
if result.isError():
|
|
386
|
+
logger.error(f"Write multiple coils error: {result}")
|
|
387
|
+
return False
|
|
388
|
+
logger.info(f"Coils {address}-{address + len(values) - 1} written")
|
|
389
|
+
return True
|
|
390
|
+
except Exception as e:
|
|
391
|
+
logger.error(f"Write multiple coils exception: {e}")
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
def write_multiple_registers(
|
|
395
|
+
self,
|
|
396
|
+
address: int,
|
|
397
|
+
values: List[int],
|
|
398
|
+
) -> bool:
|
|
399
|
+
"""
|
|
400
|
+
Write multiple registers (FC16).
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
address: Starting address
|
|
404
|
+
values: List of register values
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
True if successful, False otherwise
|
|
408
|
+
"""
|
|
409
|
+
if not self._client:
|
|
410
|
+
logger.error("Not connected")
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
result = self._client.write_registers(address=address, values=values)
|
|
415
|
+
if result.isError():
|
|
416
|
+
logger.error(f"Write multiple registers error: {result}")
|
|
417
|
+
return False
|
|
418
|
+
logger.info(f"Registers {address}-{address + len(values) - 1} written")
|
|
419
|
+
return True
|
|
420
|
+
except Exception as e:
|
|
421
|
+
logger.error(f"Write multiple registers exception: {e}")
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
def read_and_convert(
|
|
425
|
+
self,
|
|
426
|
+
address: int,
|
|
427
|
+
count: int,
|
|
428
|
+
data_type: DataType,
|
|
429
|
+
function_code: int = 3,
|
|
430
|
+
byte_order: ByteOrder = ByteOrder.ABCD,
|
|
431
|
+
) -> Optional[Union[int, float, str, List[Union[int, float, str]]]]:
|
|
432
|
+
"""
|
|
433
|
+
Read registers and convert to specified data type.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
address: Starting address
|
|
437
|
+
count: Number of registers to read
|
|
438
|
+
data_type: Target data type
|
|
439
|
+
function_code: 1=coils, 2=discrete, 3=holding, 4=input
|
|
440
|
+
byte_order: Byte order for 32-bit types
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
Converted value(s) or None on error
|
|
444
|
+
"""
|
|
445
|
+
# Handle coils and discrete inputs (1-bit values)
|
|
446
|
+
if function_code == 1:
|
|
447
|
+
values = self.read_coils(address, count)
|
|
448
|
+
if values is None:
|
|
449
|
+
return None
|
|
450
|
+
return [1 if v else 0 for v in values]
|
|
451
|
+
elif function_code == 2:
|
|
452
|
+
values = self.read_discrete_inputs(address, count)
|
|
453
|
+
if values is None:
|
|
454
|
+
return None
|
|
455
|
+
return [1 if v else 0 for v in values]
|
|
456
|
+
|
|
457
|
+
# Handle holding and input registers (16-bit values)
|
|
458
|
+
if function_code == 3:
|
|
459
|
+
registers = self.read_holding_registers(address, count)
|
|
460
|
+
else:
|
|
461
|
+
registers = self.read_input_registers(address, count)
|
|
462
|
+
|
|
463
|
+
if registers is None:
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
# Calculate how many values we need
|
|
467
|
+
regs_per_value = DATA_TYPE_REGISTERS.get(data_type, 1)
|
|
468
|
+
|
|
469
|
+
if data_type == DataType.HEX:
|
|
470
|
+
# For hex, return all registers formatted
|
|
471
|
+
return convert_registers(registers, data_type, count=count, byte_order=byte_order)
|
|
472
|
+
|
|
473
|
+
# Convert multiple values
|
|
474
|
+
values = []
|
|
475
|
+
for i in range(0, len(registers), regs_per_value):
|
|
476
|
+
chunk = registers[i:i + regs_per_value]
|
|
477
|
+
if len(chunk) == regs_per_value:
|
|
478
|
+
value = convert_registers(chunk, data_type, count=regs_per_value, byte_order=byte_order)
|
|
479
|
+
values.append(value)
|
|
480
|
+
|
|
481
|
+
if len(values) == 1:
|
|
482
|
+
return values[0]
|
|
483
|
+
return values
|
|
484
|
+
|
|
485
|
+
def write_converted(
|
|
486
|
+
self,
|
|
487
|
+
address: int,
|
|
488
|
+
value: Union[int, float],
|
|
489
|
+
data_type: DataType,
|
|
490
|
+
byte_order: ByteOrder = ByteOrder.ABCD,
|
|
491
|
+
) -> bool:
|
|
492
|
+
"""
|
|
493
|
+
Convert value and write to registers.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
address: Starting address
|
|
497
|
+
value: Value to write
|
|
498
|
+
data_type: Data type of the value
|
|
499
|
+
byte_order: Byte order for 32-bit types
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
True if successful, False otherwise
|
|
503
|
+
"""
|
|
504
|
+
# Convert value to registers
|
|
505
|
+
if data_type == DataType.INT16:
|
|
506
|
+
registers = int16_to_registers(int(value), signed=True)
|
|
507
|
+
elif data_type == DataType.UINT16:
|
|
508
|
+
registers = int16_to_registers(int(value), signed=False)
|
|
509
|
+
elif data_type == DataType.INT32:
|
|
510
|
+
registers = int32_to_registers(int(value), signed=True, byte_order=byte_order)
|
|
511
|
+
elif data_type == DataType.UINT32:
|
|
512
|
+
registers = int32_to_registers(int(value), signed=False, byte_order=byte_order)
|
|
513
|
+
elif data_type == DataType.FLOAT32:
|
|
514
|
+
registers = float32_to_registers(float(value), byte_order=byte_order)
|
|
515
|
+
else:
|
|
516
|
+
logger.error(f"Cannot write with data type: {data_type}")
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
# Write registers
|
|
520
|
+
if len(registers) == 1:
|
|
521
|
+
return self.write_single_register(address, registers[0])
|
|
522
|
+
else:
|
|
523
|
+
return self.write_multiple_registers(address, registers)
|
|
524
|
+
|
|
525
|
+
def poll(
|
|
526
|
+
self,
|
|
527
|
+
address: int,
|
|
528
|
+
count: int,
|
|
529
|
+
data_type: DataType,
|
|
530
|
+
function_code: int = 3,
|
|
531
|
+
interval: float = 1.0,
|
|
532
|
+
iterations: int = 0,
|
|
533
|
+
hex_format: bool = False,
|
|
534
|
+
byte_order: ByteOrder = ByteOrder.ABCD,
|
|
535
|
+
alarm: Optional[dict] = None,
|
|
536
|
+
output_file: Optional[str] = None,
|
|
537
|
+
):
|
|
538
|
+
"""
|
|
539
|
+
Poll registers continuously.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
address: Starting address
|
|
543
|
+
count: Number of registers
|
|
544
|
+
data_type: Data type for display
|
|
545
|
+
function_code: 3 for holding, 4 for input registers
|
|
546
|
+
interval: Polling interval in seconds
|
|
547
|
+
iterations: Number of iterations (0 = infinite)
|
|
548
|
+
hex_format: Show hex values alongside
|
|
549
|
+
byte_order: Byte order for 32-bit types
|
|
550
|
+
alarm: Alarm config {"addr": int, "op": str, "value": int/float} to alert when condition met
|
|
551
|
+
output_file: Path to CSV file for logging results
|
|
552
|
+
"""
|
|
553
|
+
iteration = 0
|
|
554
|
+
csv_fh = None
|
|
555
|
+
try:
|
|
556
|
+
if output_file:
|
|
557
|
+
csv_fh = open(output_file, "w", newline="")
|
|
558
|
+
import csv
|
|
559
|
+
writer = csv.writer(csv_fh)
|
|
560
|
+
writer.writerow(["iteration", "address", "value"])
|
|
561
|
+
|
|
562
|
+
while iterations == 0 or iteration < iterations:
|
|
563
|
+
iteration += 1
|
|
564
|
+
value = self.read_and_convert(address, count, data_type, function_code, byte_order=byte_order)
|
|
565
|
+
|
|
566
|
+
if value is not None:
|
|
567
|
+
formatted = format_value(value, data_type, hex_format)
|
|
568
|
+
line = f"{c(f'[{iteration}]', MAGENTA)} {c(formatted, CYAN_BOLD)}"
|
|
569
|
+
|
|
570
|
+
# Check alarm
|
|
571
|
+
if alarm is not None:
|
|
572
|
+
alarm_msg = self._check_alarm(value, alarm, data_type)
|
|
573
|
+
if alarm_msg:
|
|
574
|
+
line += f" {c(alarm_msg, RED_BOLD)}"
|
|
575
|
+
|
|
576
|
+
print(line)
|
|
577
|
+
|
|
578
|
+
# Write to CSV
|
|
579
|
+
if csv_fh and isinstance(value, (int, float)):
|
|
580
|
+
writer.writerow([iteration, address, value])
|
|
581
|
+
elif csv_fh and isinstance(value, list):
|
|
582
|
+
for i, v in enumerate(value):
|
|
583
|
+
writer.writerow([iteration, address + i, v])
|
|
584
|
+
else:
|
|
585
|
+
print(f"{c(f'[{iteration}]', MAGENTA)} {c('Error reading registers', RED_BOLD)}")
|
|
586
|
+
|
|
587
|
+
if iterations == 0:
|
|
588
|
+
time.sleep(interval)
|
|
589
|
+
|
|
590
|
+
except KeyboardInterrupt:
|
|
591
|
+
print(f"\n{c('Polling stopped', YELLOW)}")
|
|
592
|
+
finally:
|
|
593
|
+
if csv_fh:
|
|
594
|
+
csv_fh.close()
|
|
595
|
+
print(c(f"Completed {iteration} iterations", DIM))
|
|
596
|
+
|
|
597
|
+
def _check_alarm(self, value, alarm: dict, data_type: DataType) -> Optional[str]:
|
|
598
|
+
"""Check if value triggers alarm condition. Returns alarm message or None."""
|
|
599
|
+
addr = alarm["addr"]
|
|
600
|
+
op = alarm["op"]
|
|
601
|
+
threshold = alarm["value"]
|
|
602
|
+
|
|
603
|
+
if isinstance(value, list) and addr < len(value):
|
|
604
|
+
v = value[addr]
|
|
605
|
+
elif isinstance(value, (int, float)):
|
|
606
|
+
v = value
|
|
607
|
+
else:
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
triggered = False
|
|
611
|
+
if op == ">":
|
|
612
|
+
triggered = v > threshold
|
|
613
|
+
elif op == "<":
|
|
614
|
+
triggered = v < threshold
|
|
615
|
+
elif op == ">=":
|
|
616
|
+
triggered = v >= threshold
|
|
617
|
+
elif op == "<=":
|
|
618
|
+
triggered = v <= threshold
|
|
619
|
+
elif op == "==":
|
|
620
|
+
triggered = v == threshold
|
|
621
|
+
elif op == "!=":
|
|
622
|
+
triggered = v != threshold
|
|
623
|
+
else:
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
if triggered:
|
|
627
|
+
return f"!!! ALARM: register[addr] {op} {threshold} (current: {v}) !!!"
|
|
628
|
+
return None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class MasterResult:
|
|
632
|
+
"""Wrapper for master operation results."""
|
|
633
|
+
|
|
634
|
+
def __init__(
|
|
635
|
+
self,
|
|
636
|
+
success: bool,
|
|
637
|
+
data: Optional[Union[List[int], List[bool], int, float, str]] = None,
|
|
638
|
+
error: Optional[str] = None,
|
|
639
|
+
):
|
|
640
|
+
self.success = success
|
|
641
|
+
self.data = data
|
|
642
|
+
self.error = error
|
|
643
|
+
|
|
644
|
+
def __repr__(self):
|
|
645
|
+
if self.success:
|
|
646
|
+
return f"MasterResult(success=True, data={self.data})"
|
|
647
|
+
return f"MasterResult(success=False, error={self.error})"
|