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
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Common features
* Support for custom function codes
* Support serial (rs-485), tcp, tls and udp communication
* Support all standard frames: socket, rtu, rtu-over-tcp, tcp and ascii
* Does not have third party dependencies, apart from pyserial (optional)
* Does not have third party dependencies, apart from serialx (optional)
* Very lightweight project
* Requires Python >= 3.10
* Thorough test suite, that test all corners of the library (100% test coverage)
Expand Down Expand Up @@ -168,7 +168,7 @@ If you want to use the serial interface::

pip install pymodbus[serial]

This will install pymodbus with the pyserial dependency.
This will install pymodbus with the serialx dependency.

Pymodbus offers a number of extra options:

Expand Down
72 changes: 21 additions & 51 deletions pymodbus/client/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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.")
Expand All @@ -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
Expand All @@ -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()
Copy link
Copy Markdown
Collaborator

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.

Copy link
Copy Markdown
Author

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_timeout

Copy link
Copy Markdown
Author

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_timeout in POSIX has a resolution of 0.1s so I think this feature always effectively sets inter_byte_timeout = 0, since t0 would be less than 0.1s for any baudrate higher than 66 baud.

On Windows, it has a higher resolution and work as intended.

Copy link
Copy Markdown
Collaborator

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.

except (OSError, TimeoutError, serialx.SerialException) as msg:
Log.error("{}", msg)
self.close()
return self.socket is not None
Expand All @@ -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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Author

@puddly puddly Apr 30, 2026

Choose a reason for hiding this comment

The 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 self.comm_params.timeout_connect seconds until some data arrived. If nothing arrived, it returned an empty string. This is exactly how serial.read(max_size) works if you pass read_timeout=self.comm_params.timeout_connect, to the serial class constructor.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

If it's simpler or if I'm not understanding how read() is supposed to work, I can just restore the old behavior exactly, all of the functions are the same.


def is_socket_open(self) -> bool:
"""Check if socket is open."""
Expand Down
182 changes: 0 additions & 182 deletions pymodbus/transport/serialtransport.py

This file was deleted.

9 changes: 5 additions & 4 deletions pymodbus/transport/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@
- client: remote port to connect to (as host:port)
- client serial: no used

Pyserial allow the comm_port to be a socket e.g. "socket://localhost:502",
Serialx allows the comm_port to be a socket e.g. "socket://localhost:502",
this allows serial clients to connect to a tcp server with RTU framer.

Pymodbus allows this format for both server and client.
For clients the string is passed to pyserial,
For clients the string is passed to serialx,
but for servers it is used to start a modbus tcp server.
This allows for serial testing, without a serial cable.

Expand All @@ -40,7 +40,7 @@
This allows testing without actual network traffic and is a lot faster.

Class NullModem is a asyncio transport class,
that replaces the socket class or pyserial.
that replaces the socket class or serialx.

The class is designed to take care of differences between the different
transport mediums, and provide a neutral interface for the upper layers.
Expand All @@ -59,8 +59,9 @@
from functools import partial
from typing import Any

from serialx import create_serial_connection

from ..logging import Log
from .serialtransport import create_serial_connection


NULLMODEM_HOST = "__pymodbus_nullmodem"
Expand Down
Loading