-
Notifications
You must be signed in to change notification settings - Fork 1k
Migrate from pyserial and in-tree async serial transport to serialx #2929
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,9 +3,7 @@ | |
|
|
||
| import contextlib | ||
| import sys | ||
| import time | ||
| from collections.abc import Callable | ||
| from functools import partial | ||
|
|
||
| from ..exceptions import ConnectionException | ||
| from ..framer import FramerType | ||
|
|
@@ -16,7 +14,13 @@ | |
|
|
||
|
|
||
| with contextlib.suppress(ImportError): | ||
| import serial | ||
| import serialx | ||
|
|
||
|
|
||
| # Buffer size for a single `read()` syscall when the caller didn't specify how many | ||
| # bytes to read. Larger than any Modbus frame, so one syscall returns whatever's | ||
| # currently in the kernel buffer without truncation. | ||
| DEFAULT_RECV_SIZE = 1024 | ||
|
|
||
|
|
||
| class AsyncModbusSerialClient(ModbusBaseClient): | ||
|
|
@@ -85,10 +89,10 @@ def __init__( # pylint: disable=too-many-arguments | |
| trace_connect: Callable[[bool], None] | None = None, | ||
| ) -> None: | ||
| """Initialize Asyncio Modbus Serial Client.""" | ||
| if "serial" not in sys.modules: # pragma: no cover | ||
| if "serialx" not in sys.modules: # pragma: no cover | ||
| raise RuntimeError( | ||
| "Serial client requires pyserial " | ||
| 'Please install with "pip install pyserial" and try again.' | ||
| "Serial client requires serialx. " | ||
| 'Please install with "pip install serialx" and try again.' | ||
| ) | ||
| if framer not in [FramerType.ASCII, FramerType.RTU]: | ||
| raise TypeError("Only FramerType RTU/ASCII allowed.") | ||
|
|
@@ -177,10 +181,10 @@ def __init__( # pylint: disable=too-many-arguments | |
| trace_connect: Callable[[bool], None] | None = None, | ||
| ) -> None: | ||
| """Initialize Modbus Serial Client.""" | ||
| if "serial" not in sys.modules: # pragma: no cover | ||
| if "serialx" not in sys.modules: # pragma: no cover | ||
| raise RuntimeError( | ||
| "Serial client requires pyserial " | ||
| 'Please install with "pip install pyserial" and try again.' | ||
| "Serial client requires serialx. " | ||
| 'Please install with "pip install serialx" and try again.' | ||
| ) | ||
| if framer not in [FramerType.ASCII, FramerType.RTU]: | ||
| raise TypeError("Only RTU/ASCII allowed.") | ||
|
|
@@ -205,14 +209,9 @@ def __init__( # pylint: disable=too-many-arguments | |
| trace_pdu, | ||
| trace_connect, | ||
| ) | ||
| self.socket: serial.Serial | None = None | ||
| self.socket: serialx.Serial | None = None | ||
| self._t0 = float(1 + bytesize + stopbits) / baudrate | ||
|
|
||
| # Check every 4 bytes / 2 registers if the reading is ready | ||
| self._recv_interval = self._t0 * 4 | ||
| # Set a minimum of 1ms for high baudrates | ||
| self._recv_interval = max(self._recv_interval, 0.001) | ||
|
|
||
| self.inter_byte_timeout: float = 0 | ||
| if baudrate <= 19200: | ||
| self.inter_byte_timeout = 1.5 * self._t0 | ||
|
|
@@ -227,19 +226,18 @@ def connect(self) -> bool: | |
| if self.socket: | ||
| return True | ||
| try: | ||
| self.socket = serial.serial_for_url( | ||
| self.socket = serialx.serial_for_url( | ||
| self.comm_params.host, | ||
| timeout=self.comm_params.timeout_connect, | ||
| read_timeout=self.comm_params.timeout_connect, | ||
| bytesize=self.comm_params.bytesize, | ||
| stopbits=self.comm_params.stopbits, | ||
| baudrate=self.comm_params.baudrate, | ||
| parity=self.comm_params.parity, | ||
| exclusive=True, | ||
| inter_byte_timeout=self.inter_byte_timeout, | ||
| ) | ||
| self.socket.inter_byte_timeout = self.inter_byte_timeout | ||
| # except serial.SerialException as msg: | ||
| # pyserial raises undocumented exceptions like termios | ||
| except Exception as msg: # pylint: disable=broad-exception-caught | ||
| self.socket.open() | ||
| except (OSError, TimeoutError, serialx.SerialException) as msg: | ||
| Log.error("{}", msg) | ||
| self.close() | ||
| return self.socket is not None | ||
|
|
@@ -250,53 +248,25 @@ def close(self): | |
| self.socket.close() | ||
| self.socket = None | ||
|
|
||
| def _in_waiting(self): | ||
| """Return waiting bytes.""" | ||
| return getattr(self.socket, "in_waiting") if hasattr(self.socket, "in_waiting") else getattr(self.socket, "inWaiting")() | ||
|
|
||
| def send(self, request: bytes, addr: tuple | None = None) -> int: | ||
| """Send data on the underlying socket.""" | ||
| _ = addr | ||
| if not self.socket: | ||
| raise ConnectionException(str(self)) | ||
| if request: | ||
| if waitingbytes := self._in_waiting(): | ||
| if waitingbytes := self.socket.num_unread_bytes(): | ||
| result = self.socket.read(waitingbytes) | ||
| Log.warning("Cleanup recv buffer before send: {}", result, ":hex") | ||
| if (size := self.socket.write(request)) is None: # pragma: no cover | ||
| size = 0 | ||
| return size | ||
| return 0 | ||
|
|
||
| def _wait_for_data(self) -> int: | ||
| """Wait for data.""" | ||
| size = 0 | ||
| more_data = False | ||
| condition = partial( | ||
| lambda start, timeout: (time.time() - start) <= timeout, | ||
| timeout=self.comm_params.timeout_connect, | ||
| ) | ||
| start = time.time() | ||
| while condition(start): | ||
| available = self._in_waiting() | ||
| if (more_data and not available) or (more_data and available == size): | ||
| break | ||
| if available and available != size: | ||
| more_data = True | ||
| size = available | ||
| time.sleep(self._recv_interval) | ||
| return size | ||
|
|
||
| def recv(self, size: int | None) -> bytes: | ||
| """Read data from the underlying descriptor.""" | ||
| if not self.socket: | ||
| raise ConnectionException(str(self)) | ||
| if size is None: | ||
| size = self._wait_for_data() | ||
| if size > self._in_waiting(): | ||
| self._wait_for_data() | ||
| result = self.socket.read(size) | ||
| return result | ||
| return self.socket.read(size if size else DEFAULT_RECV_SIZE) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems to ignore the whole idea of waiting for data, something which is important with low BPS. This can return 0, which meaning the wait for data must be handled at the calling level, which is not acceptable....all serial special handling must be done at this level.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think behavior has changed. The old function read all of the buffered data, if there is any. If there was none, it busy polled for up to
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it's simpler or if I'm not understanding how |
||
|
|
||
| def is_socket_open(self) -> bool: | ||
| """Check if socket is open.""" | ||
|
|
||
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is the inter_byte_timeout ? this is used to detect broken frames.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It moved into the constructor, just a few lines up:
parity=self.comm_params.parity, exclusive=True, + inter_byte_timeout=self.inter_byte_timeout, ) - self.socket.inter_byte_timeout = self.inter_byte_timeoutThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also think this may be worth noting:
inter_byte_timeoutin POSIX has a resolution of 0.1s so I think this feature always effectively setsinter_byte_timeout = 0, sincet0would be less than 0.1s for any baudrate higher than 66 baud.On Windows, it has a higher resolution and work as intended.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well pymodbus is used on quite a number of platforms, so maybe it does not work on POSIX but helps elsewhere.