diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 95dcaf1a4..91a06dc04 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -80,6 +80,7 @@ def __init__( parity=kwargs.get("parity", None), stopbits=kwargs.get("stopbits", None), handle_local_echo=kwargs.get("handle_local_echo", False), + rs485_settings=kwargs.get("rs485_settings", None), ), retries, retry_on_empty, diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index 0fb4c6034..fbc43d1fc 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -15,6 +15,7 @@ try: import serial + from serial.rs485 import RS485Settings PYSERIAL_MISSING = False except ImportError: @@ -37,6 +38,7 @@ class AsyncModbusSerialClient(ModbusBaseClient): :param parity: 'E'ven, 'O'dd or 'N'one :param stopbits: Number of stop bits 1, 1.5, 2. :param handle_local_echo: Discard local echo from dongle. + :param rs485_settings: Allow configuring the underlying serial port for RS485 mode. Common optional parameters: @@ -73,6 +75,7 @@ def __init__( bytesize: int = 8, parity: str = "N", stopbits: int = 1, + rs485_settings: RS485Settings | None = None, **kwargs: Any, ) -> None: """Initialize Asyncio Modbus Serial Client.""" @@ -90,6 +93,7 @@ def __init__( bytesize=bytesize, parity=parity, stopbits=stopbits, + rs485_settings=rs485_settings, **kwargs, ) @@ -155,6 +159,7 @@ def __init__( bytesize: int = 8, parity: str = "N", stopbits: int = 1, + rs485_settings: RS485Settings | None = None, strict: bool = True, **kwargs: Any, ) -> None: @@ -167,6 +172,7 @@ def __init__( bytesize=bytesize, parity=parity, stopbits=stopbits, + rs485_settings=rs485_settings, **kwargs, ) self.socket: serial.Serial | None = None diff --git a/pymodbus/transport/serialtransport.py b/pymodbus/transport/serialtransport.py index 6d632fcae..319eecf08 100644 --- a/pymodbus/transport/serialtransport.py +++ b/pymodbus/transport/serialtransport.py @@ -8,6 +8,7 @@ with contextlib.suppress(ImportError): import serial + import serial.rs485 class SerialTransport(asyncio.Transport): @@ -15,18 +16,28 @@ class SerialTransport(asyncio.Transport): force_poll: bool = os.name == "nt" - def __init__(self, loop, protocol, *args, **kwargs) -> None: + def __init__(self, loop, protocol, rs485_settings, *args, **kwargs) -> None: """Initialize.""" super().__init__() self.async_loop = loop self.intern_protocol: asyncio.BaseProtocol = protocol - self.sync_serial = serial.serial_for_url(*args, **kwargs) + self.sync_serial = self._serial_for_args(rs485_settings, *args, **kwargs) self.intern_write_buffer: list[bytes] = [] self.poll_task: asyncio.Task | None = None self._poll_wait_time = 0.0005 self.sync_serial.timeout = 0 self.sync_serial.write_timeout = 0 + def _serial_for_args(self, rs485_settings, *args, **kwargs) -> serial.Serial: + sync_serial: serial.Serial + if rs485_settings is not None: + sync_serial = serial.rs485.RS485(*args, **kwargs) + sync_serial.rs485_mode = rs485_settings + else: + sync_serial = serial.serial_for_url(*args, **kwargs) + + return sync_serial + def setup(self) -> None: """Prepare to read/write.""" if self.force_poll: @@ -154,10 +165,10 @@ async def polling_task(self): self.intern_read_ready() async def create_serial_connection( - loop, protocol_factory, *args, **kwargs + loop, protocol_factory, rs485_settings, *args, **kwargs ) -> tuple[asyncio.Transport, asyncio.BaseProtocol]: """Create a connection to a new serial port instance.""" protocol = protocol_factory() - transport = SerialTransport(loop, protocol, *args, **kwargs) + transport = SerialTransport(loop, protocol, rs485_settings, *args, **kwargs) loop.call_soon(transport.setup) return transport, protocol diff --git a/pymodbus/transport/transport.py b/pymodbus/transport/transport.py index 265d98e1d..0ca0457b2 100644 --- a/pymodbus/transport/transport.py +++ b/pymodbus/transport/transport.py @@ -58,6 +58,8 @@ from functools import partial from typing import Any +from serial.rs485 import RS485Settings + from pymodbus.logging import Log from pymodbus.transport.serialtransport import create_serial_connection @@ -98,6 +100,9 @@ class CommParams: parity: str = '' stopbits: int = -1 + # RS485 + rs485_settings: RS485Settings | None = None + @classmethod def generate_ssl( cls, @@ -147,7 +152,6 @@ def __init__( self.comm_params = params.copy() self.is_server = is_server self.is_closing = False - self.transport: asyncio.BaseTransport = None # type: ignore[assignment] self.loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() self.recv_buffer: bytes = b"" @@ -188,14 +192,16 @@ def __init__( self.comm_params.comm_type = CommType.TCP parts = host.split(":") host, port = parts[1][2:], int(parts[2]) - self.init_setup_connect_listen(host, port) - def init_setup_connect_listen(self, host: str, port: int) -> None: + self.init_setup_connect_listen(host, port, self.comm_params.rs485_settings) + + def init_setup_connect_listen(self, host: str, port: int, rs485_settings: RS485Settings | None) -> None: """Handle connect/listen handler.""" if self.comm_params.comm_type == CommType.SERIAL: self.call_create = partial(create_serial_connection, self.loop, self.handle_new_connection, + rs485_settings, host, baudrate=self.comm_params.baudrate, bytesize=self.comm_params.bytesize, diff --git a/test/transport/test_serial.py b/test/transport/test_serial.py index c39e11e4c..aac566bcc 100644 --- a/test/transport/test_serial.py +++ b/test/transport/test_serial.py @@ -7,6 +7,7 @@ import pytest import serial +from serial.rs485 import RS485Settings from pymodbus.transport.serialtransport import ( SerialTransport, @@ -17,22 +18,25 @@ @mock.patch( "pymodbus.transport.serialtransport.serial.serial_for_url", mock.MagicMock() ) +@mock.patch( + "pymodbus.transport.serialtransport.serial.rs485.RS485", mock.MagicMock() +) class TestTransportSerial: """Test transport serial module.""" async def test_init(self): """Test null modem init.""" - SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") async def test_loop(self): """Test asyncio abstract methods.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") assert comm.loop @pytest.mark.parametrize("inx", range(0, 11)) async def test_abstract_methods(self, inx): """Test asyncio abstract methods.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") methods = [ partial(comm.get_protocol), partial(comm.set_protocol, None), @@ -51,14 +55,14 @@ async def test_abstract_methods(self, inx): @pytest.mark.parametrize("inx", range(0, 4)) async def test_external_methods(self, inx): """Test external methods.""" - comm = SerialTransport(mock.MagicMock(), mock.Mock(), "dummy") + comm = SerialTransport(mock.MagicMock(), mock.Mock(), None, "dummy") comm.sync_serial.read = mock.MagicMock(return_value="abcd") comm.sync_serial.write = mock.MagicMock(return_value=4) comm.sync_serial.fileno = mock.MagicMock(return_value=2) - comm.sync_serial.async_loop.add_writer = mock.MagicMock() - comm.sync_serial.async_loop.add_reader = mock.MagicMock() - comm.sync_serial.async_loop.remove_writer = mock.MagicMock() - comm.sync_serial.async_loop.remove_reader = mock.MagicMock() + comm.async_loop.add_writer = mock.MagicMock() + comm.async_loop.add_reader = mock.MagicMock() + comm.async_loop.remove_writer = mock.MagicMock() + comm.async_loop.remove_reader = mock.MagicMock() comm.sync_serial.in_waiting = False methods = [ @@ -73,17 +77,30 @@ async def test_external_methods(self, inx): async def test_create_serial(self): """Test external methods.""" transport, protocol = await create_serial_connection( - asyncio.get_running_loop(), mock.Mock, url="dummy" + asyncio.get_running_loop(), mock.Mock, None, url="dummy" + ) + assert transport + assert protocol + transport.close() + + @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") + async def test_create_rs485_serial(self): + """Test external methods.""" + settings = RS485Settings(rts_level_for_rx=True, rts_level_for_tx=True, delay_before_rx=0.4) + transport, protocol = await create_serial_connection( + asyncio.get_running_loop(), mock.Mock, settings, url="dummy" ) assert transport assert protocol + assert transport.sync_serial.rs485_mode + assert transport.sync_serial.rs485_mode == settings transport.close() async def test_force_poll(self): """Test external methods.""" SerialTransport.force_poll = True transport, protocol = await create_serial_connection( - asyncio.get_running_loop(), mock.Mock, url="dummy" + asyncio.get_running_loop(), mock.Mock, None, url="dummy" ) await asyncio.sleep(0) assert transport @@ -96,7 +113,7 @@ async def test_write_force_poll(self): """Test write with poll.""" SerialTransport.force_poll = True transport, protocol = await create_serial_connection( - asyncio.get_running_loop(), mock.Mock, url="dummy" + asyncio.get_running_loop(), mock.Mock, None, url="dummy" ) await asyncio.sleep(0) transport.write(b"abcd") @@ -106,14 +123,14 @@ async def test_write_force_poll(self): async def test_close(self): """Test close.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = None comm.close() @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_polling(self): """Test polling.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = mock.MagicMock() comm.sync_serial.read.side_effect = asyncio.CancelledError("test") with contextlib.suppress(asyncio.CancelledError): @@ -122,7 +139,7 @@ async def test_polling(self): @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_poll_task(self): """Test polling.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = mock.MagicMock() comm.sync_serial.read.side_effect = serial.SerialException("test") await comm.polling_task() @@ -130,7 +147,7 @@ async def test_poll_task(self): @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_poll_task2(self): """Test polling.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = mock.MagicMock() comm.sync_serial = mock.MagicMock() comm.sync_serial.write.return_value = 4 @@ -142,7 +159,7 @@ async def test_poll_task2(self): @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_write_exception(self): """Test write exception.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = mock.MagicMock() comm.sync_serial.write.side_effect = BlockingIOError("test") comm.intern_write_ready() @@ -152,7 +169,7 @@ async def test_write_exception(self): @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_write_ok(self): """Test write exception.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = mock.MagicMock() comm.sync_serial.write.return_value = 4 comm.intern_write_buffer.append(b"abcd") @@ -161,7 +178,7 @@ async def test_write_ok(self): @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_write_len(self): """Test write exception.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = mock.MagicMock() comm.sync_serial.write.return_value = 3 comm.async_loop.add_writer = mock.Mock() @@ -171,7 +188,7 @@ async def test_write_len(self): @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_write_force(self): """Test write exception.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.poll_task = True comm.sync_serial = mock.MagicMock() comm.sync_serial.write.return_value = 3 @@ -181,7 +198,7 @@ async def test_write_force(self): @pytest.mark.skipif(os.name == "nt", reason="Windows not supported") async def test_read_ready(self): """Test polling.""" - comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy") + comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), None, "dummy") comm.sync_serial = mock.MagicMock() comm.intern_protocol = mock.Mock() comm.sync_serial.read = mock.Mock()