Skip to content

Migrate from pyserial and in-tree async serial transport to serialx#2929

Draft
puddly wants to merge 3 commits intopymodbus-dev:serialxfrom
puddly:puddly/migrate-to-serialx
Draft

Migrate from pyserial and in-tree async serial transport to serialx#2929
puddly wants to merge 3 commits intopymodbus-dev:serialxfrom
puddly:puddly/migrate-to-serialx

Conversation

@puddly
Copy link
Copy Markdown

@puddly puddly commented Apr 29, 2026

Implementation of #2928.

This PR migrates from pyserial to serialx. As part of the change, I've dropped the in-tree asyncio adapter in favor of serialx's native serial code. serialx does not use polling on any platform or for either API so I've removed a few tests that referenced it.

Two major bugfixes:

  1. The in-tree async serial transport called Serial.close() within the event loop. This is a blocking operation for the socket:// URI scheme and can block almost indefinitely with os.close() on Linux, especially at low baudrates. For code running in a shared event loop (like in Home Assistant), this will cause issues. Only call connection_lost after the serial port is actually closed home-assistant-libs/pyserial-asyncio-fast#16 has more context.
  2. Windows support no longer relies on any sort of polling. Serialx uses overlapped IO on Windows and hooks into the proactor event loop for async.

Unrelated, but I think a codepaths using await asyncio.sleep(0.1) may be due to a race between opening a transport and connection_made being called.

Copy link
Copy Markdown
Collaborator

@janiversen janiversen left a comment

Choose a reason for hiding this comment

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

To be very fair and based on experience, that the tests pass is good and like sufficient for the async code.

But for the sync code, we have over time had so many issues stemming from real life devices, that we must be very careful to throw out that code. Your code removes all the wait for data as well as inter byte timeouts which scares me.

Comment thread pymodbus/client/serial.py Outdated
Comment thread pymodbus/client/serial.py
# 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.

Comment thread pymodbus/client/serial.py
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.

)

@mock.patch("serial.Serial")
@mock.patch("serialx.serial_for_url")
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.

it looks as if, this is not testing a serial line, but the socket part. If correct the tests should be duplicated to ensure it works for both.

Same goes for the next tests.

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.

serial_for_url is the dispatcher for all serial backends. Directly creating Serial objects isn't recommended because it is the platform-native serial constructor and will not work for other types of serial ports (e.g. RFC2217, ESPHome, TCP).


self.serial_write = ( # pylint: disable=attribute-defined-outside-init
client.transport.sync_serial.write
client.transport.serial.write
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.

client.transport provides to different serial implementations, a sync and an async, not sure if this change just combined them.

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.

sync_serial what the serialtransport.py called the Serial object attached to the transport. In serialx, we expose it as just serial, for backwards compatibility. Ideally, the test should not touch the sync serial API on the async transport. We only expose it for backwards compatibility.

Comment thread pyproject.toml
"ruff>=0.15.0",
"twine>=6.2.0",
"types-Pygments",
"types-pyserial",
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 cannot be true, does serialx not have a typed API ???

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.

Complete types are provided in the serialx package, there's no need to install a second one.

Copy link
Copy Markdown
Collaborator

@janiversen janiversen left a comment

Choose a reason for hiding this comment

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

This PR seems to work with Async apps, however edge cases have not been tested.

The sync part works with the standard serial interface at least in good weather situations, however due to the big number of RS485-USB converters it is not possible to ensure it works in all cases.

The sync version of pymodbus is heavily used especially by people testing their new devices and exactly that fact means a change from pyserial to serialx is a very critical move.

Changing from pyserial to serialx do have an effect on all apps when upgrading, and at least one production system would have problems (because they use a patched pyserial).

This PR is however correct, and should be merged.

Comment thread pymodbus/client/serial.py
# 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.

Well pymodbus is used on quite a number of platforms, so maybe it does not work on POSIX but helps elsewhere.

@janiversen
Copy link
Copy Markdown
Collaborator

The code is approved and can be merged once marked "ready for review".

However please change the target to pymodbus-dev:serialx, to allow it to coexist with the production version, giving users a change to verify it.

Since this is a major change, it will be part of an upcoming v4.0 version.

Thanks for the PR.

@puddly puddly changed the base branch from dev to serialx May 6, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants