Skip to content

Update use pyserial RS485 #2205

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 23 commits into from
Closed
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
1 change: 1 addition & 0 deletions pymodbus/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions pymodbus/client/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

try:
import serial
from serial.rs485 import RS485Settings
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is already imported in the line above, do not import twice.


PYSERIAL_MISSING = False
except ImportError:
Expand All @@ -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:

Expand Down Expand Up @@ -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."""
Expand All @@ -90,6 +93,7 @@ def __init__(
bytesize=bytesize,
parity=parity,
stopbits=stopbits,
rs485_settings=rs485_settings,
**kwargs,
)

Expand Down Expand Up @@ -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:
Expand All @@ -167,6 +172,7 @@ def __init__(
bytesize=bytesize,
parity=parity,
stopbits=stopbits,
rs485_settings=rs485_settings,
**kwargs,
)
self.socket: serial.Serial | None = None
Expand Down
19 changes: 15 additions & 4 deletions pymodbus/transport/serialtransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,36 @@

with contextlib.suppress(ImportError):
import serial
import serial.rs485
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is already imported in the line above.



class SerialTransport(asyncio.Transport):
"""An asyncio serial 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:
Expand Down Expand Up @@ -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
12 changes: 9 additions & 3 deletions pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
from functools import partial
from typing import Any

from serial.rs485 import RS485Settings
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if serial is not installed ?? Please import as serial is imported in other places.


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

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

# RS485
rs485_settings: RS485Settings | None = None

@classmethod
def generate_ssl(
cls,
Expand Down Expand Up @@ -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""
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 37 additions & 20 deletions test/transport/test_serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pytest
import serial
from serial.rs485 import RS485Settings
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already imported in the line above, please do not import twice.


from pymodbus.transport.serialtransport import (
SerialTransport,
Expand All @@ -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),
Expand All @@ -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 = [
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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):
Expand All @@ -122,15 +139,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")
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()

@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
Expand All @@ -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()
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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()
Expand Down