Skip to content

Commit

Permalink
Allow configuring pyserial hardware RS485 settings
Browse files Browse the repository at this point in the history
  • Loading branch information
jameshilliard committed Nov 20, 2024
1 parent 430529e commit b7360e3
Show file tree
Hide file tree
Showing 4 changed files with 39 additions and 19 deletions.
5 changes: 5 additions & 0 deletions pymodbus/client/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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.
:param name: Set communication name, used in logging
:param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting.
:param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting.
Expand Down Expand Up @@ -69,6 +70,7 @@ def __init__( # pylint: disable=too-many-arguments
bytesize: int = 8,
parity: str = "N",
stopbits: int = 1,
rs485_settings: serial.rs485.RS485Settings | None = None,
handle_local_echo: bool = False,
name: str = "comm",
reconnect_delay: float = 0.1,
Expand All @@ -92,6 +94,7 @@ def __init__( # pylint: disable=too-many-arguments
bytesize=bytesize,
parity=parity,
stopbits=stopbits,
rs485_settings=rs485_settings,
handle_local_echo=handle_local_echo,
comm_name=name,
reconnect_delay=reconnect_delay,
Expand Down Expand Up @@ -160,6 +163,7 @@ def __init__( # pylint: disable=too-many-arguments
bytesize: int = 8,
parity: str = "N",
stopbits: int = 1,
rs485_settings: serial.rs485.RS485Settings | None = None,
handle_local_echo: bool = False,
name: str = "comm",
reconnect_delay: float = 0.1,
Expand All @@ -182,6 +186,7 @@ def __init__( # pylint: disable=too-many-arguments
bytesize=bytesize,
parity=parity,
stopbits=stopbits,
rs485_settings=rs485_settings,
handle_local_echo=handle_local_echo,
comm_name=name,
reconnect_delay=reconnect_delay,
Expand Down
15 changes: 11 additions & 4 deletions pymodbus/transport/serialtransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ class SerialTransport(asyncio.Transport):

force_poll: bool = os.name == "nt"

def __init__(self, loop, protocol, url, baudrate, bytesize, parity, stopbits, timeout) -> None:
def __init__(
self, loop, protocol, url, baudrate, bytesize, parity, stopbits, timeout, rs485_settings
) -> None:
"""Initialize."""
super().__init__()
if "serial" not in sys.modules:
Expand All @@ -26,9 +28,12 @@ def __init__(self, loop, protocol, url, baudrate, bytesize, parity, stopbits, ti
)
self.async_loop = loop
self.intern_protocol: asyncio.BaseProtocol = protocol
self.sync_serial = serial.serial_for_url(url, exclusive=True,
self.sync_serial = serial.serial_for_url(url, do_not_open=True, exclusive=True,
baudrate=baudrate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=timeout
)
)
if rs485_settings is not None:
self.sync_serial.rs485_mode = rs485_settings
self.sync_serial.open()
self.intern_write_buffer: list[bytes] = []
self.poll_task: asyncio.Task | None = None
self._poll_wait_time = 0.0005
Expand Down Expand Up @@ -168,6 +173,7 @@ async def create_serial_connection(
parity=None,
stopbits=None,
timeout=None,
rs485_settings=None
) -> tuple[asyncio.Transport, asyncio.BaseProtocol]:
"""Create a connection to a new serial port instance."""
protocol = protocol_factory()
Expand All @@ -176,6 +182,7 @@ async def create_serial_connection(
bytesize,
parity,
stopbits,
timeout)
timeout,
rs485_settings)
loop.call_soon(transport.setup)
return transport, protocol
10 changes: 9 additions & 1 deletion pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,16 @@
from contextlib import suppress
from enum import Enum
from functools import partial
from typing import Any
from typing import TYPE_CHECKING, Any

from pymodbus.logging import Log
from pymodbus.transport.serialtransport import create_serial_connection


if TYPE_CHECKING:
import serial


NULLMODEM_HOST = "__pymodbus_nullmodem"


Expand Down Expand Up @@ -98,6 +102,9 @@ class CommParams:
parity: str = ''
stopbits: int = -1

# RS485
rs485_settings: 'serial.rs485.RS485Settings' | None = None # noqa: UP037

@classmethod
def generate_ssl(
cls,
Expand Down Expand Up @@ -204,6 +211,7 @@ def init_setup_connect_listen(self, host: str, port: int) -> None:
parity=self.comm_params.parity,
stopbits=self.comm_params.stopbits,
timeout=self.comm_params.timeout_connect,
rs485_settings=self.comm_params.rs485_settings,
)
return
if self.comm_params.comm_type == CommType.UDP:
Expand Down
28 changes: 14 additions & 14 deletions test/transport/test_serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ class TestTransportSerial:

async def test_init(self):
"""Test null modem init."""
SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None)
SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)

async def test_loop(self):
"""Test asyncio abstract methods."""
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
methods = [
partial(comm.get_protocol),
partial(comm.set_protocol, None),
Expand All @@ -52,7 +52,7 @@ 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", None, None, None, None, None)
comm = SerialTransport(mock.MagicMock(), mock.Mock(), "dummy", None, None, None, None, None, None)
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)
Expand Down Expand Up @@ -108,14 +108,14 @@ async def test_write_force_poll(self):

async def test_close(self):
"""Test close."""
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.sync_serial = mock.MagicMock()
comm.sync_serial.read.side_effect = asyncio.CancelledError("test")
with contextlib.suppress(asyncio.CancelledError):
Expand All @@ -124,15 +124,15 @@ 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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.sync_serial = mock.MagicMock()
comm.sync_serial.read.side_effect = serial.SerialException("test")
await comm.polling_task()

@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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.sync_serial = mock.MagicMock()
comm.sync_serial = mock.MagicMock()
comm.sync_serial.write.return_value = 4
Expand All @@ -144,7 +144,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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.sync_serial = mock.MagicMock()
comm.sync_serial.write.side_effect = BlockingIOError("test")
comm.intern_write_ready()
Expand All @@ -154,7 +154,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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.sync_serial = mock.MagicMock()
comm.sync_serial.write.return_value = 4
comm.intern_write_buffer.append(b"abcd")
Expand All @@ -163,7 +163,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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.sync_serial = mock.MagicMock()
comm.sync_serial.write.return_value = 3
comm.async_loop.add_writer = mock.Mock()
Expand All @@ -173,7 +173,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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.poll_task = True
comm.sync_serial = mock.MagicMock()
comm.sync_serial.write.return_value = 3
Expand All @@ -183,7 +183,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", None, None, None, None, None)
comm = SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)
comm.sync_serial = mock.MagicMock()
comm.intern_protocol = mock.Mock()
comm.sync_serial.read = mock.Mock()
Expand All @@ -199,4 +199,4 @@ async def test_import_pyserial(self):
with mock.patch.dict(sys.modules, {'no_modules': None}) as mock_modules:
del mock_modules['serial']
with pytest.raises(RuntimeError):
SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None)
SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None, None)

0 comments on commit b7360e3

Please sign in to comment.