Skip to content

Commit ffa6654

Browse files
author
Mateusz Frasunkiewicz
committed
Add ModbusFrameGenerator class to generate and parse Modbus frames without actual communication.
1 parent f7271cd commit ffa6654

File tree

4 files changed

+145
-3
lines changed

4 files changed

+145
-3
lines changed

examples/frame_generator.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env python3
2+
"""Modbus Frame Generator Example.
3+
4+
This example shows how to use ModbusFrameGenerator to create and parse
5+
Modbus frames without actual communication.
6+
"""
7+
from pymodbus.client import ModbusFrameGenerator
8+
from pymodbus.framer import FramerType
9+
10+
11+
def run_frame_generator():
12+
"""Run frame generator example."""
13+
print("### Modbus Frame Generator Example ###")
14+
15+
# Create generator instance (RTU mode, slave address 1)
16+
generator = ModbusFrameGenerator(framer=FramerType.RTU, slave=1)
17+
18+
print("\n1. Generate Request Frames:")
19+
print("-" * 40)
20+
21+
# Read Coils Request (FC=1)
22+
read_coils_frame = generator.read_coils(address=100, count=8)
23+
print(f"Read Coils (addr=100, count=8):")
24+
print(f" Frame: {read_coils_frame.hex()}")
25+
26+
# Write Single Coil Request (FC=5)
27+
write_coil_frame = generator.write_coil(address=100, value=True)
28+
print(f"\nWrite Single Coil (addr=100, value=ON):")
29+
print(f" Frame: {write_coil_frame.hex()}")
30+
31+
# Read Holding Registers Request (FC=3)
32+
read_regs_frame = generator.read_holding_registers(address=100, count=2)
33+
print(f"\nRead Holding Registers (addr=100, count=2):")
34+
print(f" Frame: {read_regs_frame.hex()}")
35+
36+
# Write Single Register Request (FC=6)
37+
write_reg_frame = generator.write_register(address=100, value=1234)
38+
print(f"\nWrite Single Register (addr=100, value=1234):")
39+
print(f" Frame: {write_reg_frame.hex()}")
40+
41+
print("\n2. Parse Response Frames:")
42+
print("-" * 40)
43+
44+
# Parse Read Coils Response (with CRC)
45+
coils_response = b"\x01\x01\x01\xCD\x81\x88" # slave 1, FC 1, 1 byte, value 0xCD + CRC
46+
decoded = generator.parse_response(coils_response)
47+
print("Read Coils Response:")
48+
print(f" Raw: {coils_response.hex()}")
49+
print(f" Decoded: {decoded}")
50+
51+
# Parse Read Holding Registers Response (with CRC)
52+
regs_response = b"\x01\x03\x04\x00\x0A\x00\x0B\x41\x83" # slave 1, FC 3, 4 bytes, values [10, 11] + CRC
53+
decoded = generator.parse_response(regs_response)
54+
print("\nRead Registers Response:")
55+
print(f" Raw: {regs_response.hex()}")
56+
print(f" Decoded: {decoded}")
57+
58+
59+
if __name__ == "__main__":
60+
run_frame_generator()

pymodbus/client/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111
"ModbusTcpClient",
1212
"ModbusTlsClient",
1313
"ModbusUdpClient",
14+
"ModbusFrameGenerator",
1415
]
1516

1617
from pymodbus.client.base import ModbusBaseClient, ModbusBaseSyncClient
17-
from pymodbus.client.serial import AsyncModbusSerialClient, ModbusSerialClient
18+
from pymodbus.client.serial import (
19+
AsyncModbusSerialClient,
20+
ModbusSerialClient,
21+
ModbusFrameGenerator,
22+
)
1823
from pymodbus.client.tcp import AsyncModbusTcpClient, ModbusTcpClient
1924
from pymodbus.client.tls import AsyncModbusTlsClient, ModbusTlsClient
2025
from pymodbus.client.udp import AsyncModbusUdpClient, ModbusUdpClient

pymodbus/client/serial.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
from pymodbus.client.base import ModbusBaseClient, ModbusBaseSyncClient
1111
from pymodbus.exceptions import ConnectionException
12-
from pymodbus.framer import FramerType
12+
from pymodbus.framer import FramerType, FramerAscii, FramerRTU
1313
from pymodbus.logging import Log
14-
from pymodbus.pdu import ModbusPDU
14+
from pymodbus.pdu import ModbusPDU, DecodePDU
1515
from pymodbus.transport import CommParams, CommType
1616

1717

@@ -318,3 +318,79 @@ def __repr__(self):
318318
f"<{self.__class__.__name__} at {hex(id(self))} socket={self.socket}, "
319319
f"framer={self.framer}, timeout={self.comm_params.timeout_connect}>"
320320
)
321+
322+
323+
class ModbusFrameGenerator(ModbusSerialClient):
324+
"""A class to generate and parse Modbus frames without actual communication.
325+
326+
This class provides a way to generate Modbus frames and parse responses
327+
without actual serial communication.
328+
329+
:param framer: The framer to use (RTU or ASCII)
330+
:param slave: Default slave address
331+
332+
Example::
333+
from pymodbus.client import ModbusFrameGenerator
334+
from pymodbus.framer import FramerType
335+
336+
generator = ModbusFrameGenerator(framer=FramerType.RTU, slave=1)
337+
request = generator.read_coils(1, 10)
338+
print(f"Generated frame: {request.hex()}")
339+
"""
340+
341+
def __init__(
342+
self,
343+
framer: FramerType = FramerType.RTU,
344+
slave: int = 0x00,
345+
) -> None:
346+
"""Initialize the frame generator."""
347+
if framer not in [FramerType.ASCII, FramerType.RTU]:
348+
raise TypeError("Only FramerType RTU/ASCII allowed.")
349+
super().__init__(
350+
port="DUMMY",
351+
framer=framer,
352+
)
353+
self.slave = slave
354+
self.socket = None # Prevent actual serial operations
355+
self.comm_params.timeout_connect = 0 # Prevent retries
356+
self.retries = 0 # Disable retries
357+
358+
def build_request(self, request: ModbusPDU) -> bytes:
359+
"""Build a complete frame from a request PDU.
360+
361+
:param request: The request PDU to frame
362+
:returns: The framed request
363+
"""
364+
request.dev_id = self.slave
365+
return self.framer.buildFrame(request)
366+
367+
def execute(self, no_response_expected: bool, request: ModbusPDU) -> bytes:
368+
"""Override execute to only generate frame without sending."""
369+
return self.build_request(request)
370+
371+
def send(self, request: bytes, addr: tuple | None = None) -> int:
372+
"""Don't actually send, just return the length of the request."""
373+
return len(request) if request else 0
374+
375+
def recv(self, size: int | None) -> bytes:
376+
"""Dummy receive - always returns empty bytes."""
377+
return b""
378+
379+
def close(self):
380+
"""Dummy close - does nothing."""
381+
pass
382+
383+
def is_socket_open(self) -> bool:
384+
"""Always returns False since we're not actually connecting."""
385+
return False
386+
387+
def parse_response(self, response: bytes) -> ModbusPDU:
388+
"""Parse a response frame into a PDU.
389+
390+
:param response: The response frame to parse
391+
:returns: The decoded PDU
392+
"""
393+
used_len, pdu = self.framer.processIncomingFrame(response)
394+
if not pdu:
395+
raise ValueError("Failed to decode response")
396+
return pdu

test_frame_generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

0 commit comments

Comments
 (0)