Repository navigation
Run the sync tcp client on the async client (draft) - #3088
sankalpsthakur wants to merge 2 commits into
Conversation
ModbusTcpClient now runs an AsyncModbusTcpClient on a private event loop in a daemon thread. Each request is submitted with asyncio.run_coroutine_threadsafe(), so the sync client uses the async transaction code and also works when the calling thread runs a loop. The loop starts on connect and stops on close. The TLS, UDP and serial clients keep their sync code for now.
|
Please check that an exception raised in the async part goes back to the app....I remember having that problem in the server. |
janiversen
left a comment
There was a problem hiding this comment.
Looks good ! I have added a small number of questions/comments.
| self.use_udp = False | ||
| self._loop_thread: _LoopThread | None = None | ||
| self._async_client: ModbusBaseClient | None = None | ||
| self._custom_responses: list[type[ModbusPDU]] = [] |
There was a problem hiding this comment.
Why do we need to store the custom responses, once registred they are kept in the pdu module.
There was a problem hiding this comment.
They are kept per decoder: DecodePDU.register() copies pdu_table into the instance, so the async client created on connect did not know them. I removed the list, the async client now uses the decoder of the sync client, so registered responses are known to both.
|
|
||
| #: True when the client runs its async counterpart on a private loop, | ||
| #: instead of using its own sync communication code. | ||
| _run_async = False |
There was a problem hiding this comment.
Meaning the old code still works, and you/I can test by setting it to true ?
There was a problem hiding this comment.
Yes, with _run_async = False the tcp client uses the old sync code. test_read now runs with both values.
| """ | ||
| if not self.connect(): | ||
| raise ConnectionException(f"Failed to connect[{self!s}]") | ||
| if self._async_client and self._loop_thread: |
There was a problem hiding this comment.
No test for run_async ? did 8 misunderstand something ?
There was a problem hiding this comment.
The tests are in test_client_sync_loop.py. test_read is now parametrized on _run_async, so both paths in execute() are tested.
|
|
||
| :meta private: | ||
| """ | ||
| return AsyncModbusTcpClient(**self._async_args, reconnect_delay=0) # type: ignore[arg-type] |
There was a problem hiding this comment.
it would be nicer with functools.partial
There was a problem hiding this comment.
Done, the tcp client sets self._async_client_factory = functools.partial(AsyncModbusTcpClient, ...).
| from pymodbus.simulator import DataType, SimData, SimDevice | ||
|
|
||
|
|
||
| def free_port() -> int: |
There was a problem hiding this comment.
please see the other tests, we use a standard fixture use_port
There was a problem hiding this comment.
Done, the test class uses use_port with a new entry in BASE_PORTS.
| return sock.getsockname()[1] | ||
|
|
||
|
|
||
| class ServerThread: # pylint: disable=too-few-public-methods |
There was a problem hiding this comment.
There is a standard fixture for this.
There was a problem hiding this comment.
Done, the tests use mock_server. The server runs on the test loop, so the sync calls go through asyncio.to_thread().
The tcp client creates its async counterpart with functools.partial. The async client shares the decoder of the sync client, so the list of registered custom responses is no longer needed. The tests use the standard use_port and mock_server fixtures, run with _run_async True and False, and check that an exception raised in the async client reaches the caller.
|
Checked: |
Draft, to discuss the approach from #3076 on real code. Only the tcp client is converted.
ModbusTcpClientnow creates anAsyncModbusTcpClienton a private event loop that runs in a daemon thread.connect()starts the loop and connects,execute()submits the request withasyncio.run_coroutine_threadsafe()and waits for the result,close()closes the async client and stops the loop. The async client getsreconnect_delay=0, because the sync client connects when a request needs it.The mechanism is in
ModbusBaseSyncClient(_LoopThread,_connect_async,_close_async), so the other clients only need_new_async_client()and_run_async = True. The TLS client keeps its sync code for now, UDP and serial are unchanged.Open points:
test_client_sync.pyandtest_client.pymocksocket.create_connection/socket.sendfor the tcp client and fail now. I'd rewrite them against a server, like the newtest_client_sync_loop.py, once the approach is agreed.Validation
pytest test/client/test_client_sync_loop.py: 6 passed (real server: read, connect on request, with block, failed connect leaves no thread, call from a thread that runs a loop, set_max_no_responses).pytest test/client: 242 passed, 8 failed (the 5 above, and 3 tls tests that cannot find the certificates in my worktree, also on dev).ruff check,ruff format --check,codespellandpylint(10.00/10) are clean;mypyreports the same findings as ondev.Related to #3076.
AI disclosure: written with an AI coding agent and tested as described above.