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.
@@ -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})"