feat!: asynchronous rewrite (roombapy 2.0) - #586
Conversation
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
|
@pschmitt About 4,000 of those are generated data: That leaves roughly 2,900 lines of actual code, and if you only look at three things I'd suggest:
Two further things: If reviewing it whole turns out to be worse than reviewing it in pieces, say so and I'll split it. You asked for one PR and I think that was right, but you're the one who has to read it. And the region guard on |
There was a problem hiding this comment.
🔵 Needs a closer look
It’s a large breaking transport/API rewrite with lifecycle supervision changes, and the review found at least two concrete correctness/robustness issues that should be addressed before merge.
Pull request overview
This PR delivers the breaking 2.0 rewrite of roombapy to be asynchronous end-to-end, replacing the prior threaded/paho client with an event-loop-driven MQTT transport (aiomqtt), and reworking discovery, password retrieval, CLI usage, and tests to match the new async API surface.
Changes:
- Replaced the threaded client stack (
remote_client.py,roomba_factory.py, oldRoomba) with an asyncRoombaClientfeaturing supervised reconnect/backoff, async callbacks with unsubscribe handles, and awatch()async iterator. - Extracted shared components: state machine into
state.py, TLS context generation intotls.py, and added opt-in typed views of reported state viatypes.py. - Updated CLI/docs and added a broad new async-focused test suite; bumped version to 2.0.0, raised Python floor to 3.11, and pinned
aiomqtt>=2.5,<3.0.
File summaries
| File | Description |
|---|---|
| uv.lock | Updates lockfile for Python >=3.11 and adds/pins aiomqtt; removes now-unneeded backports/typing deps. |
| pyproject.toml | Bumps to 2.0.0, raises Python requirement, switches MQTT dep to aiomqtt, and adjusts lint/tool configuration. |
| README.md | Documents the new async API and provides upgrade guidance from 1.x to 2.0. |
| roombapy/roomba.py | Introduces the new async RoombaClient with supervisor/reconnect/backoff, callbacks, watch(), and outbound commands. |
| roombapy/state.py | Extracts the state machine logic into a transport-independent component. |
| roombapy/tls.py | Centralizes permissive TLS context generation shared across MQTT and password retrieval. |
| roombapy/types.py | Adds TypedDict-based opt-in typed views over master_state (e.g., reported). |
| roombapy/discovery.py | Rewrites discovery to async UDP, with explicit address resolution to fail fast on typos. |
| roombapy/getpassword.py | Rewrites local password retrieval to async TLS sockets with timeouts and safe teardown. |
| roombapy/cli.py | Updates CLI to use the async client and removes the prior busy-wait connect loop. |
| roombapy/init.py | Refreshes the public API exports for the 2.0 async surface and newly exposed helpers/types. |
| roombapy/const.py | Extends ROOMBA_STATES to include spot and completed phases. |
| roombapy/remote_client.py | Removes the legacy threaded/paho remote client implementation. |
| roombapy/roomba_factory.py | Removes the legacy factory used to construct the threaded client. |
| tests/conftest.py | Removes old threaded-client fixtures and adds broker diagnostics for intermittent failures. |
| tests/test_roomba.py | Removes legacy Roomba-class message handling test. |
| tests/test_roomba_integration.py | Removes legacy threaded integration test that crossed executor boundaries. |
| tests/test_remote_client.py | Removes legacy remote-client logging tests. |
| tests/test_client.py | Adds async “vertical slice” test against a real broker. |
| tests/test_commands.py | Adds async command and callback-contract tests (payload envelope, async callbacks, error behavior). |
| tests/test_message_handling.py | Adds parity tests for topic exclusion and periodic re-derive behavior. |
| tests/test_reconnect.py | Adds reconnection test using a private mosquitto instance (skips in CI without binary). |
| tests/test_supervisor.py | Adds supervisor behavior tests (auth failure no-retry, state callbacks, unsubscribe). |
| tests/test_robustness.py | Adds robustness regression tests (unexpected errors, second connect, callback draining, transport error wrapping). |
| tests/test_watch.py | Adds tests for watch() fan-out, backpressure/drop-oldest, and watcher detachment. |
| tests/test_scope.py | Adds tests for the region-scope guard and phase coverage. |
| tests/test_types.py | Adds tests that typed views are a direct view over the same dict and tolerate unknown keys. |
| tests/test_vendor_errors.py | Adds tests validating the vendor error text table and overlap invariants. |
| tests/test_discovery.py | Rewrites discovery/password tests for async behavior with local fakes. |
| tests/test_decode.py | Updates discovery decode test to use the new ROOMBA_MESSAGE constant. |
Review details
- Files reviewed: 29/31 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
|
- discovery: match replies against the resolved IP, not the raw hostname, so RoombaDiscovery.get() works with a hostname argument - cli: catch RoombaConnectionError from discovery in _connect(), and run discovery + password lookup concurrently - roomba: fire on-disconnect callbacks on an auth-failure teardown, drop the duplicate on-state notification for a live auth revoke, make watch() consumers return instead of hanging after disconnect(), and await cancelled callback tasks in disconnect() before returning - tls: cache the SSL context again instead of rebuilding it per client - README: fix the typed-state example, which is empty until the first MQTT message arrives, not right after connect() Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01De8adUw5ChgYJ3aYmCDAFK
There was a problem hiding this comment.
🟡 Changes recommended
There are API-contract issues in the async client (disconnect callbacks firing during initial connect attempts and non-idempotent unsubscribe handles) plus a docstring markup error that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
roombapy/roomba.py:216
- The unsubscribe handle returned by
register_on_message_callbackis not idempotent: calling it twice (or after another removal) raisesValueErrorfromlist.remove. Unsubscribe handles are commonly used infinallyblocks, so making this safe prevents cleanup paths from raising unexpectedly.
This issue also appears in the following locations of the same file:
- line 218
- line 225
self, callback: MessageCallback
) -> Unsubscribe:
"""Register a message callback; returns a detach handle."""
self._on_message.append(callback)
return lambda: self._on_message.remove(callback)
roombapy/state.py:58
- The
dict_mergedocstring uses invalid reStructuredText markup (`:meth:``dict.update()```). This will render incorrectly (and may be flagged by doc tooling).
"""Recursive dict merge.
Inspired by :meth:``dict.update()``, instead
of updating only top-level keys, dict_merge recurses down into dicts
nested to an arbitrary depth, updating keys. The ``merge_dct`` is
roombapy/roomba.py:223
- The unsubscribe handle returned by
register_on_disconnect_callbackis not idempotent: calling it more than once will raiseValueError. Making the handle safe to call repeatedly avoids cleanup errors and matches typical unsubscribe semantics.
def register_on_disconnect_callback(
self, callback: ErrorCallback
) -> Unsubscribe:
"""Register a disconnect callback; returns a detach handle."""
self._on_disconnect.append(callback)
return lambda: self._on_disconnect.remove(callback)
roombapy/roomba.py:235
- The unsubscribe handle returned by
register_on_connection_state_callbackcan raiseValueErrorif called twice. Since unsubscribe handles are often called defensively (e.g., in teardown), suppressingValueErrormakes this API safer to use.
def register_on_connection_state_callback(
self, callback: StateCallback
) -> Unsubscribe:
"""Register for connection-state changes; returns a detach handle.
Fires on every transition, not only on loss, so a caller can mark an
entity unavailable and available again without inferring the second
half from the absence of the first.
"""
self._on_state.append(callback)
return lambda: self._on_state.remove(callback)
- Files reviewed: 29/31 changed files
- Comments generated: 1
- Review effort level: Lite
Signed-off-by: johnnyh1975 <jean-christoph@5heyne.de>
|
@pschmitt The unsubscribe handles are now safe to call repeatedly — that one bites exactly where handles are used, in Disconnect callbacks during the initial connect: confirmed, two of them before any session existed, since the retry budget lets two attempts fail before The Each has a test that fails without it. 81 tests now. |
commit d6f90b9 ("Adjustments - after copilot review") brought back four bugs a prior review had already fixed, none of which are caught by the existing test suite: - discovery: get() went back to comparing the reply's resolved IP against the raw hostname, so a hostname argument never matches - roomba: watch() lost its close signal, so a consumer iterating it hangs forever after disconnect() - roomba: disconnect() stopped awaiting callback tasks it cancelled after the drain timeout, so it can return before cancellation actually finishes - roomba: an auth-failure teardown stopped notifying on-disconnect subscribers at all, and reintroduced a duplicate on-state notification for a live mid-session credential revocation This restores all four while keeping the two genuine improvements from that commit: the idempotent _detacher() for unsubscribe, and gating the general reconnect path's disconnect notification on was_connected (extended here to the auth-failure path too, via a new _notify_auth_failure() helper that also keeps _supervise under ruff's statement-count limit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01De8adUw5ChgYJ3aYmCDAFK
|
Let's a go! Thanks for your work on this. |
|
Thanks — and thanks for the three fixes you put on top rather than just merging. The Also sorry about the stray |
Breaking. The library is asynchronous throughout; the threaded client is
gone. Ready for an initial review
Why
Home Assistant's integration bridges the thread boundary in 111 places:
72 async_add_executor_job, 27 run_coroutine_threadsafe, 12
call_soon_threadsafe. 49 of the executor jobs wrap a roombapy call; the
other 23 are CPU-bound rendering that belongs there anyway. All 39 of
the run_coroutine_threadsafe/call_soon_threadsafe sites exist purely
because register_on_message_callback delivers on paho's thread. So this
removes 88 of the 111, and with them a class of intermittent bug: every
one of those sites is a hand-written rule that has to be remembered.
Structure
remote_client.py, roomba_factory.py and the threaded roomba.py are
removed. roomba.py, discovery.py, getpassword.py and cli.py are the
async implementations under their existing names.
state.py holds the state machine, extracted verbatim: the logic that has
been running against real robots for years is byte-identical, only its
location changed. tls.py holds the SSL context shared by MQTT and
password retrieval; the posture stays permissive on purpose, since these
robots present self-signed certificates and older firmware needs legacy
renegotiation.
Transport
aiomqtt, pinned >=2.5,<3. It drives paho from the event loop via
add_reader/add_writer rather than a thread, so it is a wrapper rather
than a second stack. The pin matters: 3.x replaces paho with an
MQTTv5-only backend and these robots speak 3.1.1.
The library owns reconnection with exponential backoff. This is not an
addition but a replacement: aiomqtt sets reconnect_on_failure=False,
whereas loop_start() gave every consumer automatic reconnection for
free. Without it the swap would have been a silent regression.
RoombaAuthError never triggers a retry. A rejected credential is not
transient, and a robot re-provisioned mid-session would otherwise be
hammered forever with a stale password.
API
rejection arrived via on_connect and merely left roomba_connected
False, so callers polled a flag.
unsubscribe handle. Previously a registered callback could never be
detached.
caller need not infer recovery from the absence of further failures.
drop-oldest backpressure.
view are additive: the same dictionary, checked, opt-in per caller.
list. The robot omits the key in that case and cleans the whole house;
gated on the vendor app's own emit condition.
Data
ROOMBA_STATES gains spot and completed, both present in the robot's
phase enum. An unlisted phase reaches the branch that logs 'please
create a new issue'.
vendor_errors.py carries iRobot's own fault texts in eight languages.
Provenance and the unverified overlap with our own labels are stated in
the module, and a test keeps the overlap set honest as either table
changes.
Robustness
Four failures found by probing the implementation rather than by report,
each with a test that fails without its fix:
payload the state machine choked on, a synchronous callback that
raised — escaped the supervisor task and ended it permanently: no
reconnect, no callback, and the exception unretrieved until someone
awaited the task. It now backs off and returns like any other loss.
consumer persisting state from one lost it on unload. It now waits
briefly, then cancels what is stuck and says so.
raises.
in lockstep after a router restart.
only a pre-check, and the session can drop between it and the publish.
A caller told to catch RoombaError met a third-party type instead.
off. The datagram never left, the error landed on error_received where
nobody was looking, and the caller waited out the whole window for a
None. Addresses are now resolved first.
The public surface was then audited call by call — every await into
aiomqtt or asyncio across all four modules — so these are the ones that
were found rather than the ones that exist.
Compatibility
Python floor raised to 3.11 for asyncio.timeout and Self. CI tests
3.12-3.14 while the project declared >=3.10, so the two were already out
of step; worth aligning either way.
Two things I could not check and would rather flag than have you
discover:
Python 3.14. aiomqtt 2.5.1 declares >=3.8 but its classifiers stop
at 3.13, so the 3.14 job is the most likely place for this to go red
despite everything else passing. I developed against 3.12.
Hardware. None of this has run against a robot. It was developed
against this repository's own CI broker configuration and against
recorded payloads, so the wire format, the state transitions and the
reconnect mechanics are verified against a broker that can be killed on
demand — but not against firmware. In particular: whether the robot
sends a full state on connect or only deltas, and whether a sleeping
robot closes the session or merely goes quiet until the keepalive
expires. The second decides whether the backoff constants are sensible.
Everything else was run as CI runs it: uv sync --locked, pytest, ruff
check, ruff format --check, mypy, and each pre-commit hook including
codespell.