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,288 @@
1
+ """
2
+ Modbus register data structures and management.
3
+
4
+ This module defines the register types used in Modbus protocol:
5
+ - Coils (1-bit, read/write)
6
+ - Discrete Inputs (1-bit, read-only)
7
+ - Input Registers (16-bit, read-only)
8
+ - Holding Registers (16-bit, read/write)
9
+ """
10
+
11
+ import json
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from .colors import c, BOLD, DIM, RED, GREEN, GREEN_BOLD, RED_BOLD, CYAN, CYAN_BOLD, MAGENTA, WHITE_BOLD
15
+
16
+
17
+ class BaseRegister:
18
+ """Base class for all Modbus register types."""
19
+
20
+ def __init__(self, address: int, value: Any = 0, description: str = ""):
21
+ """
22
+ Initialize a register.
23
+
24
+ Args:
25
+ address: Register address (0-65535)
26
+ value: Initial value
27
+ description: Human-readable description
28
+ """
29
+ self.address = address
30
+ self.value = value
31
+ self.description = description
32
+ self._simulation_enabled = False
33
+ self._simulation_config: Dict[str, Any] = {}
34
+
35
+ @property
36
+ def simulation_enabled(self) -> bool:
37
+ """Check if simulation is enabled for this register."""
38
+ return self._simulation_enabled
39
+
40
+ @simulation_enabled.setter
41
+ def simulation_enabled(self, enabled: bool):
42
+ """Enable or disable simulation for this register."""
43
+ self._simulation_enabled = enabled
44
+
45
+ @property
46
+ def simulation_config(self) -> Dict[str, Any]:
47
+ """Get simulation configuration."""
48
+ return self._simulation_config
49
+
50
+ @simulation_config.setter
51
+ def simulation_config(self, config: Dict[str, Any]):
52
+ """Set simulation configuration."""
53
+ self._simulation_config = config
54
+
55
+ def __repr__(self):
56
+ return f"{self.__class__.__name__}(address={self.address}, value={self.value})"
57
+
58
+
59
+ class CoilRegister(BaseRegister):
60
+ """
61
+ Coil register (1-bit, read/write).
62
+
63
+ Address range: 0-65535
64
+ Function codes: 1 (Read), 5 (Write Single), 15 (Write Multiple)
65
+ """
66
+
67
+ def __init__(self, address: int, value: bool = False, description: str = ""):
68
+ super().__init__(address, value, description)
69
+
70
+ @property
71
+ def value(self) -> bool:
72
+ return self._value
73
+
74
+ @value.setter
75
+ def value(self, val: bool):
76
+ self._value = bool(val)
77
+
78
+
79
+ class DiscreteInputRegister(BaseRegister):
80
+ """
81
+ Discrete Input register (1-bit, read-only).
82
+
83
+ Address range: 0-65535
84
+ Function codes: 2 (Read)
85
+ """
86
+
87
+ def __init__(self, address: int, value: bool = False, description: str = ""):
88
+ super().__init__(address, value, description)
89
+
90
+ @property
91
+ def value(self) -> bool:
92
+ return self._value
93
+
94
+ @value.setter
95
+ def value(self, val: bool):
96
+ self._value = bool(val)
97
+
98
+
99
+ class InputRegister(BaseRegister):
100
+ """
101
+ Input Register (16-bit, read-only).
102
+
103
+ Address range: 0-65535
104
+ Function codes: 4 (Read)
105
+ """
106
+
107
+ def __init__(self, address: int, value: int = 0, description: str = ""):
108
+ super().__init__(address, value, description)
109
+
110
+ @property
111
+ def value(self) -> int:
112
+ return self._value
113
+
114
+ @value.setter
115
+ def value(self, val: int):
116
+ self._value = int(val) & 0xFFFF
117
+
118
+
119
+ class HoldingRegister(BaseRegister):
120
+ """
121
+ Holding Register (16-bit, read/write).
122
+
123
+ Address range: 0-65535
124
+ Function codes: 3 (Read), 6 (Write Single), 16 (Write Multiple)
125
+ """
126
+
127
+ def __init__(self, address: int, value: int = 0, description: str = ""):
128
+ super().__init__(address, value, description)
129
+
130
+ @property
131
+ def value(self) -> int:
132
+ return self._value
133
+
134
+ @value.setter
135
+ def value(self, val: int):
136
+ self._value = int(val) & 0xFFFF
137
+
138
+
139
+ class ModbusRegisterMap:
140
+ """
141
+ Container for all Modbus registers.
142
+
143
+ Manages coils, discrete inputs, input registers, and holding registers.
144
+ """
145
+
146
+ def __init__(self):
147
+ self.coils: Dict[int, CoilRegister] = {}
148
+ self.discrete_inputs: Dict[int, DiscreteInputRegister] = {}
149
+ self.input_registers: Dict[int, InputRegister] = {}
150
+ self.holding_registers: Dict[int, HoldingRegister] = {}
151
+
152
+ def add_coil(self, address: int, value: bool = False, description: str = ""):
153
+ """Add a coil register."""
154
+ self.coils[address] = CoilRegister(address, value, description)
155
+
156
+ def add_discrete_input(self, address: int, value: bool = False, description: str = ""):
157
+ """Add a discrete input register."""
158
+ self.discrete_inputs[address] = DiscreteInputRegister(address, value, description)
159
+
160
+ def add_input_register(self, address: int, value: int = 0, description: str = ""):
161
+ """Add an input register."""
162
+ self.input_registers[address] = InputRegister(address, value, description)
163
+
164
+ def add_holding_register(self, address: int, value: int = 0, description: str = ""):
165
+ """Add a holding register."""
166
+ self.holding_registers[address] = HoldingRegister(address, value, description)
167
+
168
+ def get_coil(self, address: int) -> Optional[CoilRegister]:
169
+ """Get a coil by address."""
170
+ return self.coils.get(address)
171
+
172
+ def get_discrete_input(self, address: int) -> Optional[DiscreteInputRegister]:
173
+ """Get a discrete input by address."""
174
+ return self.discrete_inputs.get(address)
175
+
176
+ def get_input_register(self, address: int) -> Optional[InputRegister]:
177
+ """Get an input register by address."""
178
+ return self.input_registers.get(address)
179
+
180
+ def get_holding_register(self, address: int) -> Optional[HoldingRegister]:
181
+ """Get a holding register by address."""
182
+ return self.holding_registers.get(address)
183
+
184
+ def get_all_registers(self) -> List[BaseRegister]:
185
+ """Get all registers as a flat list."""
186
+ registers = []
187
+ registers.extend(self.coils.values())
188
+ registers.extend(self.discrete_inputs.values())
189
+ registers.extend(self.input_registers.values())
190
+ registers.extend(self.holding_registers.values())
191
+ return registers
192
+
193
+ def get_simulated_registers(self) -> List[BaseRegister]:
194
+ """Get all registers with simulation enabled."""
195
+ return [r for r in self.get_all_registers() if r.simulation_enabled]
196
+
197
+ def load_from_dict(self, data: Dict[str, list]):
198
+ """Load register map from a dictionary (parsed from JSON).
199
+
200
+ Expected format:
201
+ {
202
+ "coils": [{"address": 0, "value": false, "description": "..."}, ...],
203
+ "discrete_inputs": [...],
204
+ "input_registers": [{"address": 0, "value": 0, "description": "..."}, ...],
205
+ "holding_registers": [...]
206
+ }
207
+ """
208
+ for entry in data.get("coils", []):
209
+ self.add_coil(entry["address"], entry.get("value", False), entry.get("description", ""))
210
+ for entry in data.get("discrete_inputs", []):
211
+ self.add_discrete_input(entry["address"], entry.get("value", False), entry.get("description", ""))
212
+ for entry in data.get("input_registers", []):
213
+ self.add_input_register(entry["address"], entry.get("value", 0), entry.get("description", ""))
214
+ for entry in data.get("holding_registers", []):
215
+ self.add_holding_register(entry["address"], entry.get("value", 0), entry.get("description", ""))
216
+
217
+ @classmethod
218
+ def load_from_json(cls, filepath: str) -> "ModbusRegisterMap":
219
+ """Load register map from a JSON file.
220
+
221
+ Args:
222
+ filepath: Path to JSON file
223
+
224
+ Returns:
225
+ New ModbusRegisterMap instance with loaded data
226
+ """
227
+ with open(filepath, "r", encoding="utf-8") as f:
228
+ data = json.load(f)
229
+ register_map = cls()
230
+ register_map.load_from_dict(data)
231
+ return register_map
232
+
233
+ def create_default_map(self):
234
+ """Create a default register map with sample data."""
235
+ # Coils (0-7)
236
+ for i in range(8):
237
+ self.add_coil(i, value=(i % 2 == 0), description=f"Coil {i}")
238
+
239
+ # Discrete Inputs (0-7)
240
+ for i in range(8):
241
+ self.add_discrete_input(i, value=(i % 2 == 1), description=f"Discrete Input {i}")
242
+
243
+ # Input Registers (0-9) - Read-only simulated values
244
+ self.add_input_register(0, 1234, "Temperature")
245
+ self.add_input_register(1, 5678, "Pressure")
246
+ self.add_input_register(2, 9012, "Flow Rate")
247
+ self.add_input_register(3, 3456, "Level")
248
+ self.add_input_register(4, 7890, "Speed")
249
+ self.add_input_register(5, 1111, "Counter A")
250
+ self.add_input_register(6, 2222, "Counter B")
251
+ self.add_input_register(7, 3333, "Status Word")
252
+ self.add_input_register(8, 4444, "Error Code")
253
+ self.add_input_register(9, 5555, "System Time")
254
+
255
+ # Holding Registers (0-9) - Read/Write configuration values
256
+ self.add_holding_register(0, 100, "Setpoint 1")
257
+ self.add_holding_register(1, 200, "Setpoint 2")
258
+ self.add_holding_register(2, 300, "Gain")
259
+ self.add_holding_register(3, 400, "Integral Time")
260
+ self.add_holding_register(4, 500, "Derivative Time")
261
+ self.add_holding_register(5, 600, "Output Limit High")
262
+ self.add_holding_register(6, 700, "Output Limit Low")
263
+ self.add_holding_register(7, 800, "Alarm Setpoint 1")
264
+ self.add_holding_register(8, 900, "Alarm Setpoint 2")
265
+ self.add_holding_register(9, 1000, "Control Mode")
266
+
267
+ def print_status(self):
268
+ """Print the current status of all registers."""
269
+ print(f"\n{c('=== Register Status ===', CYAN_BOLD)}")
270
+
271
+ print(f"\n{c('Coils (Read/Write):', WHITE_BOLD)}")
272
+ for addr, coil in sorted(self.coils.items()):
273
+ state = c("ON ", GREEN_BOLD) if coil.value else c("OFF", RED_BOLD)
274
+ print(f" [{c(str(addr).rjust(5), MAGENTA)}] = {state} - {c(coil.description, DIM)}")
275
+
276
+ print(f"\n{c('Discrete Inputs (Read-Only):', WHITE_BOLD)}")
277
+ for addr, di in sorted(self.discrete_inputs.items()):
278
+ state = c("ON ", GREEN_BOLD) if di.value else c("OFF", RED_BOLD)
279
+ print(f" [{c(str(addr).rjust(5), MAGENTA)}] = {state} - {c(di.description, DIM)}")
280
+
281
+ print(f"\n{c('Input Registers (Read-Only):', WHITE_BOLD)}")
282
+ for addr, ir in sorted(self.input_registers.items()):
283
+ print(f" [{c(str(addr).rjust(5), MAGENTA)}] = {c(str(ir.value).rjust(5), CYAN_BOLD)} - {c(ir.description, DIM)}")
284
+
285
+ print(f"\n{c('Holding Registers (Read/Write):', WHITE_BOLD)}")
286
+ for addr, hr in sorted(self.holding_registers.items()):
287
+ print(f" [{c(str(addr).rjust(5), MAGENTA)}] = {c(str(hr.value).rjust(5), CYAN_BOLD)} - {c(hr.description, DIM)}")
288
+ print(f"{c('========================', CYAN_BOLD)}\n")
@@ -0,0 +1,158 @@
1
+ """
2
+ Modbus RTU/ASCII Server implementation.
3
+
4
+ This module provides a Modbus serial slave server that handles
5
+ serial communication using the Modbus RTU or ASCII protocol.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from pymodbus.datastore import (
12
+ ModbusSequentialDataBlock,
13
+ ModbusSlaveContext,
14
+ ModbusServerContext,
15
+ )
16
+ from pymodbus.device import ModbusDeviceIdentification
17
+ from pymodbus.server import StartSerialServer
18
+ from pymodbus.framer import Framer
19
+
20
+ from .registers import ModbusRegisterMap
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ModbusSerialServer:
26
+ """Modbus Serial Slave Server (RTU or ASCII)."""
27
+
28
+ def __init__(
29
+ self,
30
+ register_map: ModbusRegisterMap,
31
+ port: str = "/dev/ttyUSB0",
32
+ baudrate: int = 9600,
33
+ parity: str = "N",
34
+ stopbits: int = 1,
35
+ bytesize: int = 8,
36
+ timeout: float = 1.0,
37
+ slave_id: int = 1,
38
+ framer: str = "rtu",
39
+ inter_char_delay: Optional[float] = None,
40
+ ):
41
+ self.register_map = register_map
42
+ self.port = port
43
+ self.baudrate = baudrate
44
+ self.parity = parity
45
+ self.stopbits = stopbits
46
+ self.bytesize = bytesize
47
+ self.timeout = timeout
48
+ self.slave_id = slave_id
49
+ self.framer = framer
50
+ self.inter_char_delay = inter_char_delay
51
+ self._server = None
52
+ self._slave_context = None
53
+
54
+ def _create_data_blocks(self):
55
+ """Create Modbus data blocks from register map."""
56
+ max_coil_addr = max(self.register_map.coils.keys()) + 1 if self.register_map.coils else 10
57
+ coil_values = [False] * (max_coil_addr + 1)
58
+ for addr, coil in self.register_map.coils.items():
59
+ coil_values[addr] = coil.value
60
+ coil_block = ModbusSequentialDataBlock(1, coil_values)
61
+
62
+ max_di_addr = max(self.register_map.discrete_inputs.keys()) + 1 if self.register_map.discrete_inputs else 10
63
+ di_values = [False] * (max_di_addr + 1)
64
+ for addr, di in self.register_map.discrete_inputs.items():
65
+ di_values[addr] = di.value
66
+ di_block = ModbusSequentialDataBlock(1, di_values)
67
+
68
+ max_hr_addr = max(self.register_map.holding_registers.keys()) + 1 if self.register_map.holding_registers else 10
69
+ hr_values = [0] * (max_hr_addr + 1)
70
+ for addr, hr in self.register_map.holding_registers.items():
71
+ hr_values[addr] = hr.value
72
+ hr_block = ModbusSequentialDataBlock(1, hr_values)
73
+
74
+ max_ir_addr = max(self.register_map.input_registers.keys()) + 1 if self.register_map.input_registers else 10
75
+ ir_values = [0] * (max_ir_addr + 1)
76
+ for addr, ir in self.register_map.input_registers.items():
77
+ ir_values[addr] = ir.value
78
+ ir_block = ModbusSequentialDataBlock(1, ir_values)
79
+
80
+ return coil_block, di_block, hr_block, ir_block
81
+
82
+ def _create_device_identification(self) -> ModbusDeviceIdentification:
83
+ identification = ModbusDeviceIdentification()
84
+ identification.VendorName = "Modbus Slave Simulator"
85
+ identification.ProductCode = "MBS-001"
86
+ identification.VendorUrl = "https://github.com/yourusername/modbus-slave-sim"
87
+ identification.ProductName = "Modbus Slave Sim"
88
+ identification.ModelName = "Virtual Slave Device"
89
+ identification.MajorMinorRevision = "1.0.0"
90
+ return identification
91
+
92
+ def start(self):
93
+ """Start the Modbus serial server."""
94
+ coil_block, di_block, hr_block, ir_block = self._create_data_blocks()
95
+
96
+ self._slave_context = ModbusSlaveContext(
97
+ co=coil_block, di=di_block, hr=hr_block, ir=ir_block,
98
+ )
99
+ server_context = ModbusServerContext(
100
+ slaves=self._slave_context, single=True,
101
+ )
102
+
103
+ identification = self._create_device_identification()
104
+
105
+ framer_map = {"rtu": Framer.RTU, "ascii": Framer.ASCII}
106
+ pymodbus_framer = framer_map.get(self.framer, Framer.RTU)
107
+ protocol = "ASCII" if self.framer == "ascii" else "RTU"
108
+ logger.info(f"Starting Modbus {protocol} server on {self.port}")
109
+ logger.info(f"Baudrate: {self.baudrate}, Parity: {self.parity}, Stopbits: {self.stopbits}")
110
+ logger.info(f"Slave ID: {self.slave_id}")
111
+
112
+ try:
113
+ kwargs = dict(
114
+ context=server_context,
115
+ identity=identification,
116
+ port=self.port,
117
+ framer=pymodbus_framer,
118
+ baudrate=self.baudrate,
119
+ parity=self.parity,
120
+ stopbits=self.stopbits,
121
+ bytesize=self.bytesize,
122
+ timeout=self.timeout,
123
+ )
124
+ if self.inter_char_delay is not None:
125
+ kwargs["inter_char_delay"] = self.inter_char_delay
126
+ self._server = StartSerialServer(**kwargs)
127
+ except Exception as e:
128
+ logger.error(f"Failed to start {protocol} server: {e}")
129
+ raise
130
+
131
+ def stop(self):
132
+ if self._server:
133
+ self._server.shutdown()
134
+ self._server = None
135
+ logger.info("Modbus serial server stopped")
136
+
137
+ def set_register(self, fc, address, value):
138
+ if self._slave_context:
139
+ self._slave_context.setValues(fc, address, [value])
140
+
141
+ def set_registers(self, fc, address, values):
142
+ if self._slave_context:
143
+ self._slave_context.setValues(fc, address, values)
144
+
145
+ def update_data_blocks(self):
146
+ if not self._slave_context:
147
+ return
148
+ for addr, coil in self.register_map.coils.items():
149
+ self._slave_context.setValues(0x01, addr, [coil.value])
150
+ for addr, di in self.register_map.discrete_inputs.items():
151
+ self._slave_context.setValues(0x02, addr, [di.value])
152
+ for addr, hr in self.register_map.holding_registers.items():
153
+ self._slave_context.setValues(0x03, addr, [hr.value])
154
+ for addr, ir in self.register_map.input_registers.items():
155
+ self._slave_context.setValues(0x04, addr, [ir.value])
156
+
157
+ def is_running(self) -> bool:
158
+ return self._server is not None