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,318 @@
1
+ """
2
+ Simulation engine for dynamic register value generation.
3
+
4
+ This module provides different simulation types for generating dynamic
5
+ values in Modbus registers:
6
+ - Sine wave
7
+ - Linear increment/decrement
8
+ - Random values
9
+ - Constant value
10
+ - Counter
11
+ """
12
+
13
+ import math
14
+ import random
15
+ import threading
16
+ import time
17
+ from enum import Enum
18
+ from typing import Any, Callable, Dict, List, Optional
19
+
20
+ from .registers import BaseRegister, ModbusRegisterMap
21
+ from .colors import c, RED_BOLD
22
+
23
+
24
+ class SimulationType(Enum):
25
+ """Available simulation types."""
26
+ CONSTANT = "constant"
27
+ SINE = "sine"
28
+ LINEAR = "linear"
29
+ RANDOM = "random"
30
+ COUNTER = "counter"
31
+
32
+
33
+ class SimulationConfig:
34
+ """Configuration for a single register simulation."""
35
+
36
+ def __init__(
37
+ self,
38
+ sim_type: SimulationType,
39
+ min_value: float = 0,
40
+ max_value: float = 10000,
41
+ amplitude: float = 1000,
42
+ frequency: float = 0.1,
43
+ offset: float = 5000,
44
+ step: float = 1,
45
+ is_signed: bool = False,
46
+ ):
47
+ """
48
+ Initialize simulation configuration.
49
+
50
+ Args:
51
+ sim_type: Type of simulation
52
+ min_value: Minimum value (for random, linear wrapping)
53
+ max_value: Maximum value (for random, linear wrapping)
54
+ amplitude: Amplitude for sine wave
55
+ frequency: Frequency in Hz for sine wave
56
+ offset: Offset (center) for sine wave
57
+ step: Step size for linear and counter
58
+ is_signed: Whether values should be treated as signed
59
+ """
60
+ self.sim_type = sim_type
61
+ self.min_value = min_value
62
+ self.max_value = max_value
63
+ self.amplitude = amplitude
64
+ self.frequency = frequency
65
+ self.offset = offset
66
+ self.step = step
67
+ self.is_signed = is_signed
68
+
69
+ def to_dict(self) -> Dict[str, Any]:
70
+ """Convert to dictionary."""
71
+ return {
72
+ "sim_type": self.sim_type.value,
73
+ "min_value": self.min_value,
74
+ "max_value": self.max_value,
75
+ "amplitude": self.amplitude,
76
+ "frequency": self.frequency,
77
+ "offset": self.offset,
78
+ "step": self.step,
79
+ "is_signed": self.is_signed,
80
+ }
81
+
82
+
83
+ class SimulationEngine:
84
+ """
85
+ Engine for simulating dynamic values in Modbus registers.
86
+
87
+ Runs in a background thread and updates register values based on
88
+ their simulation configuration.
89
+ """
90
+
91
+ def __init__(self, register_map: ModbusRegisterMap):
92
+ """
93
+ Initialize the simulation engine.
94
+
95
+ Args:
96
+ register_map: The register map to simulate values for
97
+ """
98
+ self.register_map = register_map
99
+ self._running = False
100
+ self._thread: Optional[threading.Thread] = None
101
+ self._interval = 0.1 # Update interval in seconds (100ms)
102
+ self._start_time = time.time()
103
+ self._counters: Dict[int, float] = {}
104
+
105
+ @property
106
+ def interval(self) -> float:
107
+ """Get the update interval."""
108
+ return self._interval
109
+
110
+ @interval.setter
111
+ def interval(self, seconds: float):
112
+ """Set the update interval."""
113
+ self._interval = max(0.01, seconds) # Minimum 10ms
114
+
115
+ def configure_simulation(
116
+ self,
117
+ address: int,
118
+ reg_type: str,
119
+ config: SimulationConfig,
120
+ ):
121
+ """
122
+ Configure simulation for a specific register.
123
+
124
+ Args:
125
+ address: Register address
126
+ reg_type: Register type ('coils', 'discrete_inputs', 'input_registers', 'holding_registers')
127
+ config: Simulation configuration
128
+ """
129
+ register = self._get_register(address, reg_type)
130
+ if register:
131
+ register.simulation_enabled = True
132
+ register.simulation_config = config.to_dict()
133
+ self._counters[address] = 0.0
134
+
135
+ def remove_simulation(self, address: int, reg_type: str):
136
+ """Remove simulation from a register."""
137
+ register = self._get_register(address, reg_type)
138
+ if register:
139
+ register.simulation_enabled = False
140
+ register.simulation_config = {}
141
+ self._counters.pop(address, None)
142
+
143
+ def _get_register(self, address: int, reg_type: str) -> Optional[BaseRegister]:
144
+ """Get register by type and address."""
145
+ reg_map = {
146
+ "coils": self.register_map.coils,
147
+ "discrete_inputs": self.register_map.discrete_inputs,
148
+ "input_registers": self.register_map.input_registers,
149
+ "holding_registers": self.register_map.holding_registers,
150
+ }
151
+ return reg_map.get(reg_type, {}).get(address)
152
+
153
+ def _calculate_value(
154
+ self,
155
+ config: SimulationConfig,
156
+ address: int,
157
+ current_time: float,
158
+ ) -> Any:
159
+ """
160
+ Calculate the next value based on simulation type.
161
+
162
+ Args:
163
+ config: Simulation configuration
164
+ address: Register address
165
+ current_time: Current timestamp
166
+
167
+ Returns:
168
+ Calculated value
169
+ """
170
+ elapsed = current_time - self._start_time
171
+
172
+ if config.sim_type == SimulationType.SINE:
173
+ # Sine wave: offset + amplitude * sin(2π * frequency * time)
174
+ value = config.offset + config.amplitude * math.sin(
175
+ 2 * math.pi * config.frequency * elapsed
176
+ )
177
+ return int(value) & 0xFFFF
178
+
179
+ elif config.sim_type == SimulationType.LINEAR:
180
+ # Linear increment with wrapping
181
+ counter = self._counters.get(address, 0.0)
182
+ counter += config.step
183
+ if counter > config.max_value:
184
+ counter = config.min_value
185
+ self._counters[address] = counter
186
+ return int(counter) & 0xFFFF
187
+
188
+ elif config.sim_type == SimulationType.RANDOM:
189
+ # Random value within range
190
+ value = random.randint(int(config.min_value), int(config.max_value))
191
+ return value & 0xFFFF
192
+
193
+ elif config.sim_type == SimulationType.COUNTER:
194
+ # Monotonically increasing counter
195
+ counter = self._counters.get(address, 0.0)
196
+ counter += config.step
197
+ if counter > 65535:
198
+ counter = config.min_value
199
+ self._counters[address] = counter
200
+ return int(counter) & 0xFFFF
201
+
202
+ elif config.sim_type == SimulationType.CONSTANT:
203
+ # Constant value (no change)
204
+ return int(config.offset) & 0xFFFF
205
+
206
+ return 0
207
+
208
+ def _update_registers(self):
209
+ """Update all simulated registers with new values."""
210
+ current_time = time.time()
211
+
212
+ for register in self.register_map.get_simulated_registers():
213
+ try:
214
+ config_dict = register.simulation_config
215
+ if not config_dict:
216
+ continue
217
+
218
+ config = SimulationConfig(
219
+ sim_type=SimulationType(config_dict.get("sim_type", "constant")),
220
+ min_value=config_dict.get("min_value", 0),
221
+ max_value=config_dict.get("max_value", 10000),
222
+ amplitude=config_dict.get("amplitude", 1000),
223
+ frequency=config_dict.get("frequency", 0.1),
224
+ offset=config_dict.get("offset", 5000),
225
+ step=config_dict.get("step", 1),
226
+ is_signed=config_dict.get("is_signed", False),
227
+ )
228
+
229
+ new_value = self._calculate_value(config, register.address, current_time)
230
+ register.value = new_value
231
+
232
+ except Exception as e:
233
+ print(c(f"Simulation error for register {register.address}: {e}", RED_BOLD))
234
+
235
+ def _simulation_loop(self):
236
+ """Main simulation loop running in background thread."""
237
+ while self._running:
238
+ self._update_registers()
239
+ time.sleep(self._interval)
240
+
241
+ def start(self):
242
+ """Start the simulation engine."""
243
+ if self._running:
244
+ return
245
+
246
+ self._running = True
247
+ self._start_time = time.time()
248
+ self._thread = threading.Thread(
249
+ target=self._simulation_loop,
250
+ daemon=True,
251
+ name="SimulationEngine"
252
+ )
253
+ self._thread.start()
254
+
255
+ def stop(self):
256
+ """Stop the simulation engine."""
257
+ self._running = False
258
+ if self._thread:
259
+ self._thread.join(timeout=2.0)
260
+ self._thread = None
261
+
262
+ def is_running(self) -> bool:
263
+ """Check if the simulation engine is running."""
264
+ return self._running
265
+
266
+ def setup_default_simulations(self):
267
+ """
268
+ Set up default simulations for demonstration purposes.
269
+
270
+ Configures:
271
+ - Input Registers 0-4: Sine waves with different frequencies
272
+ - Input Register 5-6: Counter
273
+ - Holding Register 9: Linear increment
274
+ """
275
+ # Input registers: sine waves
276
+ for i in range(5):
277
+ self.configure_simulation(
278
+ i, "input_registers",
279
+ SimulationConfig(
280
+ sim_type=SimulationType.SINE,
281
+ amplitude=2000 + i * 500,
282
+ frequency=0.05 + i * 0.02,
283
+ offset=5000 + i * 1000,
284
+ )
285
+ )
286
+
287
+ # Input registers: counters
288
+ for i in range(5, 7):
289
+ self.configure_simulation(
290
+ i, "input_registers",
291
+ SimulationConfig(
292
+ sim_type=SimulationType.COUNTER,
293
+ step=1,
294
+ )
295
+ )
296
+
297
+ # Holding register 9: linear increment
298
+ self.configure_simulation(
299
+ 9, "holding_registers",
300
+ SimulationConfig(
301
+ sim_type=SimulationType.LINEAR,
302
+ min_value=0,
303
+ max_value=1000,
304
+ step=10,
305
+ )
306
+ )
307
+
308
+ def get_simulation_status(self) -> List[Dict[str, Any]]:
309
+ """Get status of all simulated registers."""
310
+ status = []
311
+ for register in self.register_map.get_simulated_registers():
312
+ status.append({
313
+ "address": register.address,
314
+ "value": register.value,
315
+ "description": register.description,
316
+ "config": register.simulation_config,
317
+ })
318
+ return status
@@ -0,0 +1,178 @@
1
+ """
2
+ Modbus TCP Server implementation.
3
+
4
+ This module provides a Modbus TCP slave server that handles
5
+ incoming TCP connections and processes Modbus requests.
6
+ Supports standard Modbus TCP (default) as well as RTU/ASCII
7
+ over TCP for virtual serial port simulation.
8
+ """
9
+
10
+ import logging
11
+ import threading
12
+ from typing import Optional
13
+
14
+ from pymodbus.datastore import (
15
+ ModbusSequentialDataBlock,
16
+ ModbusSlaveContext,
17
+ ModbusServerContext,
18
+ )
19
+ from pymodbus.device import ModbusDeviceIdentification
20
+ from pymodbus.server import StartTcpServer
21
+ from pymodbus.framer import Framer
22
+
23
+ from .registers import ModbusRegisterMap
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ModbusTCPServer:
29
+ """
30
+ Modbus TCP Slave Server.
31
+
32
+ Provides a Modbus TCP server that simulates a slave device with
33
+ configurable registers and optional simulation.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ register_map: ModbusRegisterMap,
39
+ host: str = "0.0.0.0",
40
+ port: int = 502,
41
+ slave_id: int = 1,
42
+ framer: str = "tcp",
43
+ ):
44
+ self.register_map = register_map
45
+ self.host = host
46
+ self.port = port
47
+ self.slave_id = slave_id
48
+ self.framer = framer
49
+ self._server = None
50
+ self._slave_context = None
51
+
52
+ def _create_data_blocks(self):
53
+ """Create Modbus data blocks from register map."""
54
+ max_coil_addr = max(self.register_map.coils.keys()) + 1 if self.register_map.coils else 10
55
+ coil_values = [False] * (max_coil_addr + 1)
56
+ for addr, coil in self.register_map.coils.items():
57
+ coil_values[addr] = coil.value
58
+ coil_block = ModbusSequentialDataBlock(1, coil_values)
59
+
60
+ max_di_addr = max(self.register_map.discrete_inputs.keys()) + 1 if self.register_map.discrete_inputs else 10
61
+ di_values = [False] * (max_di_addr + 1)
62
+ for addr, di in self.register_map.discrete_inputs.items():
63
+ di_values[addr] = di.value
64
+ di_block = ModbusSequentialDataBlock(1, di_values)
65
+
66
+ max_hr_addr = max(self.register_map.holding_registers.keys()) + 1 if self.register_map.holding_registers else 10
67
+ hr_values = [0] * (max_hr_addr + 1)
68
+ for addr, hr in self.register_map.holding_registers.items():
69
+ hr_values[addr] = hr.value
70
+ hr_block = ModbusSequentialDataBlock(1, hr_values)
71
+
72
+ max_ir_addr = max(self.register_map.input_registers.keys()) + 1 if self.register_map.input_registers else 10
73
+ ir_values = [0] * (max_ir_addr + 1)
74
+ for addr, ir in self.register_map.input_registers.items():
75
+ ir_values[addr] = ir.value
76
+ ir_block = ModbusSequentialDataBlock(1, ir_values)
77
+
78
+ return coil_block, di_block, hr_block, ir_block
79
+
80
+ def _create_device_identification(self) -> ModbusDeviceIdentification:
81
+ identification = ModbusDeviceIdentification()
82
+ identification.VendorName = "Modbus Slave Simulator"
83
+ identification.ProductCode = "MBS-001"
84
+ identification.VendorUrl = "https://github.com/yourusername/modbus-slave-sim"
85
+ identification.ProductName = "Modbus Slave Sim"
86
+ identification.ModelName = "Virtual Slave Device"
87
+ identification.MajorMinorRevision = "1.0.0"
88
+ return identification
89
+
90
+ def start(self):
91
+ """Start the Modbus TCP server in a background thread."""
92
+ coil_block, di_block, hr_block, ir_block = self._create_data_blocks()
93
+
94
+ self._slave_context = ModbusSlaveContext(
95
+ co=coil_block, di=di_block, hr=hr_block, ir=ir_block,
96
+ )
97
+ server_context = ModbusServerContext(
98
+ slaves=self._slave_context, single=True,
99
+ )
100
+
101
+ identification = self._create_device_identification()
102
+
103
+ protocol_name = self._get_protocol_name()
104
+ logger.info(f"Starting Modbus {protocol_name} server on {self.host}:{self.port}")
105
+ logger.info(f"Slave ID: {self.slave_id}")
106
+
107
+ framer_map = {
108
+ "tcp": Framer.SOCKET,
109
+ "rtu": Framer.RTU,
110
+ "ascii": Framer.ASCII,
111
+ "rtu-tcp": Framer.RTU,
112
+ "ascii-tcp": Framer.ASCII,
113
+ }
114
+ pymodbus_framer = framer_map.get(self.framer, Framer.RTU)
115
+
116
+ def _run_server():
117
+ try:
118
+ self._server = StartTcpServer(
119
+ context=server_context,
120
+ identity=identification,
121
+ address=(self.host, self.port),
122
+ framer=pymodbus_framer,
123
+ )
124
+ except Exception as e:
125
+ logger.error(f"Failed to start TCP server: {e}")
126
+ raise
127
+
128
+ self._thread = threading.Thread(target=_run_server, daemon=True, name="ModbusTCPServer")
129
+ self._thread.start()
130
+
131
+ def _get_protocol_name(self) -> str:
132
+ names = {"tcp": "TCP", "rtu": "RTU (Serial)", "ascii": "ASCII (Serial)",
133
+ "rtu-tcp": "RTU over TCP", "ascii-tcp": "ASCII over TCP"}
134
+ return names.get(self.framer, self.framer)
135
+
136
+ def stop(self):
137
+ if self._server:
138
+ self._server.shutdown()
139
+ self._server = None
140
+ logger.info("Modbus TCP server stopped")
141
+
142
+ def set_register(self, fc, address, value):
143
+ """Set a register value via the slave context.
144
+
145
+ Args:
146
+ fc: Function code (3=HR, 4=IR, 1=CO, 2=DI)
147
+ address: Register address
148
+ value: New value
149
+ """
150
+ if self._slave_context:
151
+ self._slave_context.setValues(fc, address, [value])
152
+
153
+ def set_registers(self, fc, address, values):
154
+ """Set multiple register values via the slave context.
155
+
156
+ Args:
157
+ fc: Function code (3=HR, 4=IR, 1=CO, 2=DI)
158
+ address: Starting register address
159
+ values: List of new values
160
+ """
161
+ if self._slave_context:
162
+ self._slave_context.setValues(fc, address, values)
163
+
164
+ def update_data_blocks(self):
165
+ """Update server data blocks from register map."""
166
+ if not self._slave_context:
167
+ return
168
+ for addr, coil in self.register_map.coils.items():
169
+ self._slave_context.setValues(0x01, addr, [coil.value])
170
+ for addr, di in self.register_map.discrete_inputs.items():
171
+ self._slave_context.setValues(0x02, addr, [di.value])
172
+ for addr, hr in self.register_map.holding_registers.items():
173
+ self._slave_context.setValues(0x03, addr, [hr.value])
174
+ for addr, ir in self.register_map.input_registers.items():
175
+ self._slave_context.setValues(0x04, addr, [ir.value])
176
+
177
+ def is_running(self) -> bool:
178
+ return self._server is not None