Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions API_changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ API changes 3.12.0
------------------
- when using no_response_expected=, the call returns None
- remove idle_time() from sync client since it is void
- ModbusSerialServer new parameter "allow_multiple_devices"
which gives limited multipoint support with baudrate < 19200 and a good RS485 line.

API changes 3.11.0
------------------
Expand Down
2 changes: 1 addition & 1 deletion doc/source/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ communication in 2 versions:
synchronous servers are just an interface layer allowing synchronous
applications to use the server as if it was synchronous.

*Warning* The current framer implementation does not support running the server on a shared rs485 line (multipoint).
*Warning* The current serial server implementation offer only limited support for running the server on a shared rs485 line (multipoint).

.. automodule:: pymodbus.server
:members:
Expand Down
2 changes: 1 addition & 1 deletion examples/server_datamodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def define_datamodel():
# Define a group of holding/input registers (remark NO difference between shared and non-shared)
#block3 = SimData(10, 1, 123.4, datatype=DataType.FLOAT32)
#block4 = SimData(17, count=5, values=123, datatype=DataType.INT64)
block5 = SimData(27, 1, "Hello ", datatype=DataType.STRING)
block5 = SimData(1027, 1, "Hello ", datatype=DataType.STRING)

block_def = SimData(0, count=1000, datatype=DataType.REGISTERS)

Expand Down
37 changes: 11 additions & 26 deletions pymodbus/framer/rtu.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class FramerRTU(FramerBase):

For server (Multidrop line --> devices in parallel)
- only 1 request allowed (master controlled protocol)
- other devices will send responses
- other devices will send responses (unknown dev_id)
- the client (master) may retransmit but in larger time intervals

this means decoding is always exactly 1 frame request, however some requests
Expand All @@ -47,38 +47,18 @@ class FramerRTU(FramerBase):
Recovery from bad cabling and unstable USB etc is important,
the following scenarios is possible:

- garble data before frame
- garble data in frame
- garble data after frame
- data in frame garbled (wrong CRC)
- garble data before frame (extra data preceding)
- garble data in frame (wrong CRC)
- garble data after frame (extra data after correct frame)

decoding assumes the frame is sound, and if not enters a hunting mode.

The 3.5 byte transmission time at the slowest speed 1.200Bps is 31ms.
Device drivers will typically flush buffer after 10ms of silence.
If no data is received for 50ms the transmission / frame can be considered
complete.

The following table is a listing of the baud wait times for the specified
baud rates::

------------------------------------------------------------------
Baud 1.5c (18 bits) 3.5c (38 bits)
------------------------------------------------------------------
1200 13333.3 us 31666.7 us
4800 3333.3 us 7916.7 us
9600 1666.7 us 3958.3 us
19200 833.3 us 1979.2 us
38400 416.7 us 989.6 us
------------------------------------------------------------------
1 Byte = start + 8 bits + parity + stop = 11 bits
(1/Baud)(bits) = delay seconds

.. Danger:: Current framerRTU does not support running the server on a multipoint rs485 line.
.. Danger:: framerRTU only offer limited support for running the server in parallel with other devices.

"""

MIN_SIZE = 4 # <device id><function code><crc 2 bytes>
device_ids: list[int] = [] # will be converted to instance variable

@classmethod
def generate_crc16_table(cls) -> list[int]:
Expand All @@ -99,6 +79,9 @@ def generate_crc16_table(cls) -> list[int]:
return result
crc16_table: list[int] = [0]

def setMultidrop(self, device_ids: list[int]):
"""Activate multidrop support."""
self.device_ids = device_ids

def decode(self, data: bytes) -> tuple[int, int, int, bytes]:
"""Decode ADU."""
Expand All @@ -108,6 +91,8 @@ def decode(self, data: bytes) -> tuple[int, int, int, bytes]:
Log.debug("Short frame: {} wait for more data", data, ":hex")
return 0, 0, 0, self.EMPTY
dev_id = int(data[used_len])
if self.device_ids and dev_id not in self.device_ids:
return data_len, 0, 0, self.EMPTY
if not (pdu_class := self.decoder.lookupPduClass(data[used_len:])):
continue
if not (size := pdu_class.calculateRtuFrameSize(data[used_len:])):
Expand Down
1 change: 1 addition & 0 deletions pymodbus/server/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def __init__( # pylint: disable=too-many-arguments
self.trace_packet = trace_packet
self.trace_pdu = trace_pdu
self.trace_connect = trace_connect
self.allow_multiple_devices = False
if isinstance(identity, ModbusDeviceIdentification):
self.control.Identity.update(identity)

Expand Down
6 changes: 4 additions & 2 deletions pymodbus/server/requesthandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ def __init__(self, owner, trace_packet, trace_pdu, trace_connect):
handle_local_echo=owner.comm_params.handle_local_echo,
)
self.server = owner
self.framer = self.server.framer(self.server.decoder)
self.running = False
framer = owner.framer(owner.decoder)
if owner.allow_multiple_devices:
framer.setMultidrop(owner.context.device_ids())
super().__init__(
params,
self.framer,
framer,
0,
True,
trace_packet,
Expand Down
13 changes: 11 additions & 2 deletions pymodbus/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,10 @@ def __init__(
:param trace_pdu: Called with PDU received/to be sent
:param trace_connect: Called when connected/disconnected
:param custom_pdu: list of ModbusPDU custom classes
:param allow_multiple_devices: True if the rs485 have multiple devices connected.
**Remark** only works with baudrates <= 38.400 and with an error free RS485.
"""
baudrate = kwargs.get("baudrate", 19200)
params = CommParams(
comm_type=CommType.SERIAL,
comm_name="server_listener",
Expand All @@ -260,9 +263,9 @@ def __init__(
source_address=(kwargs.get("port", 0), 0),
bytesize=kwargs.get("bytesize", 8),
parity=kwargs.get("parity", "N"),
baudrate=kwargs.get("baudrate", 19200),
baudrate=baudrate,
stopbits=kwargs.get("stopbits", 1),
handle_local_echo=kwargs.get("handle_local_echo", False)
handle_local_echo=kwargs.get("handle_local_echo", False),
)
super().__init__(
params,
Expand All @@ -276,3 +279,9 @@ def __init__(
trace_connect,
custom_pdu,
)
self.allow_multiple_devices = kwargs.get("allow_multiple_devices", False)
if self.allow_multiple_devices:
if baudrate > 38400:
raise TypeError("allow_multiple_devices only allowed with baudrate <= 38.400")
if framer != FramerType.RTU:
raise TypeError("allow_multiple_devices only allowed with FramerType.RTU")
6 changes: 3 additions & 3 deletions pymodbus/simulator/simdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,17 @@ def __check_block(self, block: list[SimData]) -> list[SimData]:
if self.default and block:
first_address = block[0].address
if self.default.address > first_address:
raise TypeError("Default address is {self.default.address} but {first_address} is defined?")
raise TypeError(f"Default address is {self.default.address} but {first_address} is defined?")
def_last_address = self.default.address + self.default.count -1
if last_address > def_last_address:
raise TypeError("Default address+count is {def_last_address} but {last_address} is defined?")
raise TypeError(f"Default address+count is {def_last_address} but {last_address} is defined?")
return block

def __check_block_entries(self, last_address: int, entry: SimData) -> int:
"""Check block entries."""
values = entry.values if isinstance(entry.values, list) else [entry.values]
if entry.address <= last_address:
raise TypeError("SimData address {entry.address} is overlapping!")
raise TypeError(f"SimData address {entry.address} is overlapping!")
if entry.datatype == DataType.BITS:
if isinstance(values[0], bool):
reg_count = int((len(values) + 15) / 16)
Expand Down
13 changes: 12 additions & 1 deletion pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from contextlib import suppress
from enum import Enum
from functools import partial
from time import time_ns
from typing import Any

from ..logging import Log
Expand Down Expand Up @@ -88,7 +89,6 @@ class CommParams:
host: str = "localhost" # On some machines this will now be ::1
port: int = 0
source_address: tuple[str, int] | None = None
handle_local_echo: bool = False

# tls
sslctx: ssl.SSLContext | None = None
Expand All @@ -98,6 +98,7 @@ class CommParams:
bytesize: int = -1
parity: str = ''
stopbits: int = -1
handle_local_echo: bool = False

@classmethod
def generate_ssl(
Expand Down Expand Up @@ -161,6 +162,8 @@ def __init__(
self.unique_id: str = str(id(self))
self.reconnect_delay_current = 0.0
self.sent_buffer: bytes = b""
self.inter_frame_time: float = 0.0
self.last_frame: int = 0
self.loop: asyncio.AbstractEventLoop
if is_sync:
return
Expand Down Expand Up @@ -196,6 +199,8 @@ def __init__(
def init_setup_connect_listen(self, host: str, port: int) -> None:
"""Handle connect/listen handler."""
if self.comm_params.comm_type == CommType.SERIAL:
# time to transmit 3Char with stop bits etc.
self.inter_frame_time = int(1e9 * 3.0 * (float(1 + self.comm_params.bytesize + self.comm_params.stopbits) / self.comm_params.baudrate))
self.call_create = partial(create_serial_connection,
self.loop,
self.handle_new_connection,
Expand Down Expand Up @@ -329,6 +334,12 @@ def datagram_received(self, data: bytes, addr: tuple | None) -> None:
if not data:
return
Log.transport_dump(Log.RECV_DATA, data, self.recv_buffer)
if self.inter_frame_time:
t_now = time_ns()
if t_now - self.last_frame >= self.inter_frame_time:
Log.debug("End Of Frame detected, clearing buffer: {}", self.recv_buffer, ":hex")
self.recv_buffer = b''
self.last_frame = t_now
if len(self.recv_buffer) > 1024:
self.recv_buffer = b''
self.recv_buffer += data
Expand Down
10 changes: 7 additions & 3 deletions test/client/test_client_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_basic_syn_udp_client(self):
# receive/send
client = ModbusUdpClient("127.0.0.1")
client.socket = mockSocket()
assert not client.send(None)
assert not client.send(b'')
assert client.send(b"\x50") == 1
assert client.recv(1) == b"\x50"

Expand All @@ -44,7 +44,7 @@ def test_basic_syn_udp_client(self):
client.close()

# already closed socket
client.socket = False
client.socket = None
client.close()

assert str(client) == "ModbusUdpClient 127.0.0.1:502"
Expand Down Expand Up @@ -281,6 +281,10 @@ def test_sync_serial_client_instantiation(self):
ModbusSerialClient("/dev/null", framer=FramerType.RTU).framer,
FramerRTU,
)
assert isinstance(
ModbusSerialClient("/dev/null", baudrate=38400, framer=FramerType.RTU).framer,
FramerRTU,
)

@mock.patch("serial.Serial")
def test_basic_sync_serial_client(self, mock_serial):
Expand All @@ -293,7 +297,7 @@ def test_basic_sync_serial_client(self, mock_serial):
client = ModbusSerialClient("/dev/null")
client.socket = mock_serial
client.state = 0
assert not client.send(None)
assert not client.send(b'')
client.state = 0
assert client.send(b"\x00") == 1
assert client.recv(1) == b"\x00"
Expand Down
1 change: 0 additions & 1 deletion test/examples/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ async def test_simulator3(self):
# Awaiting fix, missing stop of task.
await run_simulator3()

@pytest.mark.skip
def test_server_datamodel(self):
"""Run different simulator configurations."""
run_main_datamodel()
Expand Down
12 changes: 12 additions & 0 deletions test/framer/test_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,15 @@ def test_rtu_decode_exception(self):
msg = b"\x00\x90\x02\x9c\x01"
_, pdu = self._rtu.handleFrame(msg, 0, 0)
assert pdu

def test_rtu_dsetMultidrop(self):
"""Test that the RTU framer can define multidrop."""
self._rtu.setMultidrop([1,2,3])

def test_rtu_dsetMultidrop2(self):
"""Test that the RTU framer can use multidrop."""
self._rtu.setMultidrop([1,2,3])
msg = b"\x05\x90\x02\x9c\x01"
cut, pdu = self._rtu.handleFrame(msg, 0, 0)
assert cut
assert not pdu
4 changes: 4 additions & 0 deletions test/framer/test_framer.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ async def test_handleFrame2(self):
assert used_len == len(msg)
assert pdu

async def test_multidrop_timing(self):
"""Test bz_bps=."""
# assert FramerRTU(DecodePDU(True), multidrop = (8, 2, 9600, []))
# assert FramerRTU(DecodePDU(True), multidrop = (8, 2, 38400, []))

class TestFramerType:
"""Test classes."""
Expand Down
26 changes: 23 additions & 3 deletions test/server/test_requesthandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
ModbusServerContext,
)
from pymodbus.exceptions import ModbusIOException, NoSuchIdException
from pymodbus.framer import FramerType
from pymodbus.pdu import ExceptionResponse
from pymodbus.server import ModbusBaseServer
from pymodbus.server import ModbusBaseServer, ModbusSerialServer
from pymodbus.transport import CommParams, CommType


Expand All @@ -33,13 +34,13 @@ async def requesthandler(self):
reconnect_delay=0.0,
reconnect_delay_max=0.0,
timeout_connect=0.0,
source_address=(0, 0),
source_address=("0", 0),
),
ModbusServerContext(devices=store, single=True),
False,
False,
None,
"socket",
FramerType.SOCKET,
None,
None,
None,
Expand Down Expand Up @@ -78,3 +79,22 @@ async def test_rh_server_send(self, requesthandler):
"""Test __init__."""
requesthandler.server_send(None, None)
requesthandler.server_send(ExceptionResponse(17), None)


async def test_serial_server_allow_multiple(self):
"""Test __init__."""
store = ModbusDeviceContext(
di=ModbusSequentialDataBlock(0, [17] * 100),
co=ModbusSequentialDataBlock(0, [17] * 100),
hr=ModbusSequentialDataBlock(0, [17] * 100),
ir=ModbusSequentialDataBlock(0, [17] * 100),
)
server = ModbusSerialServer(
ModbusServerContext(devices=store, single=True),
framer=FramerType.RTU,
baudrate=19200,
port="/dev/tty01",
allow_multiple_devices=True,
)
server.callback_new_connection()

Loading