From e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b Mon Sep 17 00:00:00 2001 From: Vincent Courcelle <2070309+tubededentifrice@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:37:16 +0400 Subject: [PATCH 01/21] feat: Q10 (B01/ss07) room/segment cleaning (clean_segments) (#851) * feat: expand Q10 (B01/ss07) status support and add device info Verified against two physical Roborock Q10 (roborock.vacuum.ss07) devices: - Add the roborock.vacuum.ss07 schema to device_info.yaml (captured via `roborock get-device-info`), per CONTRIBUTING. - Expand Q10Status to capture the ~25 additional fields the device reports in its full status dump (volume, child_lock, DND, net_info, time_zone, etc.). - Fix dpNetInfo/dpTimeZone container field names so the decamelized device keys actually parse (they previously failed to construct). - Add a CLI `status` path for B01 Q10 devices, which refreshes and prints the decoded status (previously `status` only worked for V1 devices). - Add a second real (scrubbed) full-status protocol fixture and extend the status trait tests to cover the new fields. * feat: add Q10 (B01/ss07) settings writers Adds a SettingsTrait so Q10 settings the status trait already reads can also be written: volume, child lock, Do Not Disturb, indicator LED, and dust collection. Q10 setting writes were reverse-engineered + validated against a live ss07 robot: the bare data point write ({"dps": {"26": v}}) is silently ignored; the value must be wrapped in the dpCommon (101) data point ({"dps": {"101": {"26": v}}}). Verified live by setting volume and child lock and reading the new value back from the status trait, then restoring. - traits/b01/q10/settings.py: SettingsTrait wired into Q10PropertiesApi. - cli: q10-set-volume / -child-lock / -dnd / -led / -dust-collection. - Unit tests assert the exact dpCommon-wrapped payload for each setter. * feat: add Q10 dust-collection frequency writer Adds SettingsTrait.set_dust_collection_frequency (dpDustSetting 50 via the dpCommon wrapper) accepting YXDeviceDustCollectionFrequency or its int code (0=daily, 15/30/45/60 cleans), plus a q10-set-dust-frequency CLI command. Validated live on an R1: dust_setting 0 -> 15 -> restored to 0. * refactor: split Q10 status/settings into per-concern traits Address PR #846 review feedback: - Split the monolithic Q10Status read-model + catch-all SettingsTrait into per-concern read/write traits mirroring the v1 layout: SoundVolumeTrait, ChildLockTrait, DoNotDisturbTrait, DustCollectionTrait, ButtonLightTrait, NetworkInfoTrait, ConsumableTrait. Each owns its read-model and setters. - Add an UpdatableTrait base (q10/common.py) that wires a RoborockBase read-model to the DPS push stream; Q10PropertiesApi fans each decoded message out to every read-model trait. - Convert clear on/off flags to bool (child_lock, not_disturb, dust_switch, auto_boost, multi_map_switch, line_laser_obstacle_avoidance) and map dust_setting to YXDeviceDustCollectionFrequency. - set_frequency now takes YXDeviceDustCollectionFrequency only (no int union). - Drop redundant decamelize comments on dpNetInfo/dpTimeZone; keep the note documenting the device's "ipAdress" typo. - Update CLI Q10 setters and tests for the new trait layout. * feat: decode Q10 carpet/area/mop/floor-direction status into enums+bools Reverse-engineered against live Q10 hardware (toggling each setting in the app and diffing the status dump): - area_unit -> YXAreaUnit (square_meter=0, square_feet=1) - carpet_clean_type -> YXCarpetCleanType (rise=0, avoid=1, ignore=2, cross=3) - mop_state -> bool (mop module attached) - ground_clean -> bool ("clean along floor direction") - Rename YXDeviceDustCollectionFrequency.DAILY -> REGULAR to match the app ("regular" vs "frequent" 15/30/45/60); codes unchanged. breakpoint_clean / add_clean_state / timer_type / user_plan / robot_type remain int (no app control found, runtime-only, or constant). * feat: decode Q10 add_clean_state as a bool Observed on live hardware: dpAddCleanState (96) pulses 0->1 while the app's "re cleaning" (draw-a-rectangle / add-area) request is in progress, then back to 0 once the robot has the area. Model it as a bool. * fix: stop unmapped Q10 data points from logging "not a valid code" warnings ss07 hardware pushes data points this library does not model (confirmed live: DPs 112 and 113, which stay 0 across docked/charging, segment cleaning, lifted-off-ground fault, returning to dock and dustbin-removed states, and are absent from the official app's vacuum plugin). Decoding every status push ran them through `from_code_optional`, which routed through `from_code` and logged a WARNING before returning None -- so each unknown DP spammed " is not a valid code for B01_Q10_DP". - Make `from_code_optional` (and the int path of `from_any_optional`) do their own silent lookup instead of going through `from_code`. `from_code` stays strict (warns + raises) for callers that require a known code. - Remove the duplicate-coded legacy entries `JUMP_SCAN` (101) and `CLIFF_RESTRICTED_AREA` (102): they collided with the confirmed `COMMON` (101) / `REQUEST_DPS` (102) codes, shadowing them in `from_code`, and are unused with unverifiable codes. Also drop the orphaned `CLIFF_RESTRICTED_AREA_UP` (103). - Document DPs 112/113 as observed-but-unidentified. - Tests: assert `from_code_optional` is silent, `from_code` still warns, and the B01 decode drops 112/113 without logging. * fix: avoid Q10 Consumable/NetworkInfo shadowing v1 in roborock.data The new per-concern Q10 read-models `Consumable` and `NetworkInfo` were star-exported into the `roborock.data` namespace where v1 also exports those names, so `from roborock.data import Consumable` silently resolved to the v1 class (and mypy flagged the incompatible re-import). Rename the Q10 read-models to `Q10Consumable` / `Q10NetworkInfo`; the traits already import them by fully qualified path. * fix: keep Q10 CLIFF_RESTRICTED_AREA_UP (103); ss07 pushes it While removing the duplicate-coded legacy entries I also dropped DP 103, but live capture shows ss07 hardware does push data point 103 (an empty list when no cliff-restricted areas are configured). Restore CLIFF_RESTRICTED_AREA_UP at 103; only the genuinely-colliding dpJumpScan (101) and dpCliffRestrictedArea (102) stay removed. * fix: correct Q10 vacuum command payloads, verified against ss07 hardware The VacuumTrait movement commands were unverified guesses (with TODO notes) and sent payload shapes the device ignores. Reverse-engineered the correct format from the official app's vacuum plugin and confirmed each one live against an ss07 robot (watching the DP stream react): - start_clean: {"201": 1} (was {"201": {"cmd": 1}}) -> clean_task_type 1 - pause_clean: {"204": 0} (was {"204": {}}) - resume_clean: {"205": 0} (was {"205": {}}) - stop_clean: {"206": 0} (was {"206": {}}) - return_to_dock: {"202": 5} (was {"203": {}}; dpStartBack=charge, and DP 203 is not the dock-return command) - empty_dustbin: {"203": 2} (already correct; confirmed -> status emptying) Also add spot_clean ({"201": 5} -> clean_task_type 5), confirmed live, plus a `q10-vacuum-spot` CLI command. set_fan_level / set_clean_mode were confirmed to work as bare DP writes (no dpCommon wrapper needed, unlike the settings). Segment ({"201": 2}), zone ({"201": 3}) and build-map ({"201": 4}) clean are documented but not exposed: they need a room/zone selection payload whose shape could not be verified (the broker rejects subscribing to the app's command topic, so the app's exact request can't be captured). * fix: show all Q10 read-model traits in status, wait for fresh push The Q10 status command only printed properties.status, omitting the other read-model traits that refresh() populates (volume, child lock, do-not-disturb, dust collection, network info, consumables). It also gated on properties.status.status is not None, which the persistent subscribe loop can satisfy with stale data pushed before the command ran. Register update listeners on all refreshed traits, fire refresh(), and wait for a fresh update (with a short settle window for multi-packet pushes) before dumping every trait. Verified live against ss07 (R1). * feat: Q10 (B01/ss07) room/segment cleaning Add VacuumTrait.clean_segments(segment_ids) for the Q10. dpStartClean (201) carries the room selection as an object: {"cmd": 2, "clean_paramters": [ids]} (cmd 2 = segment clean; "clean_paramters" mirrors the firmware's misspelling). Segment ids are the same room ids reported by the map (map.rooms). Captured from the official app and verified live against ss07 hardware (clean_task_type -> 2 / electoral). Adds a q10-clean-segments CLI command and trait payload tests. * refactor: use YXDeviceCleanTask.ELECTORAL and inline misspelling note Address review feedback on clean_segments: replace the literal 2 with YXDeviceCleanTask.ELECTORAL.code, move the 'clean_paramters' misspelling note inline next to the params, and drop the official-app comment. --- roborock/cli.py | 33 +++++++++++++++++++++ roborock/devices/traits/b01/q10/vacuum.py | 32 ++++++++++++++++---- tests/devices/traits/b01/q10/test_vacuum.py | 2 ++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/roborock/cli.py b/roborock/cli.py index c7366275a..9def26afc 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -1482,6 +1482,39 @@ async def q10_vacuum_spot(ctx: click.Context, device_id: str) -> None: click.echo(f"Error: {e}") +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option( + "--segments", + required=True, + help="Comma-separated room/segment ids to clean (see the `rooms` command), e.g. 9,2", +) +@click.pass_context +@async_command +async def q10_clean_segments(ctx: click.Context, device_id: str, segments: str) -> None: + """Start a room / segment clean on a Q10 device. + + Room ids come from the `rooms` command (the device's map rooms). + """ + context: RoborockContext = ctx.obj + try: + segment_ids = [int(s) for s in segments.split(",") if s.strip()] + except ValueError: + click.echo("--segments must be comma-separated integers, e.g. 9,2") + return + if not segment_ids: + click.echo("No segment ids provided") + return + try: + trait = await _q10_vacuum_trait(context, device_id) + await trait.clean_segments(segment_ids) + click.echo(f"Starting room clean of segments {segment_ids}...") + except RoborockUnsupportedFeature: + click.echo("Device does not support B01 Q10 protocol. Is it a Q10?") + except RoborockException as e: + click.echo(f"Error: {e}") + + async def _q10_set(ctx: click.Context, device_id: str, apply: Callable[[Any], Any], message: str) -> None: """Run a Q10 settings write and report the result.""" context: RoborockContext = ctx.obj diff --git a/roborock/devices/traits/b01/q10/vacuum.py b/roborock/devices/traits/b01/q10/vacuum.py index fbe447de7..2747e0248 100644 --- a/roborock/devices/traits/b01/q10/vacuum.py +++ b/roborock/devices/traits/b01/q10/vacuum.py @@ -3,6 +3,7 @@ from roborock.data.b01_q10.b01_q10_code_mappings import ( B01_Q10_DP, YXCleanType, + YXDeviceCleanTask, YXFanLevel, ) @@ -23,17 +24,38 @@ def __init__(self, command: CommandTrait) -> None: async def start_clean(self) -> None: """Start a whole-home clean. - The ``dpStartClean`` (201) command takes a bare integer task code: - ``1`` = whole-home, ``2`` = segment/room, ``3`` = zone, ``4`` = build map, - ``5`` = spot. Whole-home and spot take no extra parameters; segment and - zone need a room/zone selection whose payload shape is not yet known, so - only whole-home (here) and spot (:meth:`spot_clean`) are exposed. + The ``dpStartClean`` (201) command selects a task by code: ``1`` = + whole-home, ``2`` = segment/room (see :meth:`clean_segments`), ``3`` = + zone, ``4`` = build map, ``5`` = spot. Whole-home and spot accept the + bare integer code; segment cleaning needs a room selection (an object + payload) instead. Verified live against ss07 hardware: ``{"dps": {"201": 1}}`` starts a whole-home clean (clean_task_type -> 1). """ await self._command.send(command=B01_Q10_DP.START_CLEAN, params=1) + async def clean_segments(self, segment_ids: list[int]) -> None: + """Start a room / segment clean for the given segment (room) ids. + + The ids are the same room ids the device reports on its map (see the Q10 + ``MapContentTrait`` -- ``map.rooms``, each with an ``id``). + + Unlike whole-home and spot, ``dpStartClean`` (201) carries the room + selection as an object: ``{"cmd": , "clean_paramters": [, ...]}``, + where ``cmd`` is the segment-clean task code. + + Verified live against ss07 hardware: sending + ``{"dps": {"201": {"cmd": 2, "clean_paramters": [9]}}}`` starts cleaning + room 9 (clean_task_type -> 2 / electoral). + """ + await self._command.send( + command=B01_Q10_DP.START_CLEAN, + # "clean_paramters" intentionally mirrors the device's misspelling of + # "parameters" -- the firmware only accepts that exact key. + params={"cmd": YXDeviceCleanTask.ELECTORAL.code, "clean_paramters": segment_ids}, + ) + async def spot_clean(self) -> None: """Start a spot / part clean around the robot's current position. diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index e736d28c2..17f6b3de0 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -30,6 +30,8 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: [ # Payloads verified live against ss07 hardware. (lambda x: x.start_clean(), {"201": 1}), + (lambda x: x.clean_segments([9]), {"201": {"cmd": 2, "clean_paramters": [9]}}), + (lambda x: x.clean_segments([1, 2]), {"201": {"cmd": 2, "clean_paramters": [1, 2]}}), (lambda x: x.spot_clean(), {"201": 5}), (lambda x: x.pause_clean(), {"204": 0}), (lambda x: x.resume_clean(), {"205": 0}), From ffb53a98fd2137645fd1f54e05a0521a350bb53f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 28 Jun 2026 16:39:42 +0000 Subject: [PATCH 02/21] 5.22.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 259f43b11..b88fb05ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,71 @@ +## v5.22.0 (2026-06-28) + +### Bug Fixes + +- Avoid Q10 Consumable/NetworkInfo shadowing v1 in roborock.data + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Correct Q10 vacuum command payloads, verified against ss07 hardware + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Keep Q10 CLIFF_RESTRICTED_AREA_UP (103); ss07 pushes it + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Show all Q10 read-model traits in status, wait for fresh push + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Stop unmapped Q10 data points from logging "not a valid code" warnings + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +### Features + +- Add Q10 (B01/ss07) settings writers + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Add Q10 dust-collection frequency writer + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Decode Q10 add_clean_state as a bool + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Decode Q10 carpet/area/mop/floor-direction status into enums+bools + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Expand Q10 (B01/ss07) status support and add device info + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Q10 (B01/ss07) room/segment cleaning + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Q10 (B01/ss07) room/segment cleaning (clean_segments) + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +### Refactoring + +- Split Q10 status/settings into per-concern traits + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + +- Use YXDeviceCleanTask.ELECTORAL and inline misspelling note + ([#851](https://github.com/Python-roborock/python-roborock/pull/851), + [`e71611b`](https://github.com/Python-roborock/python-roborock/commit/e71611b2b57f2ca1dc1d2c42d6fce0d3f1b6169b)) + + ## v5.21.0 (2026-06-25) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 0ee1c99ab..0219c67eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.21.0" +version = "5.22.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index f62221174..076780160 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.21.0" +version = "5.22.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 79a996de463a59ff4de875579a417d157e8cc2e8 Mon Sep 17 00:00:00 2001 From: "Andrew L. Yeats" Date: Thu, 2 Jul 2026 21:29:36 -0400 Subject: [PATCH 03/21] feat: Q10 (B01/ss07) clean-record history trait (#857) * feat: add Q10 (B01/ss07) clean-record history trait Q10 clean history is push-driven over dpCleanRecord (DP 52), unlike the Q7's synchronous get_record_list RPC. Adds: - Q10CleanRecord: parses the 12-field op:list underscore string (raw retained), with crash-safe enum accessors .scope/.work/.result/.started_by (IntEnums that return None for an unmapped code, never raising like the YX from_code path) - CleanHistoryTrait: refresh() sends {op:list}; update_from_dps decodes both the full list and the single op:notify clean-finished push (upsert by record_id, newest-first); registered as a read-model trait in the dispatch loop Field layout is the device app's own (setHoldData), cross-confirmed by ioBroker's Q10CleanRecordService. Tests cover decode, enum labels + unmapped-safety, and the list/notify paths. * refactor: widen parse_record's parameter type to Any | None Co-authored-by: Allen Porter * refactor: call the static parse_record via the class, not self parse_record is a @staticmethod, so call it as CleanRecordConverter.parse_record(...) rather than self.parse_record(...). --------- Co-authored-by: Andrew Yeats Co-authored-by: Allen Porter --- .../data/b01_q10/b01_q10_code_mappings.py | 37 ++++ roborock/data/b01_q10/b01_q10_containers.py | 50 +++++ roborock/devices/traits/b01/q10/__init__.py | 7 + .../devices/traits/b01/q10/clean_history.py | 164 +++++++++++++++ .../traits/b01/q10/test_clean_history.py | 188 ++++++++++++++++++ .../__snapshots__/test_b01_q10_protocol.ambr | 28 +++ .../b01_q10_protocol/dpCleanRecord_list.json | 1 + .../dpCleanRecord_notify.json | 1 + 8 files changed, 476 insertions(+) create mode 100644 roborock/devices/traits/b01/q10/clean_history.py create mode 100644 tests/devices/traits/b01/q10/test_clean_history.py create mode 100644 tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_list.json create mode 100644 tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_notify.json diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index c3e1771c9..78fc1b2c3 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -226,6 +226,43 @@ class YXDeviceCleanTask(RoborockModeEnum): PART = "part", 5 +class YXCleanScope(RoborockModeEnum): + """Clean scope/type as stored in a *clean record* (``dpCleanRecord``, field 7). + + This is the same conceptual axis as the live :class:`YXDeviceCleanTask`, but the + persisted record uses a different integer encoding -- e.g. a full clean records + ``0`` here vs ``1`` (``smart``) live, and a select-rooms clean records ``1`` here + vs ``2`` (``electoral``) live. Ground-truthed against the app's History labels; + code ``2`` was never observed on ss07 and is intentionally unmapped (so it + resolves to ``None`` rather than a guessed label). + """ + + UNKNOWN = "unknown", -1 + FULL = "full", 0 + SELECTIVE_ROOM = "selective_room", 1 + ZONE = "zone", 3 + SPOT = "spot", 4 + + +class YXCleaningResult(RoborockModeEnum): + """How a clean ended, as stored in a clean record (``dpCleanRecord``, field 9).""" + + UNKNOWN = "unknown", -1 + INTERRUPTED = "interrupted", 0 # ended on a fault + COMPLETED = "completed", 1 + STOPPED = "stopped", 2 # ended early without a fault + + +class YXStartMethod(RoborockModeEnum): + """What initiated a clean, as stored in a clean record (``dpCleanRecord``, field 10).""" + + UNKNOWN = "unknown", -1 + REMOTE = "remote", 0 + APP = "app", 1 + TIMER = "timer", 2 # schedule / timer + BUTTON = "button", 3 # device button + + class YXDeviceDustCollectionFrequency(RoborockModeEnum): # The app exposes "regular" (code 0) vs "frequent", where "frequent" selects # one of the every-N-cleans intervals below. diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 7883ed739..7b0351a69 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -6,6 +6,7 @@ automatically update objects from raw device responses. """ +import datetime from dataclasses import dataclass, field from ..containers import RoborockBase @@ -14,12 +15,15 @@ YXAreaUnit, YXBackType, YXCarpetCleanType, + YXCleaningResult, YXCleanLine, + YXCleanScope, YXCleanType, YXDeviceCleanTask, YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXStartMethod, YXWaterLevel, ) @@ -32,6 +36,52 @@ class dpCleanRecord(RoborockBase): data: list +@dataclass +class Q10CleanRecord(RoborockBase): + """A single Q10 (ss07) clean record decoded from a ``dpCleanRecord`` (DP 52) entry. + + The device returns each record as a 12-field underscore-delimited string in the + ``data`` list of a ``{"op": "list"}`` query (or the ``id`` of an ``{"op": "notify"}`` + push). The ``*_len`` values are internal blob-length metrics whose units aren't + confirmed; the original ``raw`` string is always retained. The enum fields resolve + an unmapped/unset code to ``None`` rather than guessing. + """ + + raw: str + record_id: str | None = None + start_time: int | None = None + """Clean start time, Unix seconds.""" + clean_time: int | None = None + """Cleaning time, minutes.""" + clean_area: int | None = None + """Cleaned area in square meters.""" + map_len: int | None = None + """Length of the saved map blob for this record (0 = none stored).""" + path_len: int | None = None + """Length of the saved path blob for this record (0 = none stored).""" + virtual_len: int | None = None + """Length of the saved virtual-restriction blob for this record (0 = none stored).""" + clean_mode: YXCleanScope | None = None + """Clean scope/type (full / selective-room / zone / spot). Same axis as the live + :class:`YXDeviceCleanTask` but a different record encoding -- see :class:`YXCleanScope`.""" + work_mode: YXCleanType | None = None + """Actual work performed (vac+mop / vacuum / mop) -- the same enum :class:`Q10Status` + uses for the live clean-mode DP. Records only ever carry 1/2/3 here.""" + cleaning_result: YXCleaningResult | None = None + """How the clean ended: 0 interrupted (fault), 1 completed, 2 stopped (no fault).""" + start_method: YXStartMethod | None = None + """What initiated the clean: 0 remote, 1 app, 2 timer, 3 button.""" + collect_dust_count: int | None = None + """Number of dock auto-empties during the clean.""" + + @property + def start_datetime(self) -> datetime.datetime | None: + """The start time as a timezone-aware (UTC) datetime.""" + if self.start_time is not None: + return datetime.datetime.fromtimestamp(self.start_time).astimezone(datetime.UTC) + return None + + @dataclass class dpMultiMap(RoborockBase): op: str diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 8f4a52026..d026052c0 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -12,6 +12,7 @@ from .button_light import ButtonLightTrait from .child_lock import ChildLockTrait +from .clean_history import CleanHistoryTrait from .command import CommandTrait from .consumable import ConsumableTrait from .do_not_disturb import DoNotDisturbTrait @@ -27,6 +28,7 @@ "Q10PropertiesApi", "ButtonLightTrait", "ChildLockTrait", + "CleanHistoryTrait", "ConsumableTrait", "DoNotDisturbTrait", "DustCollectionTrait", @@ -78,6 +80,9 @@ class Q10PropertiesApi(Trait): map: MapContentTrait """Trait for fetching the current parsed map (image + rooms).""" + clean_history: CleanHistoryTrait + """Trait for fetching the device clean-record history (``dpCleanRecord``).""" + def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self._channel = channel @@ -93,6 +98,7 @@ def __init__(self, channel: MqttChannel) -> None: self.network_info = NetworkInfoTrait() self.consumable = ConsumableTrait() self.map = MapContentTrait() + self.clean_history = CleanHistoryTrait(self.command) # Read-model traits updated from the device's DPS push stream. self._updatable_traits = [ self.status, @@ -102,6 +108,7 @@ def __init__(self, channel: MqttChannel) -> None: self.dust_collection, self.network_info, self.consumable, + self.clean_history, ] self._subscribe_task: asyncio.Task[None] | None = None diff --git a/roborock/devices/traits/b01/q10/clean_history.py b/roborock/devices/traits/b01/q10/clean_history.py new file mode 100644 index 000000000..fde2bb2fe --- /dev/null +++ b/roborock/devices/traits/b01/q10/clean_history.py @@ -0,0 +1,164 @@ +"""Clean history trait for Q10 B01 devices. + +Unlike the Q7 (which exposes a synchronous ``service.get_record_list`` RPC), the +Q10 is push-driven: :meth:`CleanHistoryTrait.refresh` sends an ``{"op": "list"}`` +query for ``dpCleanRecord`` (DP 52) over the ``dpCommon`` channel, and the device +publishes its clean-record list back on the subscribe stream, which +:meth:`CleanHistoryTrait.update_from_dps` then decodes. + +Wire parsing is separated from state management: :class:`CleanRecordConverter` turns +a ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`, and the trait applies it. +""" + +import logging +from dataclasses import dataclass, field +from typing import Any + +from roborock.data.b01_q10.b01_q10_code_mappings import ( + B01_Q10_DP, + YXCleaningResult, + YXCleanScope, + YXCleanType, + YXStartMethod, +) +from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord + +from .command import CommandTrait +from .common import UpdatableTrait + +__all__ = [ + "CleanHistoryTrait", + "CleanRecordConverter", + "CleanRecordPush", +] + +_LOGGER = logging.getLogger(__name__) + +_RECORD_FIELD_COUNT = 12 + + +@dataclass +class CleanRecordPush: + """A parsed ``dpCleanRecord`` push: the records it carries and how to apply them. + + ``replace`` is ``True`` for an ``{"op": "list"}`` reply (the full history, which + replaces the current state) and ``False`` for an ``{"op": "notify"}`` push (a + single just-finished record, which is upserted). + """ + + records: list[Q10CleanRecord] = field(default_factory=list) + replace: bool = False + + +class CleanRecordConverter: + """Converts a raw ``dpCleanRecord`` (DP 52) envelope into a :class:`CleanRecordPush`. + + Mirrors the converter-per-object pattern used by the other Q10 traits: parsing the + wire payload lives here, separate from the trait that manages the record list. + """ + + def parse(self, envelope: dict[str, Any]) -> CleanRecordPush | None: + """Parse a decoded ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`. + + Returns ``None`` for an envelope that carries nothing usable (so the trait can + ignore it without changing state). Malformed individual records are skipped. + """ + if envelope.get("op") == "notify": + record = CleanRecordConverter.parse_record(envelope.get("id")) + return CleanRecordPush([record], replace=False) if record is not None else None + data = envelope.get("data") + if not isinstance(data, list): + return None + records = [record for item in data if (record := CleanRecordConverter.parse_record(item)) is not None] + return CleanRecordPush(records, replace=True) + + @staticmethod + def parse_record(raw: Any | None) -> Q10CleanRecord | None: + """Decode one underscore-delimited clean-record string into a :class:`Q10CleanRecord`. + + The device joins 12 values with ``_``: recordId, startTime (Unix s), cleanTime + (min), cleanArea (m2), mapLen, pathLen, virtualLen, cleanMode, workMode, + cleaningResult, startMethod, collectDustCount. Returns ``None`` for anything but + a well-formed 12-field string; an unmapped enum code resolves to ``None`` on its + field (``raw`` keeps the original). + """ + if not isinstance(raw, str): + return None + parts = raw.split("_") + if len(parts) != _RECORD_FIELD_COUNT: + return None + try: + return Q10CleanRecord( + raw=raw, + record_id=parts[0], + start_time=int(parts[1]), + clean_time=int(parts[2]), + clean_area=int(parts[3]), + map_len=int(parts[4]), + path_len=int(parts[5]), + virtual_len=int(parts[6]), + clean_mode=YXCleanScope.from_code_optional(int(parts[7])), + work_mode=YXCleanType.from_code_optional(int(parts[8])), + cleaning_result=YXCleaningResult.from_code_optional(int(parts[9])), + start_method=YXStartMethod.from_code_optional(int(parts[10])), + collect_dust_count=int(parts[11]), + ) + except ValueError: + return None + + +class CleanHistoryTrait(UpdatableTrait): + """Access to the Q10 clean-record history (``dpCleanRecord``, DP 52). + + A read-model trait updated from the DPS stream like the others, but it overrides + :meth:`update_from_dps` because the payload is a structured push (a record list, + or a single ``op:"notify"`` record) rather than a flat data-point-to-field map. + """ + + def __init__(self, command: CommandTrait) -> None: + """Initialize the clean history trait.""" + UpdatableTrait.__init__(self, command, _LOGGER) + self._converter = CleanRecordConverter() + self.records: list[Q10CleanRecord] = [] + """Decoded clean records, most recent first.""" + + @property + def last_record(self) -> Q10CleanRecord | None: + """The most recent clean record, or ``None`` if there are none.""" + return self.records[0] if self.records else None + + async def refresh(self) -> None: + """Request the clean-record list from the device. + + This sends the query and returns immediately; the records arrive + asynchronously on the device stream and populate :attr:`records` once + :meth:`update_from_dps` processes the ``dpCleanRecord`` push. + """ + if self._command is None: + raise ValueError("Trait is read-only; no command channel was provided") + await self._command.send( + B01_Q10_DP.COMMON, + params={str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "list"}}, + ) + + def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: + """Apply a ``dpCleanRecord`` push (a full list reply or a single notify).""" + envelope = decoded_dps.get(B01_Q10_DP.CLEAN_RECORD) + if not isinstance(envelope, dict): + return + push = self._converter.parse(envelope) + if push is None: + return + self._apply(push) + + def _apply(self, push: CleanRecordPush) -> None: + """Merge or replace the records from ``push``, then sort newest-first and notify.""" + if push.replace: + records = list(push.records) + else: + updated_ids = {record.record_id for record in push.records} + records = [record for record in self.records if record.record_id not in updated_ids] + records.extend(push.records) + records.sort(key=lambda record: record.start_time or 0, reverse=True) + self.records = records + self._notify_update() diff --git a/tests/devices/traits/b01/q10/test_clean_history.py b/tests/devices/traits/b01/q10/test_clean_history.py new file mode 100644 index 000000000..ff09b0efc --- /dev/null +++ b/tests/devices/traits/b01/q10/test_clean_history.py @@ -0,0 +1,188 @@ +import json + +import pytest + +from roborock.data.b01_q10.b01_q10_code_mappings import ( + B01_Q10_DP, + YXCleaningResult, + YXCleanScope, + YXCleanType, + YXStartMethod, +) +from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord +from roborock.devices.traits.b01.q10 import Q10PropertiesApi +from roborock.devices.traits.b01.q10.clean_history import CleanHistoryTrait, CleanRecordConverter +from tests.fixtures.channel_fixtures import FakeChannel + +# 12 underscore fields: recordId, startTime, cleanTime, cleanArea, mapLen, pathLen, +# virtualLen, cleanMode, workMode, cleaningResult, startMethod, collectDustCount. +RECORD_A = "abc123def456_1781226271_27_19_6692_12053_4_0_2_1_1_1" +RECORD_B = "def456abc789_1781139871_41_25_7100_18420_5_1_1_0_2_1" + + +@pytest.fixture(name="fake_channel") +def fake_channel_fixture() -> FakeChannel: + return FakeChannel() + + +@pytest.fixture(name="q10_api") +def q10_api_fixture(fake_channel: FakeChannel) -> Q10PropertiesApi: + return Q10PropertiesApi(fake_channel) # type: ignore[arg-type] + + +@pytest.fixture(name="clean_history") +def clean_history_fixture(q10_api: Q10PropertiesApi) -> CleanHistoryTrait: + return q10_api.clean_history + + +def _list_push(*records: str) -> dict[B01_Q10_DP, object]: + """Build a decoded DPS update carrying a clean-record list.""" + return {B01_Q10_DP.CLEAN_RECORD: {"op": "list", "result": 1, "id": "", "data": list(records)}} + + +def _notify_push(record: str) -> dict[B01_Q10_DP, object]: + """Build a decoded DPS update carrying a single clean-finished notify push.""" + return {B01_Q10_DP.CLEAN_RECORD: {"op": "notify", "result": 1, "id": record}} + + +def test_parse_clean_record() -> None: + record = CleanRecordConverter.parse_record(RECORD_A) # cleanMode/workMode/result/start = 0/2/1/1 + assert record is not None + assert record.raw == RECORD_A + assert record.record_id == "abc123def456" + assert record.start_time == 1781226271 + assert record.clean_time == 27 + assert record.clean_area == 19 + assert record.map_len == 6692 + assert record.path_len == 12053 + assert record.virtual_len == 4 + assert record.collect_dust_count == 1 + assert record.start_datetime is not None + # Enum-typed fields (no separate property converters). + assert record.clean_mode is YXCleanScope.FULL + assert record.work_mode is YXCleanType.VACUUM + assert record.cleaning_result is YXCleaningResult.COMPLETED + assert record.start_method is YXStartMethod.APP + + record_b = CleanRecordConverter.parse_record(RECORD_B) # 1/1/0/2 + assert record_b is not None + assert record_b.clean_mode is YXCleanScope.SELECTIVE_ROOM + assert record_b.work_mode is YXCleanType.VAC_AND_MOP + assert record_b.cleaning_result is YXCleaningResult.INTERRUPTED + assert record_b.start_method is YXStartMethod.TIMER + + +@pytest.mark.parametrize( + "raw", + [ + "too_few_fields", + "a_b_c_d_e_f_g_h_i_j_k_l_m", # 13 fields + "id_NOTANUMBER_27_19_6692_12053_4_0_2_1_1_1", # non-int where int expected + ], +) +def test_parse_clean_record_malformed_returns_none(raw: str) -> None: + assert CleanRecordConverter.parse_record(raw) is None + + +def test_unmapped_enum_code_is_crash_safe() -> None: + """An unmapped code (cleanMode 2) yields None on the field; the raw string is kept.""" + record = CleanRecordConverter.parse_record("x_1781226271_1_1_0_0_0_2_2_1_0_0") # cleanMode=2, startMethod=0 + assert record is not None + assert record.clean_mode is None # code 2 unmapped -> None, never raises + assert record.raw.split("_")[7] == "2" # the original is retained in raw + assert record.start_method is YXStartMethod.REMOTE # 0 maps even though unobserved live + assert Q10CleanRecord(raw="").clean_mode is None # an unset field -> None + + +def test_update_from_dps_populates_records_newest_first(clean_history: CleanHistoryTrait) -> None: + updates = 0 + + def _on_update() -> None: + nonlocal updates + updates += 1 + + clean_history.add_update_listener(_on_update) + + # RECORD_B starts earlier than RECORD_A; supply oldest-first to prove sorting. + clean_history.update_from_dps(_list_push(RECORD_B, RECORD_A)) + + assert [r.record_id for r in clean_history.records] == ["abc123def456", "def456abc789"] + assert clean_history.last_record is not None + assert clean_history.last_record.start_time == 1781226271 + assert updates == 1 + + +def test_update_from_dps_skips_malformed_entries(clean_history: CleanHistoryTrait) -> None: + clean_history.update_from_dps(_list_push(RECORD_A, "garbage", "1_2_3")) + assert len(clean_history.records) == 1 + assert clean_history.records[0].record_id == "abc123def456" + + +@pytest.mark.parametrize( + "dps", + [ + {}, # no clean-record key at all + {B01_Q10_DP.CLEAN_RECORD: {"op": "notify", "result": 1, "id": "x"}}, # malformed notify id + {B01_Q10_DP.CLEAN_RECORD: False}, # bool sentinel, not a dict + ], +) +def test_update_from_dps_ignores_unusable_pushes( + clean_history: CleanHistoryTrait, dps: dict[B01_Q10_DP, object] +) -> None: + updates = 0 + + def _on_update() -> None: + nonlocal updates + updates += 1 + + clean_history.add_update_listener(_on_update) + clean_history.update_from_dps(dps) # type: ignore[arg-type] + + assert clean_history.records == [] + assert clean_history.last_record is None + assert updates == 0 + + +def test_update_from_dps_notify_inserts_record(clean_history: CleanHistoryTrait) -> None: + updates = 0 + + def _on_update() -> None: + nonlocal updates + updates += 1 + + clean_history.add_update_listener(_on_update) + clean_history.update_from_dps(_notify_push(RECORD_A)) + + assert [r.record_id for r in clean_history.records] == ["abc123def456"] + assert updates == 1 + + +def test_update_from_dps_notify_upserts_newest_first(clean_history: CleanHistoryTrait) -> None: + clean_history.update_from_dps(_list_push(RECORD_B)) # older record present + clean_history.update_from_dps(_notify_push(RECORD_A)) # newer -> prepend + + assert [r.record_id for r in clean_history.records] == ["abc123def456", "def456abc789"] + + # A notify for an existing record_id replaces it (no duplicate). + updated_a = RECORD_A.replace("_27_", "_99_") # same id, clean_time 27 -> 99 + clean_history.update_from_dps(_notify_push(updated_a)) + + assert [r.record_id for r in clean_history.records] == ["abc123def456", "def456abc789"] + assert clean_history.records[0].clean_time == 99 + + +def test_update_from_dps_notify_ignores_malformed(clean_history: CleanHistoryTrait) -> None: + clean_history.update_from_dps(_list_push(RECORD_A)) + clean_history.update_from_dps(_notify_push("garbage")) # malformed id -> ignored + + assert [r.record_id for r in clean_history.records] == ["abc123def456"] + + +async def test_refresh_sends_op_list(q10_api: Q10PropertiesApi, fake_channel: FakeChannel) -> None: + await q10_api.clean_history.refresh() + + assert len(fake_channel.published_messages) == 1 + raw_payload = fake_channel.published_messages[0].payload + assert raw_payload is not None + payload = json.loads(raw_payload.decode()) + assert payload == {"dps": {"101": {"52": {"op": "list"}}}} diff --git a/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr b/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr index a1c15e8c1..24c5125e9 100644 --- a/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr +++ b/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr @@ -6,6 +6,34 @@ } ''' # --- +# name: test_decode_rpc_payload[dpCleanRecord_list] + ''' + { + "dpCleanRecord": { + "data": [ + "yr5t99qi6a36dd76_1781980078_6_4_946_1829_4_00_01_01_1_0", + "7760q1736a38b564_1782100895_6_4_7869_1877_42_00_01_01_1_0", + "cps6q8e76a381b38_1782061387_5_3_8192_2253_42_01_03_01_1_1", + "worv8b5b6a385e8c_1782078579_6_4_7976_2737_42_01_03_01_1_0", + "0548ba9h6a3499cc_1781831446_2_1_7766_3117_42_03_03_01_1_0", + "2rhuh07c6a37c77f_1782040432_0_0_932_21_4_04_01_02_1_0" + ], + "op": "list", + "result": 1 + } + } + ''' +# --- +# name: test_decode_rpc_payload[dpCleanRecord_notify] + ''' + { + "dpCleanRecord": { + "id": "517vu2726a3dfb69_1782446641_3_3_8345_1029_4_01_01_01_2_0", + "op": "notify" + } + } + ''' +# --- # name: test_decode_rpc_payload[dpRequestDps_vacuum_only] ''' { diff --git a/tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_list.json b/tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_list.json new file mode 100644 index 000000000..0cdeb4c33 --- /dev/null +++ b/tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_list.json @@ -0,0 +1 @@ +{"dps": {"101": {"52": {"data": ["yr5t99qi6a36dd76_1781980078_6_4_946_1829_4_00_01_01_1_0", "7760q1736a38b564_1782100895_6_4_7869_1877_42_00_01_01_1_0", "cps6q8e76a381b38_1782061387_5_3_8192_2253_42_01_03_01_1_1", "worv8b5b6a385e8c_1782078579_6_4_7976_2737_42_01_03_01_1_0", "0548ba9h6a3499cc_1781831446_2_1_7766_3117_42_03_03_01_1_0", "2rhuh07c6a37c77f_1782040432_0_0_932_21_4_04_01_02_1_0"], "op": "list", "result": 1}}}, "t": 1782520900} diff --git a/tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_notify.json b/tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_notify.json new file mode 100644 index 000000000..326064c8c --- /dev/null +++ b/tests/protocols/testdata/b01_q10_protocol/dpCleanRecord_notify.json @@ -0,0 +1 @@ +{"dps": {"101": {"52": {"id": "517vu2726a3dfb69_1782446641_3_3_8345_1029_4_01_01_01_2_0", "op": "notify"}}}, "t": 1782446954} From 5abe47871ea9faee533657a007624c42568eb119 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 3 Jul 2026 01:31:44 +0000 Subject: [PATCH 04/21] 5.23.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 23 +++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b88fb05ad..fde63059f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ +## v5.23.0 (2026-07-03) + +### Features + +- Add Q10 (B01/ss07) clean-record history trait + ([#857](https://github.com/Python-roborock/python-roborock/pull/857), + [`79a996d`](https://github.com/Python-roborock/python-roborock/commit/79a996de463a59ff4de875579a417d157e8cc2e8)) + +- Q10 (B01/ss07) clean-record history trait + ([#857](https://github.com/Python-roborock/python-roborock/pull/857), + [`79a996d`](https://github.com/Python-roborock/python-roborock/commit/79a996de463a59ff4de875579a417d157e8cc2e8)) + +### Refactoring + +- Call the static parse_record via the class, not self + ([#857](https://github.com/Python-roborock/python-roborock/pull/857), + [`79a996d`](https://github.com/Python-roborock/python-roborock/commit/79a996de463a59ff4de875579a417d157e8cc2e8)) + +- Widen parse_record's parameter type to Any | None + ([#857](https://github.com/Python-roborock/python-roborock/pull/857), + [`79a996d`](https://github.com/Python-roborock/python-roborock/commit/79a996de463a59ff4de875579a417d157e8cc2e8)) + + ## v5.22.0 (2026-06-28) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 0219c67eb..e01224ac2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.22.0" +version = "5.23.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 076780160..3bd6ddaeb 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.22.0" +version = "5.23.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From e3c97c68ce5cf38022241bf85d68977c223b8922 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 2 Jul 2026 19:31:08 -0700 Subject: [PATCH 05/21] fix: verify MQTT connection requires successful subscription (#858) * fix: verify MQTT connection requires successful subscription * docs: clarify is_mqtt_connected docstring with motivation * refactor: remove unused fixtures in tests --- roborock/devices/rpc/v1_channel.py | 10 ++++-- tests/devices/rpc/test_v1_channel.py | 50 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/roborock/devices/rpc/v1_channel.py b/roborock/devices/rpc/v1_channel.py index 3197c6b30..63028e6bd 100644 --- a/roborock/devices/rpc/v1_channel.py +++ b/roborock/devices/rpc/v1_channel.py @@ -204,8 +204,14 @@ def is_local_connected(self) -> bool: @property def is_mqtt_connected(self) -> bool: - """Return whether MQTT connection is available.""" - return self._mqtt_channel.is_connected + """Return whether MQTT connection is available for the device. + + This requires the MQTT session to be connected to the broker and + the subscription to the device's topic to have successfully + been established (to handle cases where the device is offline + or deleted). + """ + return self._mqtt_channel.is_connected and self._mqtt_unsub is not None @property def rpc_channel(self) -> V1RpcChannel: diff --git a/tests/devices/rpc/test_v1_channel.py b/tests/devices/rpc/test_v1_channel.py index 24e63c773..278a796b5 100644 --- a/tests/devices/rpc/test_v1_channel.py +++ b/tests/devices/rpc/test_v1_channel.py @@ -233,6 +233,56 @@ async def test_v1_channel_mqtt_disconnected( assert not mock_mqtt_channel.subscribers +async def test_v1_channel_mqtt_subscription_fails_local_succeeds( + v1_channel: V1Channel, + mock_mqtt_channel: FakeChannel, + device_cache: DeviceCache, +) -> None: + """Test MQTT subscription failure while local connection succeeds.""" + # Pre-populate cache so we don't query network info via MQTT + device_cache_data = await device_cache.get() + device_cache_data.network_info = TEST_NETWORKING_INFO + await device_cache.set(device_cache_data) + + # Simulate MQTT subscription failing + mock_mqtt_channel.subscribe.side_effect = RoborockException("MQTT subscription failed") + + # Subscribe should succeed via local fallback + callback = Mock() + unsub = await v1_channel.subscribe(callback) + + # Verify MQTT is not reported as connected, but local is + assert not v1_channel.is_mqtt_connected + assert v1_channel.is_local_connected + assert v1_channel.is_connected + + unsub() + + +async def test_v1_channel_all_connection_attempts_fail( + v1_channel: V1Channel, + mock_mqtt_channel: FakeChannel, + mock_local_channel: FakeChannel, + device_cache: DeviceCache, +) -> None: + """Test when both local connect and MQTT subscribe fail.""" + # Pre-populate cache so we don't query network info via MQTT + device_cache_data = await device_cache.get() + device_cache_data.network_info = TEST_NETWORKING_INFO + await device_cache.set(device_cache_data) + + mock_local_channel.connect.side_effect = RoborockException("local down") + mock_mqtt_channel.subscribe.side_effect = RoborockException("MQTT subscription failed") + + with pytest.raises(RoborockException): + await v1_channel.subscribe(Mock()) + + # After a failed subscription, properties should reflect no active connection + assert not v1_channel.is_mqtt_connected + assert not v1_channel.is_local_connected + assert not v1_channel.is_connected + + async def test_v1_channel_subscribe_local_success( v1_channel: V1Channel, mock_mqtt_channel: Mock, From b679d5538ec3b1393111506d03e4888b2b082265 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 3 Jul 2026 02:33:37 +0000 Subject: [PATCH 06/21] 5.23.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 21 +++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fde63059f..dcb5d3e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ +## v5.23.1 (2026-07-03) + +### Bug Fixes + +- Verify MQTT connection requires successful subscription + ([#858](https://github.com/Python-roborock/python-roborock/pull/858), + [`e3c97c6`](https://github.com/Python-roborock/python-roborock/commit/e3c97c68ce5cf38022241bf85d68977c223b8922)) + +### Documentation + +- Clarify is_mqtt_connected docstring with motivation + ([#858](https://github.com/Python-roborock/python-roborock/pull/858), + [`e3c97c6`](https://github.com/Python-roborock/python-roborock/commit/e3c97c68ce5cf38022241bf85d68977c223b8922)) + +### Refactoring + +- Remove unused fixtures in tests + ([#858](https://github.com/Python-roborock/python-roborock/pull/858), + [`e3c97c6`](https://github.com/Python-roborock/python-roborock/commit/e3c97c68ce5cf38022241bf85d68977c223b8922)) + + ## v5.23.0 (2026-07-03) ### Features diff --git a/pyproject.toml b/pyproject.toml index e01224ac2..741b17bd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.23.0" +version = "5.23.1" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 3bd6ddaeb..41f92545d 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.23.0" +version = "5.23.1" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From a793c8e0a3e0359027d491d406dd36f2d62d41f6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 3 Jul 2026 11:32:14 -0700 Subject: [PATCH 07/21] feat: implement dynamic map update listeners and filtering on HomeTrait (#861) This change enables Home Assistant to dynamically add and remove maps/camera entities without restarting. Resolves HA issue: https://github.com/home-assistant/core/issues/175400 --- roborock/devices/traits/v1/home.py | 35 ++++++- tests/devices/traits/v1/test_home.py | 139 +++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/roborock/devices/traits/v1/home.py b/roborock/devices/traits/v1/home.py index 2d9d7bb18..afc134ae9 100644 --- a/roborock/devices/traits/v1/home.py +++ b/roborock/devices/traits/v1/home.py @@ -18,10 +18,12 @@ import asyncio import base64 import logging +from collections.abc import Callable from roborock.data import CombinedMapInfo, MultiMapsListMapInfo, NamedRoomMapping, RoborockBase from roborock.data.v1.v1_code_mappings import RoborockStateCode from roborock.devices.cache import DeviceCache +from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.traits.v1 import common from roborock.exceptions import RoborockDeviceBusy, RoborockException, RoborockInvalidStatus from roborock.roborock_typing import RoborockCommand @@ -36,7 +38,7 @@ MAP_SLEEP = 3 -class HomeTrait(RoborockBase, common.V1TraitMixin): +class HomeTrait(RoborockBase, common.V1TraitMixin, TraitUpdateListener): """Trait that represents a full view of the home layout.""" command = RoborockCommand.GET_MAP_V1 # This is not used @@ -66,6 +68,7 @@ def __init__( accuracy. """ super().__init__() + TraitUpdateListener.__init__(self, logger=_LOGGER) self._status_trait = status_trait self._maps_trait = maps_trait self._map_content = map_content @@ -100,6 +103,7 @@ async def discover_home(self) -> None: _LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex) self._home_map_content = {} else: + self._notify_update() return if self._status_trait.state == RoborockStateCode.cleaning: @@ -202,6 +206,7 @@ async def refresh(self) -> None: map_flag := self._maps_trait.current_map ) is None: _LOGGER.debug("Cannot refresh home data without current map info") + self._notify_update() return # Refresh the map content to ensure we have the latest image and object positions @@ -212,10 +217,26 @@ async def refresh(self) -> None: map_flag, combined_map_info, new_map_content, update_cache=self._discovery_completed ) + def add_update_listener(self, callback: Callable[[], None]) -> Callable[[], None]: + """Register a callback when the trait has been updated. + + Overridden to immediately execute the callback with the current state if populated. + """ + unsub = super().add_update_listener(callback) + if self._home_map_info is not None: + callback() + return unsub + @property def home_map_info(self) -> dict[int, CombinedMapInfo] | None: """Returns the map information for all cached maps.""" - return self._home_map_info + if self._home_map_info is None or self._maps_trait.map_info is None: + return self._home_map_info + return { + mi.map_flag: value + for mi in self._maps_trait.map_info + if (value := self._home_map_info.get(mi.map_flag)) is not None + } @property def current_map_data(self) -> CombinedMapInfo | None: @@ -235,7 +256,13 @@ def current_rooms(self) -> list[NamedRoomMapping]: @property def home_map_content(self) -> dict[int, MapContent] | None: """Returns the map content for all cached maps.""" - return self._home_map_content + if self._home_map_content is None or self._maps_trait.map_info is None: + return self._home_map_content + return { + mi.map_flag: value + for mi in self._maps_trait.map_info + if (value := self._home_map_content.get(mi.map_flag)) is not None + } async def _update_home_cache( self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent] @@ -251,6 +278,7 @@ async def _update_home_cache( await self._device_cache.set(device_cache_data) self._home_map_info = home_map_info self._home_map_content = home_map_content + self._notify_update() async def _update_current_map( self, @@ -283,3 +311,4 @@ async def _update_current_map( if self._home_map_content is None: self._home_map_content = {} self._home_map_content[map_flag] = map_content + self._notify_update() diff --git a/tests/devices/traits/v1/test_home.py b/tests/devices/traits/v1/test_home.py index 9c070e013..42660f6eb 100644 --- a/tests/devices/traits/v1/test_home.py +++ b/tests/devices/traits/v1/test_home.py @@ -748,3 +748,142 @@ async def test_refresh_map_info_prefers_map_info_names_and_adds_missing_rooms( assert sorted_rooms[4].segment_id == 20 assert sorted_rooms[4].name == "Office from rooms_trait" assert sorted_rooms[4].iot_id == "9999001" + + +async def test_home_trait_listener_notifications( + home_trait: HomeTrait, + mock_rpc_channel: AsyncMock, + mock_mqtt_rpc_channel: AsyncMock, + mock_map_rpc_channel: AsyncMock, + device_cache: DeviceCache, + web_api_client: AsyncMock, +) -> None: + """Test that listener callbacks are called when home discovery/updates occur.""" + mock_callback = MagicMock() + unsub = home_trait.add_update_listener(mock_callback) + # Callback should NOT be executed immediately on subscription since home_map_info is None + assert mock_callback.call_count == 0 + + # 1. Test notification on empty cache discovery + mock_rpc_channel.send_command.side_effect = [ + UPDATED_STATUS_MAP_123, + ROOM_MAPPING_DATA_MAP_123, + UPDATED_STATUS_MAP_0, + ROOM_MAPPING_DATA_MAP_0, + ] + mock_mqtt_rpc_channel.send_command.side_effect = [ + MULTI_MAP_LIST_DATA, + {}, + {}, + ] + mock_map_rpc_channel.send_command.side_effect = [ + MAP_BYTES_RESPONSE_2, + MAP_BYTES_RESPONSE_1, + ] + web_api_client.get_rooms.return_value = [ + HomeDataRoom(id=2362048, name="Example room 1"), + HomeDataRoom(id=2362044, name="Example room 2"), + ] + + await home_trait.discover_home() + # Mock callback should have been called during discovery + assert mock_callback.call_count > 0 + mock_callback.reset_mock() + + # 2. Test that registering a new listener now (when cache is populated) executes immediately + mock_callback_immediate = MagicMock() + unsub_immediate = home_trait.add_update_listener(mock_callback_immediate) + assert mock_callback_immediate.call_count == 1 + unsub_immediate() + + # 3. Test notification on cached discovery + # Re-run discover_home (which skips API calls and loads from cache) + await home_trait.discover_home() + assert mock_callback.call_count == 1 + mock_callback.reset_mock() + + # 4. Test notification on refresh + mock_rpc_channel.send_command.side_effect = [ + ROOM_MAPPING_DATA_MAP_0, + ] + mock_mqtt_rpc_channel.send_command.side_effect = [ + MULTI_MAP_LIST_DATA, + ] + mock_map_rpc_channel.send_command.side_effect = [ + MAP_BYTES_RESPONSE_1, + ] + await home_trait.refresh() + assert mock_callback.call_count > 0 + + # Unsubscribe and verify it is no longer called + mock_callback.reset_mock() + unsub() + mock_rpc_channel.send_command.side_effect = [ + ROOM_MAPPING_DATA_MAP_0, + ] + mock_mqtt_rpc_channel.send_command.side_effect = [ + MULTI_MAP_LIST_DATA, + ] + mock_map_rpc_channel.send_command.side_effect = [ + MAP_BYTES_RESPONSE_1, + ] + await home_trait.refresh() + assert mock_callback.call_count == 0 + + +async def test_home_trait_map_eviction( + home_trait: HomeTrait, + mock_rpc_channel: AsyncMock, + mock_mqtt_rpc_channel: AsyncMock, + mock_map_rpc_channel: AsyncMock, + device_cache: DeviceCache, + web_api_client: AsyncMock, +) -> None: + """Test that maps deleted from the device are evicted from cache during refresh.""" + # Pre-populate cache with maps 0 and 123 + device_cache_data = DeviceCacheData( + home_map_info={ + 0: CombinedMapInfo(map_flag=0, name="Ground Floor", rooms=[]), + 123: CombinedMapInfo(map_flag=123, name="Second Floor", rooms=[]), + }, + home_map_content_base64={ + 0: base64.b64encode(MAP_BYTES_RESPONSE_1).decode("utf-8"), + 123: base64.b64encode(MAP_BYTES_RESPONSE_2).decode("utf-8"), + }, + ) + await device_cache.set(device_cache_data) + await home_trait.discover_home() + + assert home_trait.home_map_info is not None + assert home_trait.home_map_content is not None + assert len(home_trait.home_map_info) == 2 + assert len(home_trait.home_map_content) == 2 + + # Set up listener callback + mock_callback = MagicMock() + home_trait.add_update_listener(mock_callback) + assert mock_callback.call_count == 1 + mock_callback.reset_mock() + + # Mock maps_trait.refresh so that only map 0 is returned (map 123 deleted) + mock_mqtt_rpc_channel.send_command.side_effect = [ + MULTI_MAP_LIST_SINGLE_MAP_DATA, # Only has map 0 + ] + mock_rpc_channel.send_command.side_effect = [ + ROOM_MAPPING_DATA_MAP_0, + ] + mock_map_rpc_channel.send_command.side_effect = [ + MAP_BYTES_RESPONSE_1, + ] + + await home_trait.refresh() + + # Verify map 123 is excluded from memory cache + assert home_trait.home_map_info is not None + assert home_trait.home_map_content is not None + assert 123 not in home_trait.home_map_info + assert 123 not in home_trait.home_map_content + assert 0 in home_trait.home_map_info + + # Verify listener notified + assert mock_callback.call_count > 0 From 7888a6d53fc657f9af6bf92d55563636fc12d05e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 3 Jul 2026 18:34:23 +0000 Subject: [PATCH 08/21] 5.24.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb5d3e40..55660dd18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v5.24.0 (2026-07-03) + +### Features + +- Implement dynamic map update listeners and filtering on HomeTrait + ([#861](https://github.com/Python-roborock/python-roborock/pull/861), + [`a793c8e`](https://github.com/Python-roborock/python-roborock/commit/a793c8e0a3e0359027d491d406dd36f2d62d41f6)) + + ## v5.23.1 (2026-07-03) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 741b17bd4..abd99c6e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.23.1" +version = "5.24.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 41f92545d..fa05230bb 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.23.1" +version = "5.24.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 9474bfaa607f15c7b66f520af17d81c849651f68 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 3 Jul 2026 19:15:41 -0400 Subject: [PATCH 09/21] feat: add better cleaning mode support (#817) * feat: add better cleaning mode support * chore: address comments from copilot * chore: address comments from copilot * chore: address comments from copilot * chore: address comments * chore: drop int typing and improve comment * chore: add some comments that better explain 'get_mop_only_vacuum_mode' --- roborock/data/v1/v1_clean_modes.py | 173 +++++++++- roborock/devices/traits/v1/status.py | 40 ++- .../devices/__snapshots__/test_v1_device.ambr | 2 +- tests/devices/traits/v1/test_status.py | 296 +++++++++++++++++- 4 files changed, 503 insertions(+), 8 deletions(-) diff --git a/roborock/data/v1/v1_clean_modes.py b/roborock/data/v1/v1_clean_modes.py index 2b709d0d5..cc6345aa5 100644 --- a/roborock/data/v1/v1_clean_modes.py +++ b/roborock/data/v1/v1_clean_modes.py @@ -1,7 +1,10 @@ from __future__ import annotations import typing +from enum import StrEnum +from typing import TypeVar +from ...exceptions import RoborockUnsupportedFeature from ..code_mappings import RoborockModeEnum if typing.TYPE_CHECKING: @@ -68,6 +71,22 @@ class WashTowelModes(RoborockModeEnum): SUPER_DEEP = ("super_deep", 8) +class CleaningMode(StrEnum): + """High-level cleaning intent derived from the lower-level motor settings. + + Prefer this abstraction when you want to present or switch between the + user-facing cleaning behaviors exposed by the app. The lower-level + `VacuumModes`, `WaterModes`, and `CleanRoutes` enums are still useful for + fine-grained selection. + """ + + VACUUM = "vacuum" + VAC_AND_MOP = "vac_and_mop" + MOP = "mop" + CUSTOM = "custom" + SMART_MODE = "smart_mode" + + WATER_SLIDE_MODE_MAPPING: dict[int, WaterModes] = { 200: WaterModes.OFF, 221: WaterModes.PURE_WATER_FLOW_START, @@ -78,6 +97,8 @@ class WashTowelModes(RoborockModeEnum): 250: WaterModes.PURE_WATER_FLOW_END, } +ModeEnumT = TypeVar("ModeEnumT", bound=RoborockModeEnum) + def get_wash_towel_modes(features: DeviceFeatures) -> list[WashTowelModes]: """Get the valid wash towel modes for the device""" @@ -174,7 +195,151 @@ def get_water_mode_mapping(features: DeviceFeatures) -> dict[int, str]: return {mode.code: mode.value for mode in get_water_modes(features)} -def is_mode_customized(clean_mode: VacuumModes, water_mode: WaterModes, mop_mode: CleanRoutes) -> bool: +def get_cleaning_mode_options(features: DeviceFeatures) -> list[CleaningMode]: + """Return the supported high-level cleaning modes for the device. + + These options are the preferred user-facing choices because they bundle the + correct fan, water, and mop-route settings together for the device. Callers + should generally present these instead of mixing lower-level mode enums + unless they explicitly need fine-grained control. + """ + if not features.is_support_water_mode: + return [] + + supported_water_modes = get_water_modes(features) + options = [CleaningMode.VACUUM, CleaningMode.VAC_AND_MOP] + if features.is_pure_clean_mop_supported: + options.append(CleaningMode.MOP) + if features.is_customized_clean_supported and WaterModes.CUSTOMIZED in supported_water_modes: + options.append(CleaningMode.CUSTOM) + if features.is_smart_clean_mode_set_supported and WaterModes.SMART_MODE in supported_water_modes: + options.append(CleaningMode.SMART_MODE) + return options + + +def get_mop_only_vacuum_mode(features: DeviceFeatures) -> VacuumModes: + """Determine the vacuum mode to use when you just want to mop. + + There are three cases that must be handled: + 1. The device does not support only mopping. + 2. The device supports raising the vacuum brush while mopping + 3. All other cases. + """ + if not features.is_pure_clean_mop_supported: + raise RoborockUnsupportedFeature("Mop-only cleaning is not supported") + if features.is_support_main_brush_up_down_supported: + return VacuumModes.OFF_RAISE_MAIN_BRUSH + return VacuumModes.OFF + + +def _get_default_mopping_water_mode(features: DeviceFeatures) -> WaterModes: + """Pick a sensible default water mode when mopping for the device.""" + # Water-slide devices use a disjoint set of water codes; pick a mid-flow + # slide code instead of the standard 202, which they don't accept. + if features.is_water_slide_mode_supported: + return WaterModes.PURE_WATER_FLOW_MIDDLE + return WaterModes.STANDARD + + +def _get_clean_motor_mode_params( + mode: CleaningMode, + features: DeviceFeatures, +) -> tuple[VacuumModes, WaterModes, CleanRoutes]: + """Return (fan_power, water_box_mode, mop_mode) enums for the high-level mode.""" + if mode == CleaningMode.VACUUM: + return (VacuumModes.BALANCED, WaterModes.OFF, CleanRoutes.STANDARD) + if mode == CleaningMode.VAC_AND_MOP: + return (VacuumModes.BALANCED, _get_default_mopping_water_mode(features), CleanRoutes.STANDARD) + if mode == CleaningMode.MOP: + return ( + get_mop_only_vacuum_mode(features), + _get_default_mopping_water_mode(features), + CleanRoutes.STANDARD, + ) + if mode == CleaningMode.CUSTOM: + return (VacuumModes.CUSTOMIZED, WaterModes.CUSTOMIZED, CleanRoutes.CUSTOMIZED) + if mode == CleaningMode.SMART_MODE: + return (VacuumModes.SMART_MODE, WaterModes.SMART_MODE, CleanRoutes.SMART_MODE) + raise RoborockUnsupportedFeature(f"Cleaning mode {mode.value!r} is not supported") + + +def resolve_cleaning_mode(cleaning_mode: str | CleaningMode) -> CleaningMode: + """Resolve a string or enum into a CleaningMode value.""" + if isinstance(cleaning_mode, CleaningMode): + return cleaning_mode + try: + return CleaningMode(cleaning_mode) + except ValueError as err: + raise RoborockUnsupportedFeature(f"Cleaning mode {cleaning_mode!r} is not supported") from err + + +def get_cleaning_mode_parameters(cleaning_mode: CleaningMode, features: DeviceFeatures) -> list[dict[str, int]]: + """Get the RPC payload for switching the high-level cleaning mode.""" + if cleaning_mode not in get_cleaning_mode_options(features): + raise RoborockUnsupportedFeature(f"Cleaning mode {cleaning_mode.value!r} is not supported") + + fan_power, water_box_mode, mop_mode = _get_clean_motor_mode_params(cleaning_mode, features) + params: dict[str, int] = {"fan_power": fan_power.code, "water_box_mode": water_box_mode.code} + if features.is_clean_route_setting_supported: + params["mop_mode"] = mop_mode.code + return [params] + + +def _resolve_mode_code(value: int | ModeEnumT | None, mode_cls: type[ModeEnumT]) -> ModeEnumT | None: + """Resolve a raw code or enum into a RoborockModeEnum.""" + if value is None: + return None + if isinstance(value, mode_cls): + return value + return mode_cls.from_code_optional(int(value)) + + +def _resolve_clean_mode(value: int | VacuumModes | None, features: DeviceFeatures) -> VacuumModes | None: + """Resolve a vacuum mode code, accounting for feature-specific code aliases.""" + if value is None or isinstance(value, VacuumModes): + return value + if value == VacuumModes.OFF.code: + if features.is_pure_clean_mop_supported: + return get_mop_only_vacuum_mode(features) + return VacuumModes.GENTLE + return VacuumModes.from_code_optional(value) + + +def get_current_cleaning_mode( + clean_mode: int | VacuumModes | None, + water_mode: int | WaterModes | None, + mop_mode: int | CleanRoutes | None, + features: DeviceFeatures, +) -> CleaningMode | None: + """Classify the current high-level cleaning mode from individual mode codes.""" + if not features.is_support_water_mode: + return None + clean_mode_enum = _resolve_clean_mode(clean_mode, features) + water_mode_enum = _resolve_mode_code(water_mode, WaterModes) + mop_mode_enum = _resolve_mode_code(mop_mode, CleanRoutes) + if clean_mode_enum is None or water_mode_enum is None: + return None + + if is_smart_mode_set(water_mode_enum, clean_mode_enum, mop_mode_enum): + return CleaningMode.SMART_MODE + if is_mode_customized(clean_mode_enum, water_mode_enum, mop_mode_enum): + return CleaningMode.CUSTOM + if water_mode_enum != WaterModes.OFF: + try: + if clean_mode_enum == get_mop_only_vacuum_mode(features): + return CleaningMode.MOP + except RoborockUnsupportedFeature: + pass + if water_mode_enum == WaterModes.OFF: + return CleaningMode.VACUUM + return CleaningMode.VAC_AND_MOP + + +def is_mode_customized( + clean_mode: VacuumModes | None, + water_mode: WaterModes | None, + mop_mode: CleanRoutes | None, +) -> bool: """Check if any of the cleaning modes are set to a custom value.""" return ( clean_mode == VacuumModes.CUSTOMIZED @@ -183,7 +348,11 @@ def is_mode_customized(clean_mode: VacuumModes, water_mode: WaterModes, mop_mode ) -def is_smart_mode_set(water_mode: WaterModes, clean_mode: VacuumModes, mop_mode: CleanRoutes) -> bool: +def is_smart_mode_set( + water_mode: WaterModes | None, + clean_mode: VacuumModes | None, + mop_mode: CleanRoutes | None, +) -> bool: """Check if the smart mode is set for the given water mode and clean mode""" return ( water_mode == WaterModes.SMART_MODE diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index c55ae7999..3bdc19c6a 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -3,14 +3,19 @@ from typing import Any from roborock import ( + CleaningMode, CleanRoutes, StatusV2, VacuumModes, WaterModes, get_clean_modes, get_clean_routes, + get_cleaning_mode_options, + get_cleaning_mode_parameters, + get_current_cleaning_mode, get_water_mode_mapping, get_water_modes, + resolve_cleaning_mode, ) from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener from roborock.roborock_message import RoborockDataProtocol @@ -42,10 +47,11 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener): - Water Mode - Mop Route - You should call the _options() version of the attribute to know which are supported for your device - (i.e. fan_speed_options()) - Then you can call the _mapping to convert an int value to the actual Enum. (i.e. fan_speed_mapping()) - You can call the _name property to get the str value of the enum. (i.e. fan_speed_name) + You should use the _options version of the attribute to know which are + supported for your device (i.e. fan_speed_options) + Then you can use the _mapping to convert an int value to the actual Enum. + (i.e. fan_speed_mapping) + You can use the _name property to get the str value of the enum. (i.e. fan_speed_name) """ @@ -83,6 +89,10 @@ def mop_route_options(self) -> list[CleanRoutes]: def mop_route_mapping(self) -> dict[int, str]: return {route.code: route.value for route in self.mop_route_options} + @cached_property + def cleaning_mode_options(self) -> list[CleaningMode]: + return get_cleaning_mode_options(self._device_features_trait) + @property def fan_speed_name(self) -> str | None: if self.fan_power is None: @@ -101,6 +111,28 @@ def mop_route_name(self) -> str | None: return None return self.mop_route_mapping.get(self.mop_mode) + @property + def current_cleaning_mode(self) -> CleaningMode | None: + return get_current_cleaning_mode( + clean_mode=self.fan_power, + water_mode=self.water_box_mode, + mop_mode=self.mop_mode, + features=self._device_features_trait, + ) + + @property + def current_cleaning_mode_name(self) -> str | None: + if (cleaning_mode := self.current_cleaning_mode) is None: + return None + return cleaning_mode.value + + async def set_cleaning_mode(self, cleaning_mode: str | CleaningMode) -> None: + """Set the preferred high-level cleaning mode for the device.""" + await self.rpc_channel.send_command( + RoborockCommand.SET_CLEAN_MOTOR_MODE, + params=get_cleaning_mode_parameters(resolve_cleaning_mode(cleaning_mode), self._device_features_trait), + ) + def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None: """Update the trait from data protocol push message data. diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index 2f109c5b3..61858ce2b 100644 --- a/tests/devices/__snapshots__/test_v1_device.ambr +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -870,7 +870,7 @@ }) # --- # name: test_device_trait_command_parsing[status] - StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) + StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) # --- # name: test_device_trait_command_parsing[status].1 dict({ diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 1878d2fd7..f11ee6619 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -6,6 +6,15 @@ import pytest +from roborock import ( + CleaningMode, + CleanRoutes, + VacuumModes, + WaterModes, + get_cleaning_mode_parameters, + get_current_cleaning_mode, + resolve_cleaning_mode, +) from roborock.data import SHORT_MODEL_TO_ENUM from roborock.data.v1 import ( RoborockStateCode, @@ -14,7 +23,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait from roborock.devices.traits.v1.status import StatusTrait -from roborock.exceptions import RoborockException, RoborockParsingException +from roborock.exceptions import RoborockException, RoborockParsingException, RoborockUnsupportedFeature from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from tests import mock_data @@ -28,6 +37,26 @@ def status_trait(device: RoborockDevice) -> StatusTrait: return device.v1_properties.status +def _create_cleaning_mode_status_trait(**feature_overrides: bool) -> StatusTrait: + """Create a status trait with mop-capable V1 features for cleaning mode tests.""" + short_model = mock_data.A27_PRODUCT_DATA["model"].split(".")[-1] + features = DeviceFeatures.from_feature_flags( + new_feature_info=0, + new_feature_info_str="", + feature_info=[], + product_nickname=SHORT_MODEL_TO_ENUM[short_model], + ) + features.is_support_water_mode = True + features.is_pure_clean_mop_supported = True + features.is_customized_clean_supported = True + features.is_clean_route_setting_supported = True + for feature_name, value in feature_overrides.items(): + if not hasattr(features, feature_name): + raise AttributeError(f"Unknown DeviceFeatures override: {feature_name}") + setattr(features, feature_name, value) + return StatusTrait(cast(DeviceFeaturesTrait, features), region="us") + + async def test_refresh_status(status_trait: StatusTrait, mock_rpc_channel: AsyncMock) -> None: """Test successfully refreshing status.""" mock_rpc_channel.send_command.return_value = [STATUS] @@ -89,6 +118,271 @@ def test_options(status_trait: StatusTrait) -> None: assert len(status_trait.mop_route_options) > 0 +def test_cleaning_mode_options() -> None: + """Test the high-level cleaning mode options for the device.""" + status_trait = _create_cleaning_mode_status_trait() + assert status_trait.cleaning_mode_options == [ + CleaningMode.VACUUM, + CleaningMode.VAC_AND_MOP, + CleaningMode.MOP, + CleaningMode.CUSTOM, + ] + + +@pytest.mark.parametrize( + ("fan_power", "water_box_mode", "mop_mode", "expected_mode"), + [ + ( + VacuumModes.BALANCED.code, + WaterModes.STANDARD.code, + CleanRoutes.STANDARD.code, + CleaningMode.VAC_AND_MOP, + ), + ( + VacuumModes.BALANCED.code, + WaterModes.OFF.code, + CleanRoutes.STANDARD.code, + CleaningMode.VACUUM, + ), + ( + VacuumModes.OFF.code, + WaterModes.STANDARD.code, + CleanRoutes.STANDARD.code, + CleaningMode.MOP, + ), + ( + VacuumModes.CUSTOMIZED.code, + WaterModes.STANDARD.code, + CleanRoutes.STANDARD.code, + CleaningMode.CUSTOM, + ), + ( + VacuumModes.BALANCED.code, + WaterModes.SMART_MODE.code, + CleanRoutes.STANDARD.code, + CleaningMode.SMART_MODE, + ), + ], +) +def test_current_cleaning_mode( + fan_power: int, + water_box_mode: int, + mop_mode: int, + expected_mode: CleaningMode, +) -> None: + """Test the current high-level cleaning mode classification.""" + status_trait = _create_cleaning_mode_status_trait(is_smart_clean_mode_set_supported=True) + status_trait.fan_power = fan_power + status_trait.water_box_mode = water_box_mode + status_trait.mop_mode = mop_mode + + assert status_trait.current_cleaning_mode == expected_mode + assert status_trait.current_cleaning_mode_name == expected_mode.value + + +def test_current_cleaning_mode_with_brush_up_mop() -> None: + """Test brush-up mop-only classification on supported devices.""" + status_trait = _create_cleaning_mode_status_trait(is_support_main_brush_up_down_supported=True) + status_trait.fan_power = VacuumModes.OFF_RAISE_MAIN_BRUSH.code + status_trait.water_box_mode = WaterModes.STANDARD.code + status_trait.mop_mode = CleanRoutes.STANDARD.code + + assert status_trait.current_cleaning_mode == CleaningMode.MOP + + +def test_current_cleaning_mode_accepts_enums() -> None: + """Test direct enum inputs are resolved before classification.""" + status_trait = _create_cleaning_mode_status_trait(is_smart_clean_mode_set_supported=True) + + assert ( + get_current_cleaning_mode( + clean_mode=VacuumModes.BALANCED, + water_mode=WaterModes.SMART_MODE, + mop_mode=CleanRoutes.STANDARD, + features=status_trait._device_features_trait, + ) + == CleaningMode.SMART_MODE + ) + + +def test_current_cleaning_mode_none() -> None: + """Test that incomplete status values do not classify a cleaning mode.""" + status_trait = _create_cleaning_mode_status_trait() + status_trait.fan_power = None + assert status_trait.current_cleaning_mode is None + assert status_trait.current_cleaning_mode_name is None + + +def test_current_cleaning_mode_without_mop_route_status() -> None: + """Test older V1 devices can classify cleaning mode without mop route status.""" + status_trait = _create_cleaning_mode_status_trait( + is_clean_route_setting_supported=False, + is_customized_clean_supported=False, + ) + status_trait.fan_power = VacuumModes.BALANCED.code + status_trait.water_box_mode = WaterModes.OFF.code + status_trait.mop_mode = None + + assert status_trait.current_cleaning_mode == CleaningMode.VACUUM + + +def test_get_cleaning_mode_parameters() -> None: + """Test payload generation for supported high-level cleaning modes.""" + status_trait = _create_cleaning_mode_status_trait() + assert get_cleaning_mode_parameters(CleaningMode.VACUUM, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.BALANCED.code, + "water_box_mode": WaterModes.OFF.code, + "mop_mode": CleanRoutes.STANDARD.code, + } + ] + assert get_cleaning_mode_parameters(resolve_cleaning_mode("custom"), status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.CUSTOMIZED.code, + "water_box_mode": WaterModes.CUSTOMIZED.code, + "mop_mode": CleanRoutes.CUSTOMIZED.code, + } + ] + + +def test_get_cleaning_mode_parameters_unsupported() -> None: + """Test unsupported cleaning modes raise a clear error.""" + status_trait = _create_cleaning_mode_status_trait() + with pytest.raises(RoborockUnsupportedFeature, match="not supported"): + get_cleaning_mode_parameters(CleaningMode.SMART_MODE, status_trait._device_features_trait) + + +def test_get_cleaning_mode_parameters_invalid_name() -> None: + """Test invalid cleaning mode names raise RoborockUnsupportedFeature.""" + with pytest.raises(RoborockUnsupportedFeature, match="not supported"): + resolve_cleaning_mode("invalid_mode") + + +async def test_set_cleaning_mode( + mock_rpc_channel: AsyncMock, +) -> None: + """Test setting the high-level cleaning mode.""" + status_trait = _create_cleaning_mode_status_trait() + status_trait._rpc_channel = mock_rpc_channel # type: ignore[assignment] + await status_trait.set_cleaning_mode(CleaningMode.CUSTOM) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_CLEAN_MOTOR_MODE, + params=[ + { + "fan_power": VacuumModes.CUSTOMIZED.code, + "water_box_mode": WaterModes.CUSTOMIZED.code, + "mop_mode": CleanRoutes.CUSTOMIZED.code, + } + ], + ) + + +def test_cleaning_mode_options_with_smart_mode() -> None: + """Test SmartPlan support is reflected in the available options.""" + status_trait = _create_cleaning_mode_status_trait(is_smart_clean_mode_set_supported=True) + + assert status_trait.cleaning_mode_options == [ + CleaningMode.VACUUM, + CleaningMode.VAC_AND_MOP, + CleaningMode.MOP, + CleaningMode.CUSTOM, + CleaningMode.SMART_MODE, + ] + + +def test_get_cleaning_mode_parameters_with_brush_up_mop() -> None: + """Test mop-only uses the brush-up mode when supported.""" + status_trait = _create_cleaning_mode_status_trait(is_support_main_brush_up_down_supported=True) + + assert get_cleaning_mode_parameters(CleaningMode.MOP, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.OFF_RAISE_MAIN_BRUSH.code, + "water_box_mode": WaterModes.STANDARD.code, + "mop_mode": CleanRoutes.STANDARD.code, + } + ] + + +def test_get_cleaning_mode_parameters_without_clean_route_setting() -> None: + """Test older V1 devices use the 2-field clean motor payload.""" + status_trait = _create_cleaning_mode_status_trait( + is_clean_route_setting_supported=False, + is_customized_clean_supported=False, + ) + + assert get_cleaning_mode_parameters(CleaningMode.VACUUM, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.BALANCED.code, + "water_box_mode": WaterModes.OFF.code, + } + ] + assert get_cleaning_mode_parameters(CleaningMode.VAC_AND_MOP, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.BALANCED.code, + "water_box_mode": WaterModes.STANDARD.code, + } + ] + assert get_cleaning_mode_parameters(CleaningMode.MOP, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.OFF.code, + "water_box_mode": WaterModes.STANDARD.code, + } + ] + + +def test_get_cleaning_mode_parameters_water_slide_device() -> None: + """Water-slide devices should use a slide-compatible water code, not 202.""" + status_trait = _create_cleaning_mode_status_trait(is_water_slide_mode_supported=True) + + assert get_cleaning_mode_parameters(CleaningMode.VACUUM, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.BALANCED.code, + "water_box_mode": WaterModes.OFF.code, + "mop_mode": CleanRoutes.STANDARD.code, + } + ] + assert get_cleaning_mode_parameters(CleaningMode.VAC_AND_MOP, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.BALANCED.code, + "water_box_mode": WaterModes.PURE_WATER_FLOW_MIDDLE.code, + "mop_mode": CleanRoutes.STANDARD.code, + } + ] + assert get_cleaning_mode_parameters(CleaningMode.MOP, status_trait._device_features_trait) == [ + { + "fan_power": VacuumModes.OFF.code, + "water_box_mode": WaterModes.PURE_WATER_FLOW_MIDDLE.code, + "mop_mode": CleanRoutes.STANDARD.code, + } + ] + + +def test_cleaning_mode_options_water_slide_device() -> None: + """Water-slide devices should not expose unsupported custom or smart water modes.""" + status_trait = _create_cleaning_mode_status_trait( + is_water_slide_mode_supported=True, + is_customized_clean_supported=True, + is_smart_clean_mode_set_supported=True, + ) + + assert status_trait.cleaning_mode_options == [ + CleaningMode.VACUUM, + CleaningMode.VAC_AND_MOP, + CleaningMode.MOP, + ] + + +def test_current_cleaning_mode_gentle_not_mop_without_pure_mop() -> None: + """Test code 105 is not treated as mop-only on devices without pure mop.""" + status_trait = _create_cleaning_mode_status_trait(is_pure_clean_mop_supported=False) + status_trait.fan_power = VacuumModes.GENTLE.code + status_trait.water_box_mode = WaterModes.STANDARD.code + status_trait.mop_mode = CleanRoutes.STANDARD.code + + assert status_trait.current_cleaning_mode == CleaningMode.VAC_AND_MOP + + def test_water_slide_mode_mapping() -> None: """Test feature-aware water mode mapping for water slide mode devices.""" short_model = mock_data.A114_PRODUCT_DATA["model"].split(".")[-1] From 413d6e57ae74d3d54f9f1d4c8918ffbe9d215b7b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 3 Jul 2026 19:15:57 -0400 Subject: [PATCH 10/21] chore: add dataclasses for Roborock mower (#862) * chore: add dataclasses for Roborock mower * chore: apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- roborock/data/__init__.py | 1 + roborock/data/mower/__init__.py | 3 + roborock/data/mower/mower_containers.py | 43 ++++++++++++++ roborock/roborock_message.py | 39 +++++++++++++ tests/data/test_mower_containers.py | 74 +++++++++++++++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 roborock/data/mower/__init__.py create mode 100644 roborock/data/mower/mower_containers.py create mode 100644 tests/data/test_mower_containers.py diff --git a/roborock/data/__init__.py b/roborock/data/__init__.py index a3e7863a7..121f8b3cd 100644 --- a/roborock/data/__init__.py +++ b/roborock/data/__init__.py @@ -5,5 +5,6 @@ from .code_mappings import * from .containers import * from .dyad import * +from .mower import * from .v1 import * from .zeo import * diff --git a/roborock/data/mower/__init__.py b/roborock/data/mower/__init__.py new file mode 100644 index 000000000..a12cd9e48 --- /dev/null +++ b/roborock/data/mower/__init__.py @@ -0,0 +1,3 @@ +"""Dataclasses for Roborock mower devices.""" + +from .mower_containers import * diff --git a/roborock/data/mower/mower_containers.py b/roborock/data/mower/mower_containers.py new file mode 100644 index 000000000..949bfbab9 --- /dev/null +++ b/roborock/data/mower/mower_containers.py @@ -0,0 +1,43 @@ +"""Data containers for Roborock mower devices.""" + +from dataclasses import dataclass, field +from typing import Any + +from roborock.data.containers import RoborockBase +from roborock.roborock_message import RoborockMowerDataProtocol + + +@dataclass +class MowerStatus(RoborockBase): + """Core mower status backed by mower DPS updates.""" + + error_code: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.ERROR_CODE}) + battery: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BATTERY}) + mow_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_TYPE}) + mow_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE}) + mapping_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_TYPE}) + mapping_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MAPPING_STATE}) + ota_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OTA_STATE}) + charge_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_STATE}) + dock_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.DOCK_STATE}) + charge_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.CHARGE_TYPE}) + pend_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.PEND_TYPE}) + remote_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.REMOTE_STATE}) + mow_start_type: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_START_TYPE}) + mow_eff_mode: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_EFF_MODE}) + mow_height: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_HEIGHT}) + mow_direction_angle: int | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_DIRECTION_ANGLE} + ) + mow_pattern: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_PATTERN}) + mow_conf_mode: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_CONF_MODE}) + offline_status: Any | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.OFFLINE_STATUS}) + mow_progress: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_PROGRESS}) + blade_lifespan: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BLADE_LIFESPAN}) + fc_state: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.FC_STATE}) + gps_coordinate: Any | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.GPS_COORDINATE}) + off_dock_no_task_status: int | None = field( + default=None, metadata={"dps": RoborockMowerDataProtocol.OFF_DOCK_NO_TASK_STATUS} + ) + afs_status: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.AFS_STATUS}) + network_channel: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.NETWORK_CHANNEL}) diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index ae6696700..72564b38c 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -40,6 +40,45 @@ def _missing_(cls: type[Self], key) -> Self: raise ValueError(f"{key} not a valid key for Data Protocol") +class RoborockMowerDataProtocol(RoborockEnum): + UNKNOWN = 0 + ERROR_CODE = 120 + BATTERY = 121 + MOW_TYPE = 122 + MOW_STATE = 123 + MAPPING_TYPE = 124 + MAPPING_STATE = 125 + OTA_STATE = 126 + CHARGE_STATE = 127 + DOCK_STATE = 128 + CHARGE_TYPE = 129 + PEND_TYPE = 130 + REMOTE_STATE = 131 + MOW_START_TYPE = 132 + MOW_EFF_MODE = 133 + MOW_HEIGHT = 134 + MOW_DIRECTION_ANGLE = 135 + MOW_PATTERN = 136 + MOW_CONF_MODE = 137 + OFFLINE_STATUS = 138 + MOW_PROGRESS = 139 + BLADE_LIFESPAN = 140 + FC_STATE = 141 + GPS_COORDINATE = 142 + OFF_DOCK_NO_TASK_STATUS = 143 + AFS_STATUS = 144 + NETWORK_CHANNEL = 145 + START = 201 + DOCK = 202 + PAUSE = 203 + RESUME = 204 + STOP = 205 + + @classmethod + def _missing_(cls: type[Self], key) -> Self: + raise ValueError(f"{key} not a valid key for Mower Data Protocol") + + class RoborockDyadDataProtocol(RoborockEnum): DRYING_STATUS = 134 START = 200 diff --git a/tests/data/test_mower_containers.py b/tests/data/test_mower_containers.py new file mode 100644 index 000000000..6495ef2d0 --- /dev/null +++ b/tests/data/test_mower_containers.py @@ -0,0 +1,74 @@ +"""Test mower data containers.""" + +import dataclasses + +from roborock.data import MowerStatus +from roborock.roborock_message import RoborockMowerDataProtocol + + +def test_mower_status_from_dict() -> None: + status = MowerStatus.from_dict( + { + "error_code": 0, + "battery": 82, + "mow_state": 3, + "mowHeight": 70, + "gpsCoordinate": {"latitude": 1, "longitude": 2}, + "unknown": "ignored", + } + ) + + assert status.error_code == 0 + assert status.battery == 82 + assert status.mow_state == 3 + assert status.mow_height == 70 + assert status.gps_coordinate == {"latitude": 1, "longitude": 2} + + +def test_mower_status_dps_metadata() -> None: + dps_by_field = { + field.name: field.metadata["dps"] + for field in dataclasses.fields(MowerStatus) + if field.metadata and "dps" in field.metadata + } + + assert dps_by_field == { + "error_code": RoborockMowerDataProtocol.ERROR_CODE, + "battery": RoborockMowerDataProtocol.BATTERY, + "mow_type": RoborockMowerDataProtocol.MOW_TYPE, + "mow_state": RoborockMowerDataProtocol.MOW_STATE, + "mapping_type": RoborockMowerDataProtocol.MAPPING_TYPE, + "mapping_state": RoborockMowerDataProtocol.MAPPING_STATE, + "ota_state": RoborockMowerDataProtocol.OTA_STATE, + "charge_state": RoborockMowerDataProtocol.CHARGE_STATE, + "dock_state": RoborockMowerDataProtocol.DOCK_STATE, + "charge_type": RoborockMowerDataProtocol.CHARGE_TYPE, + "pend_type": RoborockMowerDataProtocol.PEND_TYPE, + "remote_state": RoborockMowerDataProtocol.REMOTE_STATE, + "mow_start_type": RoborockMowerDataProtocol.MOW_START_TYPE, + "mow_eff_mode": RoborockMowerDataProtocol.MOW_EFF_MODE, + "mow_height": RoborockMowerDataProtocol.MOW_HEIGHT, + "mow_direction_angle": RoborockMowerDataProtocol.MOW_DIRECTION_ANGLE, + "mow_pattern": RoborockMowerDataProtocol.MOW_PATTERN, + "mow_conf_mode": RoborockMowerDataProtocol.MOW_CONF_MODE, + "offline_status": RoborockMowerDataProtocol.OFFLINE_STATUS, + "mow_progress": RoborockMowerDataProtocol.MOW_PROGRESS, + "blade_lifespan": RoborockMowerDataProtocol.BLADE_LIFESPAN, + "fc_state": RoborockMowerDataProtocol.FC_STATE, + "gps_coordinate": RoborockMowerDataProtocol.GPS_COORDINATE, + "off_dock_no_task_status": RoborockMowerDataProtocol.OFF_DOCK_NO_TASK_STATUS, + "afs_status": RoborockMowerDataProtocol.AFS_STATUS, + "network_channel": RoborockMowerDataProtocol.NETWORK_CHANNEL, + } + + +def test_mower_command_dps_are_not_status_fields() -> None: + status_dps = { + field.metadata["dps"] for field in dataclasses.fields(MowerStatus) if field.metadata and "dps" in field.metadata + } + + assert RoborockMowerDataProtocol.START not in status_dps + assert RoborockMowerDataProtocol.DOCK not in status_dps + assert RoborockMowerDataProtocol.PAUSE not in status_dps + assert RoborockMowerDataProtocol.RESUME not in status_dps + assert RoborockMowerDataProtocol.STOP not in status_dps From 8721d70da88de91e6f886927209139320d76aaad Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 3 Jul 2026 23:19:09 +0000 Subject: [PATCH 11/21] 5.25.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55660dd18..d81ba5238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ +## v5.25.0 (2026-07-03) + +### Chores + +- Add dataclasses for Roborock mower + ([#862](https://github.com/Python-roborock/python-roborock/pull/862), + [`413d6e5`](https://github.com/Python-roborock/python-roborock/commit/413d6e57ae74d3d54f9f1d4c8918ffbe9d215b7b)) + +- Add some comments that better explain 'get_mop_only_vacuum_mode' + ([#817](https://github.com/Python-roborock/python-roborock/pull/817), + [`9474bfa`](https://github.com/Python-roborock/python-roborock/commit/9474bfaa607f15c7b66f520af17d81c849651f68)) + +- Address comments ([#817](https://github.com/Python-roborock/python-roborock/pull/817), + [`9474bfa`](https://github.com/Python-roborock/python-roborock/commit/9474bfaa607f15c7b66f520af17d81c849651f68)) + +- Address comments from copilot + ([#817](https://github.com/Python-roborock/python-roborock/pull/817), + [`9474bfa`](https://github.com/Python-roborock/python-roborock/commit/9474bfaa607f15c7b66f520af17d81c849651f68)) + +- Apply suggestions from code review + ([#862](https://github.com/Python-roborock/python-roborock/pull/862), + [`413d6e5`](https://github.com/Python-roborock/python-roborock/commit/413d6e57ae74d3d54f9f1d4c8918ffbe9d215b7b)) + +- Drop int typing and improve comment + ([#817](https://github.com/Python-roborock/python-roborock/pull/817), + [`9474bfa`](https://github.com/Python-roborock/python-roborock/commit/9474bfaa607f15c7b66f520af17d81c849651f68)) + +### Features + +- Add better cleaning mode support + ([#817](https://github.com/Python-roborock/python-roborock/pull/817), + [`9474bfa`](https://github.com/Python-roborock/python-roborock/commit/9474bfaa607f15c7b66f520af17d81c849651f68)) + + ## v5.24.0 (2026-07-03) ### Features diff --git a/pyproject.toml b/pyproject.toml index abd99c6e4..4bd120268 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.24.0" +version = "5.25.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index fa05230bb..9078a748f 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.24.0" +version = "5.25.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From b00d60b9f70d6a5da9089e470db60b04437c0b14 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 4 Jul 2026 08:25:05 -0700 Subject: [PATCH 12/21] refactor: decouple B01 (Q7/Q10) protocol layer from transport layer (#859) --- roborock/devices/device_manager.py | 15 +- roborock/devices/rpc/b01_q10_channel.py | 52 ++- roborock/devices/rpc/b01_q7_channel.py | 94 ++++-- roborock/devices/traits/b01/q10/__init__.py | 9 +- roborock/devices/traits/b01/q10/command.py | 11 +- roborock/devices/traits/b01/q7/__init__.py | 41 ++- .../devices/traits/b01/q7/clean_summary.py | 14 +- roborock/devices/traits/b01/q7/map.py | 12 +- roborock/devices/traits/b01/q7/map_content.py | 15 +- tests/devices/rpc/test_b01_q10_channel.py | 102 ++++++ tests/devices/rpc/test_b01_q7_channel.py | 295 ++++++++++++++++++ tests/devices/traits/b01/q10/conftest.py | 54 ++++ .../traits/b01/q10/test_clean_history.py | 27 +- tests/devices/traits/b01/q10/test_map.py | 39 ++- tests/devices/traits/b01/q10/test_remote.py | 27 +- tests/devices/traits/b01/q10/test_settings.py | 48 +-- tests/devices/traits/b01/q10/test_status.py | 73 ++--- tests/devices/traits/b01/q10/test_vacuum.py | 35 +-- tests/devices/traits/b01/q7/__init__.py | 52 +-- tests/devices/traits/b01/q7/conftest.py | 64 ++-- .../traits/b01/q7/test_clean_summary.py | 21 +- tests/devices/traits/b01/q7/test_init.py | 250 +++++---------- tests/devices/traits/b01/q7/test_map.py | 41 +-- .../devices/traits/b01/q7/test_map_content.py | 72 ++--- 24 files changed, 912 insertions(+), 551 deletions(-) create mode 100644 tests/devices/rpc/test_b01_q10_channel.py create mode 100644 tests/devices/rpc/test_b01_q7_channel.py create mode 100644 tests/devices/traits/b01/q10/conftest.py diff --git a/roborock/devices/device_manager.py b/roborock/devices/device_manager.py index 5f82bfcac..fe497b71e 100644 --- a/roborock/devices/device_manager.py +++ b/roborock/devices/device_manager.py @@ -26,6 +26,8 @@ from roborock.web_api import RoborockApiClient, UserWebApiClient from .cache import Cache, DeviceCache, NoCache +from .rpc.b01_q7_channel import create_b01_q7_channel +from .rpc.b01_q10_channel import create_b01_q10_channel from .rpc.v1_channel import create_v1_channel from .traits import Trait, a01, b01, v1 from .transport.channel import Channel @@ -254,13 +256,22 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) trait = a01.create(product, channel) case DeviceVersion.B01: - channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) + mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) model_part = product.model.split(".")[-1] if "ss" in model_part: + b01_q10_channel = create_b01_q10_channel(mqtt_channel) + channel = b01_q10_channel trait = b01.q10.create(channel) elif "sc" in model_part: # Q7 devices start with 'sc' in their model naming. - trait = b01.q7.create(product, device, channel) + b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel) + channel = b01_q7_channel + trait = b01.q7.create( + product, + device, + rpc_channel=b01_q7_channel, + map_rpc_channel=b01_q7_channel, + ) else: raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}") case _: diff --git a/roborock/devices/rpc/b01_q10_channel.py b/roborock/devices/rpc/b01_q10_channel.py index 497583267..0706c332c 100644 --- a/roborock/devices/rpc/b01_q10_channel.py +++ b/roborock/devices/rpc/b01_q10_channel.py @@ -1,9 +1,11 @@ """Thin wrapper around the MQTT channel for Roborock B01 Q10 devices.""" import logging -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable +from typing import Protocol from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.devices.transport.channel import Channel from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException from roborock.protocols.b01_q10_protocol import ( @@ -12,10 +14,53 @@ decode_message, encode_mqtt_payload, ) +from roborock.roborock_message import RoborockMessage _LOGGER = logging.getLogger(__name__) +class Q10RpcChannel(Protocol): + """Protocol for Q10 RPC channels.""" + + async def send_command( + self, + command: B01_Q10_DP, + params: ParamsType = None, + ) -> None: + """Send a command on the MQTT channel, without waiting for a response.""" + ... + + +class B01Q10Channel(Channel, Q10RpcChannel): + """Unified B01 Q10 channel wrapping MQTT transport.""" + + def __init__(self, mqtt_channel: MqttChannel) -> None: + self._mqtt_channel = mqtt_channel + + @property + def is_connected(self) -> bool: + return self._mqtt_channel.is_connected + + @property + def is_local_connected(self) -> bool: + return False + + async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: + return await self._mqtt_channel.subscribe(callback) + + async def subscribe_stream(self) -> AsyncGenerator[Q10Message, None]: + """Stream decoded Q10 messages received via MQTT.""" + async for msg in stream_decoded_messages(self._mqtt_channel): + yield msg + + async def send_command( + self, + command: B01_Q10_DP, + params: ParamsType = None, + ) -> None: + await send_command(self._mqtt_channel, command, params) + + async def stream_decoded_messages( mqtt_channel: MqttChannel, ) -> AsyncGenerator[Q10Message, None]: @@ -59,3 +104,8 @@ async def send_command( ex, ) raise + + +def create_b01_q10_channel(mqtt_channel: MqttChannel) -> B01Q10Channel: + """Create a B01Q10Channel wrapping MQTT transport.""" + return B01Q10Channel(mqtt_channel) diff --git a/roborock/devices/rpc/b01_q7_channel.py b/roborock/devices/rpc/b01_q7_channel.py index b9926e3ed..e2bc7b12a 100644 --- a/roborock/devices/rpc/b01_q7_channel.py +++ b/roborock/devices/rpc/b01_q7_channel.py @@ -1,19 +1,23 @@ """Thin wrapper around the MQTT channel for Roborock B01 Q7 devices.""" -from __future__ import annotations - import asyncio import json import logging from collections.abc import Callable -from typing import TypeAlias, TypeVar +from typing import Protocol, TypeAlias, TypeVar +from roborock.data import HomeDataDevice, HomeDataProduct +from roborock.devices.transport.channel import Channel from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException from roborock.protocols.b01_q7_protocol import ( + B01_Q7_DPS, B01_VERSION, + CommandType, MapKey, + ParamsType, Q7RequestMessage, + create_map_key, decode_map_payload, decode_rpc_response, encode_mqtt_payload, @@ -26,6 +30,30 @@ DecodedB01Response: TypeAlias = dict[str, object] | str +class Q7RpcChannel(Protocol): + """Protocol for Q7 RPC channels.""" + + async def send_command( + self, + command: CommandType, + params: ParamsType = None, + ) -> DecodedB01Response: + """Send a command and get a decoded response.""" + ... + + +class Q7MapRpcChannel(Protocol): + """Protocol for Q7 map RPC channels.""" + + async def send_map_command( + self, + command: CommandType, + params: ParamsType = None, + ) -> bytes: + """Send a map command and get decoded bytes.""" + ... + + def _matches_map_response(response_message: RoborockMessage, *, version: bytes | None) -> bytes | None: """Return raw map payload bytes for matching MAP_RESPONSE messages.""" if ( @@ -120,39 +148,55 @@ def find_response(response_message: RoborockMessage) -> DecodedB01Response | Non raise RoborockException(f"B01 command timed out after {_TIMEOUT}s ({request_message})") from ex except RoborockException as ex: _LOGGER.warning( - "Error sending B01 decoded command (%ss): %s", + "Error sending B01 decoded command (%s): %s", request_message, ex, ) raise except Exception as ex: _LOGGER.exception( - "Error sending B01 decoded command (%ss): %s", + "Error sending B01 decoded command (%s): %s", request_message, ex, ) raise -class MapRpcChannel: - """RPC channel for map-related commands on B01/Q7 devices.""" +class B01Q7Channel(Channel, Q7RpcChannel, Q7MapRpcChannel): + """Unified B01 Q7 channel wrapping MQTT transport.""" def __init__(self, mqtt_channel: MqttChannel, map_key: MapKey) -> None: self._mqtt_channel = mqtt_channel self._map_key = map_key - async def send_map_command(self, request_message: Q7RequestMessage) -> bytes: - """Send a map upload command and return decoded SCMap bytes. - - This publishes the request and waits for a matching ``MAP_RESPONSE`` message - with the correct protocol version. The raw ``MAP_RESPONSE`` payload bytes are - then decoded/inflated via :func:`decode_map_payload` using this channel's - ``map_key``, and the resulting SCMap bytes are returned. - - The returned value is the decoded map data bytes suitable for passing to the - map parser library, not the raw MQTT ``MAP_RESPONSE`` payload bytes. - """ + @property + def is_connected(self) -> bool: + return self._mqtt_channel.is_connected + + @property + def is_local_connected(self) -> bool: + return False + + async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: + return await self._mqtt_channel.subscribe(callback) + + async def send_command( + self, + command: CommandType, + params: ParamsType = None, + ) -> DecodedB01Response: + return await send_decoded_command( + self._mqtt_channel, + Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params), + ) + async def send_map_command( + self, + command: CommandType, + params: ParamsType = None, + ) -> bytes: + """Send a map upload command and return decoded SCMap bytes.""" + request_message = Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params) try: raw_payload = await _send_command( self._mqtt_channel, @@ -163,3 +207,17 @@ async def send_map_command(self, request_message: Q7RequestMessage) -> bytes: raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex return decode_map_payload(raw_payload, map_key=self._map_key) + + +def create_b01_q7_channel( + device: HomeDataDevice, + product: HomeDataProduct, + mqtt_channel: MqttChannel, +) -> B01Q7Channel: + """Create a B01Q7Channel for the given device.""" + if device.sn is None or product.model is None: + raise RoborockException( + f"Device serial number and product model are required (sn: {device.sn}, model: {product.model})" + ) + map_key = create_map_key(serial=device.sn, model=product.model) + return B01Q7Channel(mqtt_channel, map_key) diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index d026052c0..b85007f02 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -4,9 +4,8 @@ import logging from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP -from roborock.devices.rpc.b01_q10_channel import stream_decoded_messages +from roborock.devices.rpc.b01_q10_channel import B01Q10Channel from roborock.devices.traits import Trait -from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message @@ -83,7 +82,7 @@ class Q10PropertiesApi(Trait): clean_history: CleanHistoryTrait """Trait for fetching the device clean-record history (``dpCleanRecord``).""" - def __init__(self, channel: MqttChannel) -> None: + def __init__(self, channel: B01Q10Channel) -> None: """Initialize the B01Props API.""" self._channel = channel self.command = CommandTrait(channel) @@ -134,7 +133,7 @@ async def refresh(self) -> None: async def _subscribe_loop(self) -> None: """Persistent loop dispatching decoded messages to the read-model traits.""" - async for message in stream_decoded_messages(self._channel): + async for message in self._channel.subscribe_stream(): self._handle_message(message) def _handle_message(self, message: Q10Message) -> None: @@ -157,6 +156,6 @@ def _handle_message(self, message: Q10Message) -> None: trait.update_from_dps(message.dps) -def create(channel: MqttChannel) -> Q10PropertiesApi: +def create(channel: B01Q10Channel) -> Q10PropertiesApi: """Create traits for B01 devices.""" return Q10PropertiesApi(channel) diff --git a/roborock/devices/traits/b01/q10/command.py b/roborock/devices/traits/b01/q10/command.py index 4aa3a5937..b8f06445b 100644 --- a/roborock/devices/traits/b01/q10/command.py +++ b/roborock/devices/traits/b01/q10/command.py @@ -1,8 +1,7 @@ from typing import Any from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP -from roborock.devices.rpc.b01_q10_channel import send_command -from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.devices.rpc.b01_q10_channel import Q10RpcChannel from roborock.protocols.b01_q10_protocol import ParamsType @@ -15,9 +14,9 @@ class CommandTrait: available. """ - def __init__(self, channel: MqttChannel) -> None: + def __init__(self, rpc_channel: Q10RpcChannel) -> None: """Initialize the CommandTrait.""" - self._channel = channel + self._rpc_channel = rpc_channel async def send(self, command: B01_Q10_DP, params: ParamsType = None) -> Any: """Send a command to the device. @@ -27,6 +26,6 @@ async def send(self, command: B01_Q10_DP, params: ParamsType = None) -> Any: caller to ensure that any traits affected by the command are refreshed as needed. """ - if not self._channel: + if not self._rpc_channel: raise ValueError("Device trait in invalid state") - return await send_command(self._channel, command, params=params) + return await self._rpc_channel.send_command(command, params=params) diff --git a/roborock/devices/traits/b01/q7/__init__.py b/roborock/devices/traits/b01/q7/__init__.py index a48dc3443..35a291440 100644 --- a/roborock/devices/traits/b01/q7/__init__.py +++ b/roborock/devices/traits/b01/q7/__init__.py @@ -3,8 +3,6 @@ Potentially other devices may fall into this category in the future. """ -from __future__ import annotations - from typing import Any from roborock import B01Props @@ -18,11 +16,10 @@ SCWindMapping, WaterLevelMapping, ) -from roborock.devices.rpc.b01_q7_channel import MapRpcChannel, send_decoded_command +from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel, Q7RpcChannel from roborock.devices.traits import Trait -from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException -from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage, create_map_key +from roborock.protocols.b01_q7_protocol import CommandType, ParamsType from roborock.roborock_message import RoborockB01Props from roborock.roborock_typing import RoborockB01Q7Methods @@ -53,10 +50,14 @@ class Q7PropertiesApi(Trait): """Trait for fetching parsed current map content.""" def __init__( - self, channel: MqttChannel, map_rpc_channel: MapRpcChannel, device: HomeDataDevice, product: HomeDataProduct + self, + rpc_channel: Q7RpcChannel, + map_rpc_channel: Q7MapRpcChannel, + device: HomeDataDevice, + product: HomeDataProduct, ) -> None: """Initialize the Q7 API.""" - self._channel = channel + self._rpc_channel = rpc_channel self._map_rpc_channel = map_rpc_channel self._device = device self._product = product @@ -64,8 +65,8 @@ def __init__( if not device.sn or not product.model: raise ValueError("B01 Q7 map content requires device serial number and product model metadata") - self.clean_summary = CleanSummaryTrait(channel) - self.map = MapTrait(channel) + self.clean_summary = CleanSummaryTrait(rpc_channel) + self.map = MapTrait(rpc_channel) self.map_content = MapContentTrait( self._map_rpc_channel, self.map, @@ -199,17 +200,23 @@ async def find_me(self) -> None: async def send(self, command: CommandType, params: ParamsType) -> Any: """Send a command to the device.""" - return await send_decoded_command( - self._channel, - Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params), - ) + return await self._rpc_channel.send_command(command, params) -def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi: +def create( + product: HomeDataProduct, + device: HomeDataDevice, + rpc_channel: Q7RpcChannel, + map_rpc_channel: Q7MapRpcChannel, +) -> Q7PropertiesApi: """Create traits for B01 Q7 devices.""" if device.sn is None or product.model is None: raise RoborockException( - f"Device serial number and product model are required (sn:: {device.sn}, model: {product.model})" + f"Device serial number and product model are required (sn: {device.sn}, model: {product.model})" ) - map_rpc_channel = MapRpcChannel(channel, map_key=create_map_key(serial=device.sn, model=product.model)) - return Q7PropertiesApi(channel, device=device, product=product, map_rpc_channel=map_rpc_channel) + return Q7PropertiesApi( + rpc_channel=rpc_channel, + map_rpc_channel=map_rpc_channel, + device=device, + product=product, + ) diff --git a/roborock/devices/traits/b01/q7/clean_summary.py b/roborock/devices/traits/b01/q7/clean_summary.py index 65fea0e89..c2b62cfa6 100644 --- a/roborock/devices/traits/b01/q7/clean_summary.py +++ b/roborock/devices/traits/b01/q7/clean_summary.py @@ -7,11 +7,9 @@ import logging from roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary -from roborock.devices.rpc.b01_q7_channel import send_decoded_command +from roborock.devices.rpc.b01_q7_channel import Q7RpcChannel from roborock.devices.traits import Trait -from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException -from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage from roborock.roborock_typing import RoborockB01Q7Methods __all__ = [ @@ -24,11 +22,11 @@ class CleanSummaryTrait(CleanRecordSummary, Trait): """B01/Q7 clean summary + clean record access (via record list service).""" - def __init__(self, channel: MqttChannel) -> None: + def __init__(self, channel: Q7RpcChannel) -> None: """Initialize the clean summary trait. Args: - channel: MQTT channel used to communicate with the device. + channel: RPC channel used to communicate with the device. """ super().__init__() self._channel = channel @@ -46,9 +44,9 @@ async def refresh(self) -> None: async def _get_record_list(self) -> CleanRecordList: """Fetch the raw device clean record list (`service.get_record_list`).""" - result = await send_decoded_command( - self._channel, - Q7RequestMessage(dps=B01_Q7_DPS, command=RoborockB01Q7Methods.GET_RECORD_LIST, params={}), + result = await self._channel.send_command( + command=RoborockB01Q7Methods.GET_RECORD_LIST, + params={}, ) if not isinstance(result, dict): diff --git a/roborock/devices/traits/b01/q7/map.py b/roborock/devices/traits/b01/q7/map.py index f367e4076..50bea18fe 100644 --- a/roborock/devices/traits/b01/q7/map.py +++ b/roborock/devices/traits/b01/q7/map.py @@ -1,11 +1,9 @@ """Map trait for B01 Q7 devices.""" from roborock.data import Q7MapList -from roborock.devices.rpc.b01_q7_channel import send_decoded_command +from roborock.devices.rpc.b01_q7_channel import Q7RpcChannel from roborock.devices.traits import Trait -from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException -from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage from roborock.roborock_typing import RoborockB01Q7Methods @@ -16,15 +14,15 @@ class MapTrait(Q7MapList, Trait): current map ID to fetch. """ - def __init__(self, channel: MqttChannel) -> None: + def __init__(self, channel: Q7RpcChannel) -> None: super().__init__() self._channel = channel async def refresh(self) -> None: """Refresh cached map list metadata from the device.""" - response = await send_decoded_command( - self._channel, - Q7RequestMessage(dps=B01_Q7_DPS, command=RoborockB01Q7Methods.GET_MAP_LIST, params={}), + response = await self._channel.send_command( + command=RoborockB01Q7Methods.GET_MAP_LIST, + params={}, ) if not isinstance(response, dict): raise RoborockException( diff --git a/roborock/devices/traits/b01/q7/map_content.py b/roborock/devices/traits/b01/q7/map_content.py index afc36d024..0becf91a6 100644 --- a/roborock/devices/traits/b01/q7/map_content.py +++ b/roborock/devices/traits/b01/q7/map_content.py @@ -14,11 +14,10 @@ from vacuum_map_parser_base.map_data import MapData from roborock.data import RoborockBase -from roborock.devices.rpc.b01_q7_channel import MapRpcChannel +from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel from roborock.devices.traits import Trait from roborock.exceptions import RoborockException from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig -from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage from roborock.roborock_typing import RoborockB01Q7Methods from .map import MapTrait @@ -55,7 +54,7 @@ class MapContentTrait(MapContent, Trait): def __init__( self, - map_rpc_channel: MapRpcChannel, + map_rpc_channel: Q7MapRpcChannel, map_trait: MapTrait, *, map_parser_config: B01MapParserConfig | None = None, @@ -77,13 +76,11 @@ async def refresh(self) -> None: if (map_id := self._map_trait.current_map_id) is None: raise RoborockException("Unable to determine current map ID") - request = Q7RequestMessage( - dps=B01_Q7_DPS, - command=RoborockB01Q7Methods.UPLOAD_BY_MAPID, - params={"map_id": map_id}, - ) async with self._map_command_lock: - raw_payload = await self._map_rpc_channel.send_map_command(request) + raw_payload = await self._map_rpc_channel.send_map_command( + RoborockB01Q7Methods.UPLOAD_BY_MAPID, + {"map_id": map_id}, + ) try: parsed_data = self._map_parser.parse(raw_payload) diff --git a/tests/devices/rpc/test_b01_q10_channel.py b/tests/devices/rpc/test_b01_q10_channel.py new file mode 100644 index 000000000..0ffcc71fa --- /dev/null +++ b/tests/devices/rpc/test_b01_q10_channel.py @@ -0,0 +1,102 @@ +import json +from collections.abc import AsyncGenerator + +import pytest + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.devices.rpc.b01_q10_channel import create_b01_q10_channel +from roborock.exceptions import RoborockException +from roborock.protocols.b01_q10_protocol import Q10DpsUpdate +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from tests.fixtures.channel_fixtures import FakeChannel + + +@pytest.fixture(name="fake_channel") +def fake_channel_fixture() -> FakeChannel: + return FakeChannel() + + +async def test_create_b01_q10_channel(fake_channel: FakeChannel) -> None: + channel = create_b01_q10_channel(fake_channel) # type: ignore[arg-type] + assert channel is not None + assert channel.is_connected is False + assert channel.is_local_connected is False + + +async def test_q10_channel_send_command(fake_channel: FakeChannel) -> None: + channel = create_b01_q10_channel(fake_channel) # type: ignore[arg-type] + await channel.send_command(B01_Q10_DP.VOLUME, 50) + + assert len(fake_channel.published_messages) == 1 + message = fake_channel.published_messages[0] + assert message.protocol == RoborockMessageProtocol.RPC_REQUEST + assert message.payload is not None + payload_data = json.loads(message.payload.decode()) + assert payload_data == {"dps": {"26": 50}} + + +async def test_q10_channel_send_command_error(fake_channel: FakeChannel) -> None: + channel = create_b01_q10_channel(fake_channel) # type: ignore[arg-type] + fake_channel.publish_side_effect = RoborockException("Publish error") + + with pytest.raises(RoborockException, match="Publish error"): + await channel.send_command(B01_Q10_DP.VOLUME, 50) + + +async def test_q10_channel_subscribe_stream(fake_channel: FakeChannel) -> None: + channel = create_b01_q10_channel(fake_channel) # type: ignore[arg-type] + + async def simulate_messages() -> AsyncGenerator[RoborockMessage, None]: + dps_payload = {"dps": {"26": 30}} + # Valid message + yield RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=json.dumps(dps_payload).encode(), + version=b"B01", + ) + # Invalid message (causes decode failure/RoborockException) + yield RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=b"invalid-json{", + version=b"B01", + ) + # JSON but no dps key (returns None from decoder) + yield RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=b'{"not_dps": 1}', + version=b"B01", + ) + # Another valid message + dps_payload_2 = {"dps": {"26": 40}} + yield RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=json.dumps(dps_payload_2).encode(), + version=b"B01", + ) + + # Patch fake_channel.subscribe_stream + setattr(fake_channel, "subscribe_stream", MockStream(simulate_messages())) + + messages = [] + async for msg in channel.subscribe_stream(): + messages.append(msg) + + assert len(messages) == 2 + assert isinstance(messages[0], Q10DpsUpdate) + assert messages[0].dps[B01_Q10_DP.VOLUME] == 30 + assert isinstance(messages[1], Q10DpsUpdate) + assert messages[1].dps[B01_Q10_DP.VOLUME] == 40 + + +class MockStream: + def __init__(self, generator: AsyncGenerator[RoborockMessage, None]) -> None: + self._generator = generator + + def __call__(self, *args, **kwargs): + return self + + def __aiter__(self): + return self + + async def __anext__(self): + return await self._generator.__anext__() diff --git a/tests/devices/rpc/test_b01_q7_channel.py b/tests/devices/rpc/test_b01_q7_channel.py new file mode 100644 index 000000000..21c6d31ae --- /dev/null +++ b/tests/devices/rpc/test_b01_q7_channel.py @@ -0,0 +1,295 @@ +import asyncio +import json +import math +import time +from collections.abc import Generator +from typing import Any, cast +from unittest.mock import patch + +import pytest +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad + +from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory +from roborock.devices.rpc.b01_q7_channel import ( + create_b01_q7_channel, + send_decoded_command, +) +from roborock.exceptions import RoborockException +from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from tests.fixtures.channel_fixtures import FakeChannel + + +class B01MessageBuilder: + """Helper class to build B01 RPC response messages for tests.""" + + def __init__(self) -> None: + self.msg_id = 123456789 + self.seq = 2020 + + def build(self, data: dict[str, Any] | str, code: int | None = None) -> RoborockMessage: + """Build an encoded B01 RPC response message.""" + message: dict[str, Any] = { + "msgId": str(self.msg_id), + "data": data, + } + if code is not None: + message["code"] = code + return self._build_dps(message) + + def _build_dps(self, message: dict[str, Any] | str) -> RoborockMessage: + """Build an encoded B01 RPC response message.""" + dps_payload = {"dps": {"10000": json.dumps(message)}} + self.seq += 1 + return RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=pad( + json.dumps(dps_payload).encode(), + AES.block_size, + ), + version=b"B01", + seq=self.seq, + ) + + def build_map_response(self, payload: bytes) -> RoborockMessage: + """Build a dummy MAP_RESPONSE message.""" + self.seq += 1 + return RoborockMessage( + protocol=RoborockMessageProtocol.MAP_RESPONSE, + payload=payload, + version=b"B01", + seq=self.seq, + ) + + +@pytest.fixture(name="fake_channel") +def fake_channel_fixture() -> FakeChannel: + return FakeChannel() + + +@pytest.fixture(name="product") +def product_fixture() -> HomeDataProduct: + return HomeDataProduct( + id="product-id-q7", + name="Roborock Q7", + model="roborock.vacuum.sc05", + category=RoborockCategory.VACUUM, + ) + + +@pytest.fixture(name="device") +def device_fixture() -> HomeDataDevice: + return HomeDataDevice( + duid="abc123", + name="Q7", + local_key="key123key123key1", + product_id="product-id-q7", + sn="testsn012345", + ) + + +@pytest.fixture(name="expected_msg_id", autouse=True) +def next_message_id_fixture() -> Generator[int, None, None]: + expected_msg_id = math.floor(time.time()) + with patch("roborock.protocols.b01_q7_protocol.get_next_int", return_value=expected_msg_id): + yield expected_msg_id + + +@pytest.fixture(name="message_builder") +def message_builder_fixture(expected_msg_id: int) -> B01MessageBuilder: + builder = B01MessageBuilder() + builder.msg_id = expected_msg_id + return builder + + +async def test_create_b01_q7_channel( + device: HomeDataDevice, product: HomeDataProduct, fake_channel: FakeChannel +) -> None: + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + assert channel is not None + assert channel.is_connected is False + assert channel.is_local_connected is False + + +async def test_create_b01_q7_channel_missing_metadata( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, +) -> None: + """Test creating Q7 channel without required metadata raises RoborockException.""" + device.sn = None + with pytest.raises(RoborockException, match="Device serial number and product model are required"): + create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + + +async def test_q7_channel_send_command( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +) -> None: + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + fake_channel.response_queue.append(message_builder.build({"status": 1})) + + result = await channel.send_command("prop.get", {"property": ["status"]}) + assert result == {"status": 1} + + assert len(fake_channel.published_messages) == 1 + message = fake_channel.published_messages[0] + assert message.protocol == RoborockMessageProtocol.RPC_REQUEST + assert message.version == B01_VERSION + + assert message.payload is not None + payload_data = json.loads(unpad(message.payload, AES.block_size)) + assert payload_data["dps"]["10000"]["method"] == "prop.get" + + +async def test_q7_channel_send_map_command( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +) -> None: + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + unrelated_msg = message_builder.build({"status": 1}) + fake_channel.response_queue.append(unrelated_msg) + + with patch( + "roborock.devices.rpc.b01_q7_channel.decode_map_payload", + return_value=b"inflated-payload", + ) as mock_decode: + task = asyncio.create_task(channel.send_map_command("service.upload_by_mapid", {"map_id": 123})) + await asyncio.sleep(0) + + fake_channel.notify_subscribers(message_builder.build_map_response(b"raw-map-payload")) + result = await task + assert result == b"inflated-payload" + mock_decode.assert_called_once() + + +async def test_send_decoded_command_non_dict_response(fake_channel: FakeChannel, message_builder: B01MessageBuilder): + """Test validity of handling non-dict responses.""" + message = message_builder.build("some_string_error") + fake_channel.response_queue.append(message) + + with pytest.raises(RoborockException, match="Unexpected data type for response"): + await send_decoded_command(fake_channel, Q7RequestMessage(dps=10000, command="prop.get", params=[])) # type: ignore[arg-type] + + +async def test_send_decoded_command_error_code(fake_channel: FakeChannel, message_builder: B01MessageBuilder): + """Test that non-zero error codes from device are properly handled.""" + message = message_builder.build({}, code=5001) + fake_channel.response_queue.append(message) + + with pytest.raises(RoborockException, match="B01 command failed with code 5001"): + await send_decoded_command(fake_channel, Q7RequestMessage(dps=10000, command="prop.get", params=[])) # type: ignore[arg-type] + + +async def test_send_decoded_command_allows_ok_string_ack(fake_channel: FakeChannel, message_builder: B01MessageBuilder): + """Command ACKs may return plain string payloads like ``ok``.""" + message = message_builder.build("ok") + fake_channel.response_queue.append(message) + + result = await send_decoded_command( + cast(Any, fake_channel), + Q7RequestMessage(dps=10000, command="service.set_room_clean", params=[]), # type: ignore[arg-type] + ) + + assert result == "ok" + + +async def test_send_command_timeout( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, +) -> None: + """Test timeout behavior on regular send_command.""" + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + + with patch("roborock.devices.rpc.b01_q7_channel._TIMEOUT", 0.01): + with pytest.raises(RoborockException, match="B01 command timed out after"): + await channel.send_command("prop.get", {"property": ["status"]}) + + +async def test_send_map_command_timeout( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, +) -> None: + """Test timeout behavior on send_map_command.""" + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + + with patch("roborock.devices.rpc.b01_q7_channel._TIMEOUT", 0.01): + with pytest.raises(RoborockException, match="B01 map command timed out after"): + await channel.send_map_command("service.upload_by_mapid", {"map_id": 123}) + + +@pytest.mark.parametrize( + "bad_payload", + [ + # Undecryptable/unpad-failing message + b"short", + # Non-string dps value + pad(b'{"dps": {"10000": 123}}', AES.block_size), + # Invalid JSON string in dps value + pad(b'{"dps": {"10000": "invalid-json{"}}', AES.block_size), + # Message with incorrect msgId + pad(b'{"dps": {"10000": "{\\"msgId\\": \\"999\\"}"}}', AES.block_size), + ], +) +async def test_send_command_ignores_invalid_response( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, + bad_payload: bytes, +) -> None: + """Test that invalid, unparsable, or wrong-msgId messages are skipped gracefully.""" + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + + bad_message = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=bad_payload, + version=b"B01", + ) + fake_channel.response_queue.append(bad_message) + + task = asyncio.create_task(channel.send_command("prop.get", {"property": ["status"]})) + await asyncio.sleep(0) + + fake_channel.notify_subscribers(message_builder.build({"status": 1})) + result = await task + assert result == {"status": 1} + + +async def test_send_command_ignores_messages_after_resolve( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, + message_builder: B01MessageBuilder, +) -> None: + """Test that messages arriving after the command has resolved are ignored.""" + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + + fake_channel.response_queue.append(message_builder.build({"status": 1})) + + task = asyncio.create_task(channel.send_command("prop.get", {"property": ["status"]})) + await asyncio.sleep(0) + + fake_channel.notify_subscribers(message_builder.build({"status": 1})) + result = await task + assert result == {"status": 1} + + +async def test_send_command_general_exception( + device: HomeDataDevice, + product: HomeDataProduct, + fake_channel: FakeChannel, +) -> None: + """Test that non-RoborockException errors are propagated and logged.""" + channel = create_b01_q7_channel(device, product, fake_channel) # type: ignore[arg-type] + fake_channel.publish_side_effect = RuntimeError("Generic publish crash") + + with pytest.raises(RuntimeError, match="Generic publish crash"): + await channel.send_command("prop.get", {"property": ["status"]}) diff --git a/tests/devices/traits/b01/q10/conftest.py b/tests/devices/traits/b01/q10/conftest.py new file mode 100644 index 000000000..85cf0a028 --- /dev/null +++ b/tests/devices/traits/b01/q10/conftest.py @@ -0,0 +1,54 @@ +from collections.abc import AsyncGenerator +from typing import Any + +import pytest + +from roborock.devices.rpc.b01_q10_channel import B01Q10Channel, Q10RpcChannel +from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create +from roborock.protocols.b01_q10_protocol import Q10Message + + +class FakeQ10RpcChannel(Q10RpcChannel): + """Plaintext mock RPC channel for Q10.""" + + def __init__(self) -> None: + self.published_commands: list[tuple[Any, Any]] = [] + + async def send_command(self, command: Any, params: Any = None) -> None: + self.published_commands.append((command, params)) + + +class FakeB01Q10Channel(B01Q10Channel): + """Plaintext mock transport channel for Q10.""" + + def __init__(self) -> None: + self.published_commands: list[tuple[Any, Any]] = [] + self.messages_to_stream: list[Q10Message] = [] + + @property + def is_connected(self) -> bool: + return True + + @property + def is_local_connected(self) -> bool: + return False + + async def subscribe(self, callback: Any) -> Any: + return lambda: None + + async def subscribe_stream(self) -> AsyncGenerator[Q10Message, None]: + for msg in self.messages_to_stream: + yield msg + + async def send_command(self, command: Any, params: Any = None) -> None: + self.published_commands.append((command, params)) + + +@pytest.fixture(name="fake_channel") +def fake_channel_fixture() -> FakeB01Q10Channel: + return FakeB01Q10Channel() + + +@pytest.fixture(name="q10_api") +def q10_api_fixture(fake_channel: FakeB01Q10Channel) -> Q10PropertiesApi: + return create(fake_channel) diff --git a/tests/devices/traits/b01/q10/test_clean_history.py b/tests/devices/traits/b01/q10/test_clean_history.py index ff09b0efc..10fce2b64 100644 --- a/tests/devices/traits/b01/q10/test_clean_history.py +++ b/tests/devices/traits/b01/q10/test_clean_history.py @@ -1,5 +1,3 @@ -import json - import pytest from roborock.data.b01_q10.b01_q10_code_mappings import ( @@ -12,7 +10,8 @@ from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord from roborock.devices.traits.b01.q10 import Q10PropertiesApi from roborock.devices.traits.b01.q10.clean_history import CleanHistoryTrait, CleanRecordConverter -from tests.fixtures.channel_fixtures import FakeChannel + +from .conftest import FakeB01Q10Channel # 12 underscore fields: recordId, startTime, cleanTime, cleanArea, mapLen, pathLen, # virtualLen, cleanMode, workMode, cleaningResult, startMethod, collectDustCount. @@ -20,16 +19,6 @@ RECORD_B = "def456abc789_1781139871_41_25_7100_18420_5_1_1_0_2_1" -@pytest.fixture(name="fake_channel") -def fake_channel_fixture() -> FakeChannel: - return FakeChannel() - - -@pytest.fixture(name="q10_api") -def q10_api_fixture(fake_channel: FakeChannel) -> Q10PropertiesApi: - return Q10PropertiesApi(fake_channel) # type: ignore[arg-type] - - @pytest.fixture(name="clean_history") def clean_history_fixture(q10_api: Q10PropertiesApi) -> CleanHistoryTrait: return q10_api.clean_history @@ -178,11 +167,11 @@ def test_update_from_dps_notify_ignores_malformed(clean_history: CleanHistoryTra assert [r.record_id for r in clean_history.records] == ["abc123def456"] -async def test_refresh_sends_op_list(q10_api: Q10PropertiesApi, fake_channel: FakeChannel) -> None: +async def test_refresh_sends_op_list(q10_api: Q10PropertiesApi, fake_channel: FakeB01Q10Channel) -> None: await q10_api.clean_history.refresh() - assert len(fake_channel.published_messages) == 1 - raw_payload = fake_channel.published_messages[0].payload - assert raw_payload is not None - payload = json.loads(raw_payload.decode()) - assert payload == {"dps": {"101": {"52": {"op": "list"}}}} + assert len(fake_channel.published_commands) == 1 + assert fake_channel.published_commands[0] == ( + B01_Q10_DP.COMMON, + {"52": {"op": "list"}}, + ) diff --git a/tests/devices/traits/b01/q10/test_map.py b/tests/devices/traits/b01/q10/test_map.py index 470f3f993..fb07e5859 100644 --- a/tests/devices/traits/b01/q10/test_map.py +++ b/tests/devices/traits/b01/q10/test_map.py @@ -9,7 +9,7 @@ from collections.abc import AsyncGenerator from pathlib import Path from typing import cast -from unittest.mock import AsyncMock, Mock +from unittest.mock import Mock import pytest @@ -17,18 +17,14 @@ from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create from roborock.devices.traits.b01.q10.map import MapContentTrait from roborock.map.b01_q10_map_parser import Q10Point, parse_map_packet, parse_trace_packet -from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from roborock.protocols.b01_q10_protocol import Q10Message + +from .conftest import FakeB01Q10Channel FIXTURE = Path("tests/map/testdata/b01_q10_map.bin") TRACE_FIXTURE = Path("tests/map/testdata/b01_q10_trace.bin") -def _map_message( - payload: bytes, protocol: RoborockMessageProtocol = RoborockMessageProtocol.MAP_RESPONSE -) -> RoborockMessage: - return RoborockMessage(protocol=protocol, payload=payload, version=b"B01") - - def test_update_from_map_packet_populates_image_and_rooms() -> None: """A parsed 01 01 map packet populates the image, rooms and map data.""" packet = parse_map_packet(FIXTURE.read_bytes()) @@ -124,24 +120,25 @@ async def test_await_q10_map_push_can_fall_back_to_cached_map_on_timeout() -> No # --- Integration through the Q10PropertiesApi subscribe loop ----------------- -@pytest.fixture -def message_queue() -> asyncio.Queue[RoborockMessage]: +@pytest.fixture(name="message_queue") +def message_queue_fixture() -> asyncio.Queue[Q10Message]: return asyncio.Queue() -@pytest.fixture -def mock_channel(message_queue: asyncio.Queue[RoborockMessage]) -> AsyncMock: - async def mock_stream() -> AsyncGenerator[RoborockMessage, None]: +@pytest.fixture(name="mock_channel") +def mock_channel_fixture(message_queue: asyncio.Queue[Q10Message]) -> FakeB01Q10Channel: + channel = FakeB01Q10Channel() + + async def mock_stream() -> AsyncGenerator[Q10Message, None]: while True: yield await message_queue.get() - channel = AsyncMock() - channel.subscribe_stream = Mock(return_value=mock_stream()) + setattr(channel, "subscribe_stream", Mock(side_effect=mock_stream)) return channel -@pytest.fixture -async def q10_api(mock_channel: AsyncMock) -> AsyncGenerator[Q10PropertiesApi, None]: +@pytest.fixture(name="q10_api") +async def q10_api_fixture(mock_channel: FakeB01Q10Channel) -> AsyncGenerator[Q10PropertiesApi, None]: api = create(mock_channel) await api.start() yield api @@ -156,12 +153,12 @@ async def _wait_for(predicate, timeout: float = 2.0) -> None: async def test_subscribe_loop_routes_map_push( q10_api: Q10PropertiesApi, - message_queue: asyncio.Queue[RoborockMessage], + message_queue: asyncio.Queue[Q10Message], ) -> None: """A map pushed onto the stream is routed to the map trait by the loop.""" assert q10_api.map.image_content is None - message_queue.put_nowait(_map_message(FIXTURE.read_bytes())) + message_queue.put_nowait(parse_map_packet(FIXTURE.read_bytes())) await _wait_for(lambda: q10_api.map.image_content is not None) assert {room.id: room.name for room in q10_api.map.rooms} == {2: "Living Room", 3: "Bedroom"} @@ -169,12 +166,12 @@ async def test_subscribe_loop_routes_map_push( async def test_subscribe_loop_routes_trace_push( q10_api: Q10PropertiesApi, - message_queue: asyncio.Queue[RoborockMessage], + message_queue: asyncio.Queue[Q10Message], ) -> None: """A trace pushed onto the stream is routed to the map trait by the loop.""" assert not q10_api.map.path - message_queue.put_nowait(_map_message(TRACE_FIXTURE.read_bytes())) + message_queue.put_nowait(parse_trace_packet(TRACE_FIXTURE.read_bytes())) await _wait_for(lambda: bool(q10_api.map.path)) assert q10_api.map.robot_position is not None diff --git a/tests/devices/traits/b01/q10/test_remote.py b/tests/devices/traits/b01/q10/test_remote.py index 4de8c986d..2eac14034 100644 --- a/tests/devices/traits/b01/q10/test_remote.py +++ b/tests/devices/traits/b01/q10/test_remote.py @@ -1,4 +1,3 @@ -import json from collections.abc import Awaitable, Callable from typing import Any @@ -6,17 +5,8 @@ from roborock.devices.traits.b01.q10 import Q10PropertiesApi from roborock.devices.traits.b01.q10.remote import RemoteTrait -from tests.fixtures.channel_fixtures import FakeChannel - -@pytest.fixture(name="fake_channel") -def fake_channel_fixture() -> FakeChannel: - return FakeChannel() - - -@pytest.fixture(name="q10_api") -def q10_api_fixture(fake_channel: FakeChannel) -> Q10PropertiesApi: - return Q10PropertiesApi(fake_channel) # type: ignore[arg-type] +from .conftest import FakeB01Q10Channel @pytest.fixture(name="remote") @@ -36,15 +26,18 @@ def remote_fixture(q10_api: Q10PropertiesApi) -> RemoteTrait: ) async def test_remote_commands( remote: RemoteTrait, - fake_channel: FakeChannel, + fake_channel: FakeB01Q10Channel, command_fn: Callable[[RemoteTrait], Awaitable[None]], expected_payload: dict[str, Any], ) -> None: """Test sending a remote start command.""" await command_fn(remote) - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - assert message.payload - payload_data = json.loads(message.payload.decode()) - assert payload_data == {"dps": expected_payload} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + + dp_code = int(list(expected_payload.keys())[0]) + expected_params = list(expected_payload.values())[0] + + assert command.code == dp_code + assert params == expected_params diff --git a/tests/devices/traits/b01/q10/test_settings.py b/tests/devices/traits/b01/q10/test_settings.py index 7b3474c59..7d29347b4 100644 --- a/tests/devices/traits/b01/q10/test_settings.py +++ b/tests/devices/traits/b01/q10/test_settings.py @@ -1,8 +1,5 @@ """Tests for the Q10 B01 setting writer traits.""" -import json -from typing import cast - import pytest from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceDustCollectionFrequency @@ -12,31 +9,30 @@ from roborock.devices.traits.b01.q10.do_not_disturb import DoNotDisturbTrait from roborock.devices.traits.b01.q10.dust_collection import DustCollectionTrait from roborock.devices.traits.b01.q10.volume import SoundVolumeTrait -from roborock.devices.transport.mqtt_channel import MqttChannel -from tests.fixtures.channel_fixtures import FakeChannel + +from .conftest import FakeQ10RpcChannel -@pytest.fixture -def fake_channel() -> FakeChannel: - return FakeChannel() +@pytest.fixture(name="fake_rpc_channel") +def fake_rpc_channel_fixture() -> FakeQ10RpcChannel: + return FakeQ10RpcChannel() -@pytest.fixture -def command(fake_channel: FakeChannel) -> CommandTrait: - return CommandTrait(cast(MqttChannel, fake_channel)) +@pytest.fixture(name="command") +def command_fixture(fake_rpc_channel: FakeQ10RpcChannel) -> CommandTrait: + return CommandTrait(fake_rpc_channel) -def _sent_dps(fake_channel: FakeChannel) -> dict: - assert len(fake_channel.published_messages) == 1 - payload = fake_channel.published_messages[0].payload - assert payload is not None - return json.loads(payload)["dps"] +def _sent_dps(fake_rpc_channel: FakeQ10RpcChannel) -> dict: + assert len(fake_rpc_channel.published_commands) == 1 + command, params = fake_rpc_channel.published_commands[0] + return {str(command.code): params} -async def test_set_volume_uses_common_wrapper(fake_channel: FakeChannel, command: CommandTrait) -> None: +async def test_set_volume_uses_common_wrapper(fake_rpc_channel: FakeQ10RpcChannel, command: CommandTrait) -> None: """Volume writes are wrapped in dpCommon (101) -> {"26": value}.""" await SoundVolumeTrait(command).set_volume(55) - assert _sent_dps(fake_channel) == {"101": {"26": 55}} + assert _sent_dps(fake_rpc_channel) == {"101": {"26": 55}} @pytest.mark.parametrize("volume", [-1, 101, 1000]) @@ -55,16 +51,20 @@ async def test_set_volume_rejects_out_of_range(command: CommandTrait, volume: in ], ) async def test_switch_enable_writes_common_wrapped_dp( - fake_channel: FakeChannel, command: CommandTrait, trait_cls: type, method: str, code: str + fake_rpc_channel: FakeQ10RpcChannel, + command: CommandTrait, + trait_cls: type, + method: str, + code: str, ) -> None: """Each switch trait's enable() writes its data point as int 1 under dpCommon.""" await getattr(trait_cls(command), method)() - assert _sent_dps(fake_channel) == {"101": {code: 1}} + assert _sent_dps(fake_rpc_channel) == {"101": {code: 1}} -async def test_switch_disable_sends_zero(fake_channel: FakeChannel, command: CommandTrait) -> None: +async def test_switch_disable_sends_zero(fake_rpc_channel: FakeQ10RpcChannel, command: CommandTrait) -> None: await ChildLockTrait(command).disable() - assert _sent_dps(fake_channel) == {"101": {"47": 0}} + assert _sent_dps(fake_rpc_channel) == {"101": {"47": 0}} @pytest.mark.parametrize( @@ -77,11 +77,11 @@ async def test_switch_disable_sends_zero(fake_channel: FakeChannel, command: Com ], ) async def test_set_dust_frequency_writes_interval_code( - fake_channel: FakeChannel, + fake_rpc_channel: FakeQ10RpcChannel, command: CommandTrait, frequency: YXDeviceDustCollectionFrequency, code: int, ) -> None: """Frequency enum writes its interval code under dpDustSetting (50).""" await DustCollectionTrait(command).set_frequency(frequency) - assert _sent_dps(fake_channel) == {"101": {"50": code}} + assert _sent_dps(fake_rpc_channel) == {"101": {"50": code}} diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 98732ce60..8105142a3 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -1,11 +1,10 @@ """Tests for the Q10 B01 status trait.""" import asyncio -import json import pathlib from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import AsyncMock, Mock +from unittest.mock import Mock import pytest @@ -23,42 +22,38 @@ ) from roborock.data.b01_q10.b01_q10_containers import dpNetInfo, dpNotDisturbExpand, dpTimeZone from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create +from roborock.protocols.b01_q10_protocol import Q10Message, decode_message from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from .conftest import FakeB01Q10Channel + TEST_DATA_DIR = pathlib.Path("tests/protocols/testdata/b01_q10_protocol") TESTDATA_DP_STATUS_DP_CLEAN_TASK_TYPE = (TEST_DATA_DIR / "dpStatus-dpCleanTaskType.json").read_bytes() TESTDATA_DP_REQUEST_DPS = (TEST_DATA_DIR / "dpRequetdps.json").read_bytes() -@pytest.fixture -def mock_channel(): - """Fixture for a mocked MQTT channel.""" - mock = AsyncMock() - return mock - - -@pytest.fixture -def message_queue() -> asyncio.Queue[RoborockMessage]: +@pytest.fixture(name="message_queue") +def message_queue_fixture() -> asyncio.Queue[Q10Message]: """Fixture for a message queue used by the mock stream.""" return asyncio.Queue() -@pytest.fixture -def mock_subscribe_stream(mock_channel: AsyncMock, message_queue: asyncio.Queue[RoborockMessage]) -> Mock: +@pytest.fixture(name="mock_channel") +def mock_channel_fixture(message_queue: asyncio.Queue[Q10Message]) -> FakeB01Q10Channel: """Fixture to mock the subscribe_stream method to yield from a queue.""" + channel = FakeB01Q10Channel() - async def mock_stream() -> AsyncGenerator[RoborockMessage, None]: + async def mock_stream() -> AsyncGenerator[Q10Message, None]: while True: yield await message_queue.get() - mock = Mock(return_value=mock_stream()) - mock_channel.subscribe_stream = mock - return mock + setattr(channel, "subscribe_stream", Mock(side_effect=mock_stream)) + return channel -@pytest.fixture -async def q10_api(mock_channel: AsyncMock, mock_subscribe_stream: Mock) -> AsyncGenerator[Q10PropertiesApi, None]: +@pytest.fixture(name="q10_api") +async def q10_api_fixture(mock_channel: FakeB01Q10Channel) -> AsyncGenerator[Q10PropertiesApi, None]: """Fixture to create and manage the Q10PropertiesApi.""" api = create(mock_channel) await api.start() @@ -66,20 +61,20 @@ async def q10_api(mock_channel: AsyncMock, mock_subscribe_stream: Mock) -> Async await api.close() -def build_message(payload: bytes) -> RoborockMessage: - """Helper to build a RoborockMessage for testing.""" - return RoborockMessage( +def build_q10_message(payload: bytes) -> Q10Message: + """Helper to build a Q10Message for testing.""" + msg = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=payload, version=b"B01", ) + result = decode_message(msg) + assert result is not None + return result async def wait_for_attribute_value(obj: Any, attribute: str, value: Any, timeout: float = 2.0) -> None: - """Wait for an attribute on an object to reach a specific value. - - This is a temporary polling solution until listeners are implemented. - """ + """Wait for an attribute on an object to reach a specific value.""" for _ in range(int(timeout / 0.1)): if getattr(obj, attribute) == value: return @@ -89,12 +84,12 @@ async def wait_for_attribute_value(obj: Any, attribute: str, value: Any, timeout async def test_status_trait_streaming( q10_api: Q10PropertiesApi, - message_queue: asyncio.Queue[RoborockMessage], + message_queue: asyncio.Queue[Q10Message], ) -> None: """Test that the StatusTrait updates its state from streaming messages.""" # status (121) = 8 (CHARGING_STATE) # clean_task_type (138) = 0 (IDLE) - message = build_message(TESTDATA_DP_STATUS_DP_CLEAN_TASK_TYPE) + message = build_q10_message(TESTDATA_DP_STATUS_DP_CLEAN_TASK_TYPE) assert q10_api.status.status is None assert q10_api.status.clean_task_type is None @@ -112,8 +107,8 @@ async def test_status_trait_streaming( async def test_status_trait_refresh( q10_api: Q10PropertiesApi, - mock_channel: AsyncMock, - message_queue: asyncio.Queue[RoborockMessage], + mock_channel: FakeB01Q10Channel, + message_queue: asyncio.Queue[Q10Message], ) -> None: """Test that the StatusTrait sends a refresh command and updates state.""" assert q10_api.status.battery is None @@ -128,18 +123,14 @@ async def test_status_trait_refresh( # battery (122) = 100 # status (121) = 8 (CHARGING_STATE) # fan_level (123) = 2 (BALANCED) - message = build_message(TESTDATA_DP_REQUEST_DPS) + message = build_q10_message(TESTDATA_DP_REQUEST_DPS) # Send a refresh command await q10_api.refresh() - mock_channel.publish.assert_called_once() - sent_message = mock_channel.publish.call_args[0][0] - assert sent_message.protocol == RoborockMessageProtocol.RPC_REQUEST - # Verify refresh payload - data = json.loads(sent_message.payload) - assert data - assert data.get("dps") - assert data.get("dps").get("102") == {} # REQUEST_DPS code is 102 + assert len(mock_channel.published_commands) == 1 + command, params = mock_channel.published_commands[0] + assert command == B01_Q10_DP.REQUEST_DPS + assert params == {} # Push the response message into the queue message_queue.put_nowait(message) @@ -199,11 +190,11 @@ async def test_status_trait_refresh( async def test_status_trait_vacuum_only_refresh( q10_api: Q10PropertiesApi, - message_queue: asyncio.Queue[RoborockMessage], + message_queue: asyncio.Queue[Q10Message], ) -> None: """Test decoding a full status dump from a vacuum-only (no mop) Q10.""" payload = (TEST_DATA_DIR / "dpRequestDps_vacuum_only.json").read_bytes() - message_queue.put_nowait(build_message(payload)) + message_queue.put_nowait(build_q10_message(payload)) await wait_for_attribute_value(q10_api.status, "battery", 75) diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index 17f6b3de0..9ce448bfa 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -1,4 +1,3 @@ -import json from collections.abc import Awaitable, Callable from typing import Any @@ -7,21 +6,12 @@ from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType, YXFanLevel from roborock.devices.traits.b01.q10 import Q10PropertiesApi from roborock.devices.traits.b01.q10.vacuum import VacuumTrait -from tests.fixtures.channel_fixtures import FakeChannel +from .conftest import FakeB01Q10Channel -@pytest.fixture(name="fake_channel") -def fake_channel_fixture() -> FakeChannel: - return FakeChannel() - -@pytest.fixture(name="q10_api") -def q10_api_fixture(fake_channel: FakeChannel) -> Q10PropertiesApi: - return Q10PropertiesApi(fake_channel) # type: ignore[arg-type] - - -@pytest.fixture(name="vacuumm") -def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: +@pytest.fixture(name="vacuum") +def vacuum_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: return q10_api.vacuum @@ -43,16 +33,19 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: ], ) async def test_vacuum_commands( - vacuumm: VacuumTrait, - fake_channel: FakeChannel, + vacuum: VacuumTrait, + fake_channel: FakeB01Q10Channel, command_fn: Callable[[VacuumTrait], Awaitable[None]], expected_payload: dict[str, Any], ) -> None: """Test sending a vacuum start command.""" - await command_fn(vacuumm) + await command_fn(vacuum) + + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + + dp_code = int(list(expected_payload.keys())[0]) + expected_params = list(expected_payload.values())[0] - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - assert message.payload - payload_data = json.loads(message.payload.decode()) - assert payload_data == {"dps": expected_payload} + assert command.code == dp_code + assert params == expected_params diff --git a/tests/devices/traits/b01/q7/__init__.py b/tests/devices/traits/b01/q7/__init__.py index 128a09240..1fcbe4aa1 100644 --- a/tests/devices/traits/b01/q7/__init__.py +++ b/tests/devices/traits/b01/q7/__init__.py @@ -1,51 +1 @@ -import json -from typing import Any - -from Crypto.Cipher import AES -from Crypto.Util.Padding import pad - -from roborock.devices.traits.b01.q7 import Q7PropertiesApi -from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol -from tests.fixtures.channel_fixtures import FakeChannel - - -class B01MessageBuilder: - """Helper class to build B01 RPC response messages for tests.""" - - def __init__(self) -> None: - self.msg_id = 123456789 - self.seq = 2020 - - def build(self, data: dict[str, Any] | str, code: int | None = None) -> RoborockMessage: - """Build an encoded B01 RPC response message.""" - message: dict[str, Any] = { - "msgId": str(self.msg_id), - "data": data, - } - if code is not None: - message["code"] = code - return self._build_dps(message) - - def _build_dps(self, message: dict[str, Any] | str) -> RoborockMessage: - """Build an encoded B01 RPC response message.""" - dps_payload = {"dps": {"10000": json.dumps(message)}} - self.seq += 1 - return RoborockMessage( - protocol=RoborockMessageProtocol.RPC_RESPONSE, - payload=pad( - json.dumps(dps_payload).encode(), - AES.block_size, - ), - version=b"B01", - seq=self.seq, - ) - - def build_map_response(self, payload: bytes) -> RoborockMessage: - """Build a dummy MAP_RESPONSE message.""" - self.seq += 1 - return RoborockMessage( - protocol=RoborockMessageProtocol.MAP_RESPONSE, - payload=payload, - version=b"B01", - seq=self.seq, - ) +# Q7 traits test package diff --git a/tests/devices/traits/b01/q7/conftest.py b/tests/devices/traits/b01/q7/conftest.py index caf2097d2..efc9d166e 100644 --- a/tests/devices/traits/b01/q7/conftest.py +++ b/tests/devices/traits/b01/q7/conftest.py @@ -1,20 +1,38 @@ -import math -import time -from collections.abc import Generator -from unittest.mock import patch +from typing import Any import pytest from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory +from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel, Q7RpcChannel from roborock.devices.traits.b01.q7 import Q7PropertiesApi, create -from tests.fixtures.channel_fixtures import FakeChannel -from . import B01MessageBuilder + +class FakeQ7Channel(Q7RpcChannel, Q7MapRpcChannel): + """A plaintext mock for Q7 Rpc and Map Channel.""" + + def __init__(self) -> None: + self.published_commands: list[tuple[Any, Any]] = [] + self.response_queue: list[Any] = [] + self.side_effect: Exception | None = None + + async def send_command(self, command: Any, params: Any = None) -> Any: + if self.side_effect: + raise self.side_effect + self.published_commands.append((command, params)) + if self.response_queue: + return self.response_queue.pop(0) + return {} + + async def send_map_command(self, command: Any, params: Any = None) -> bytes: + self.published_commands.append((command, params)) + if self.response_queue: + return self.response_queue.pop(0) + return b"" @pytest.fixture(name="fake_channel") -def fake_channel_fixture() -> FakeChannel: - return FakeChannel() +def fake_channel_fixture() -> FakeQ7Channel: + return FakeQ7Channel() @pytest.fixture(name="product") @@ -39,27 +57,9 @@ def device_fixture() -> HomeDataDevice: @pytest.fixture(name="q7_api") -def q7_api_fixture(fake_channel: FakeChannel, device: HomeDataDevice, product: HomeDataProduct) -> Q7PropertiesApi: - return create(product, device, fake_channel) # type: ignore[arg-type] - - -@pytest.fixture(name="expected_msg_id", autouse=True) -def next_message_id_fixture() -> Generator[int, None, None]: - """Fixture to patch get_next_int to return the expected message ID. - - We pick an arbitrary number, but just need it to ensure we can craft a fake - response with the message id matched to the outgoing RPC. - """ - - expected_msg_id = math.floor(time.time()) - - # Patch get_next_int to return our expected msg_id so the channel waits for it - with patch("roborock.protocols.b01_q7_protocol.get_next_int", return_value=expected_msg_id): - yield expected_msg_id - - -@pytest.fixture(name="message_builder") -def message_builder_fixture(expected_msg_id: int) -> B01MessageBuilder: - builder = B01MessageBuilder() - builder.msg_id = expected_msg_id - return builder +def q7_api_fixture( + fake_channel: FakeQ7Channel, + device: HomeDataDevice, + product: HomeDataProduct, +) -> Q7PropertiesApi: + return create(product, device, fake_channel, fake_channel) diff --git a/tests/devices/traits/b01/q7/test_clean_summary.py b/tests/devices/traits/b01/q7/test_clean_summary.py index 0a2bccbbf..ae77208ef 100644 --- a/tests/devices/traits/b01/q7/test_clean_summary.py +++ b/tests/devices/traits/b01/q7/test_clean_summary.py @@ -8,9 +8,8 @@ from roborock.data.b01_q7 import CleanRecordList from roborock.devices.traits.b01.q7.clean_summary import CleanSummaryTrait from roborock.exceptions import RoborockException -from tests.fixtures.channel_fixtures import FakeChannel -from . import B01MessageBuilder +from .conftest import FakeQ7Channel CLEAN_RECORD_LIST_DATA = { "total_time": 34980, @@ -60,17 +59,16 @@ @pytest.fixture(name="clean_summary_trait") -def clean_summary_trait_fixture(fake_channel: FakeChannel) -> CleanSummaryTrait: - return CleanSummaryTrait(fake_channel) # type: ignore[arg-type] +def clean_summary_trait_fixture(fake_channel: FakeQ7Channel) -> CleanSummaryTrait: + return CleanSummaryTrait(fake_channel) async def test_refresh_success( clean_summary_trait: CleanSummaryTrait, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ) -> None: """Test successfully refreshing clean summary.""" - fake_channel.response_queue.append(message_builder.build(CLEAN_RECORD_LIST_DATA)) + fake_channel.response_queue.append(CLEAN_RECORD_LIST_DATA) await clean_summary_trait.refresh() assert clean_summary_trait.total_time == 34980 @@ -82,8 +80,7 @@ async def test_refresh_success( async def test_refresh_with_no_records( clean_summary_trait: CleanSummaryTrait, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ) -> None: """Test refreshing with no records.""" empty_response = { @@ -92,7 +89,7 @@ async def test_refresh_with_no_records( "total_count": 0, "record_list": [], } - fake_channel.response_queue.append(message_builder.build(empty_response)) + fake_channel.response_queue.append(empty_response) await clean_summary_trait.refresh() assert clean_summary_trait.total_time == 0 @@ -103,10 +100,10 @@ async def test_refresh_with_no_records( async def test_refresh_propagates_exceptions( clean_summary_trait: CleanSummaryTrait, - fake_channel: FakeChannel, + fake_channel: FakeQ7Channel, ) -> None: """Test that exceptions from channel are propagated during refresh.""" - fake_channel.publish_side_effect = RoborockException("Communication error") + fake_channel.side_effect = RoborockException("Communication error") with pytest.raises(RoborockException, match="Communication error"): await clean_summary_trait.refresh() diff --git a/tests/devices/traits/b01/q7/test_init.py b/tests/devices/traits/b01/q7/test_init.py index 0c04a2611..c273d2d49 100644 --- a/tests/devices/traits/b01/q7/test_init.py +++ b/tests/devices/traits/b01/q7/test_init.py @@ -1,9 +1,6 @@ -import json -from typing import Any, cast +from typing import Any import pytest -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad from roborock.data.b01_q7 import ( CleanTaskTypeMapping, @@ -13,19 +10,14 @@ WaterLevelMapping, WorkStatusMapping, ) -from roborock.devices.rpc.b01_q7_channel import send_decoded_command from roborock.devices.traits.b01.q7 import Q7PropertiesApi -from roborock.exceptions import RoborockException -from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage -from roborock.roborock_message import RoborockB01Props, RoborockMessageProtocol -from tests.fixtures.channel_fixtures import FakeChannel +from roborock.roborock_message import RoborockB01Props +from roborock.roborock_typing import RoborockB01Q7Methods -from . import B01MessageBuilder +from .conftest import FakeQ7Channel -async def test_q7_api_query_values( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_query_values(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test that Q7PropertiesApi correctly converts raw values.""" response_data = { "status": 1, @@ -33,7 +25,7 @@ async def test_q7_api_query_values( "battery": 100, } - fake_channel.response_queue.append(message_builder.build(response_data)) + fake_channel.response_queue.append(response_data) result = await q7_api.query_values( [ @@ -46,19 +38,10 @@ async def test_q7_api_query_values( assert result.status == WorkStatusMapping.WAITING_FOR_ORDERS assert result.wind == SCWindMapping.STANDARD - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - assert message.protocol == RoborockMessageProtocol.RPC_REQUEST - assert message.version == B01_VERSION - - assert message.payload is not None - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert "dps" in payload_data - assert "10000" in payload_data["dps"] - inner = payload_data["dps"]["10000"] - assert inner["method"] == "prop.get" - assert inner["msgId"] == str(message_builder.msg_id) - assert inner["params"] == {"property": [RoborockB01Props.STATUS, RoborockB01Props.WIND]} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.GET_PROP + assert params == {"property": [RoborockB01Props.STATUS, RoborockB01Props.WIND]} @pytest.mark.parametrize( @@ -81,11 +64,10 @@ async def test_q7_response_value_mapping( response_data: dict[str, Any], expected_status: WorkStatusMapping, q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): """Test Q7PropertiesApi value mapping for different statuses.""" - fake_channel.response_queue.append(message_builder.build(response_data)) + fake_channel.response_queue.append(response_data) result = await q7_api.query_values(query) @@ -93,81 +75,42 @@ async def test_q7_response_value_mapping( assert result.status == expected_status -async def test_send_decoded_command_non_dict_response(fake_channel: FakeChannel, message_builder: B01MessageBuilder): - """Test validity of handling non-dict responses (should not timeout).""" - message = message_builder.build("some_string_error") - fake_channel.response_queue.append(message) - - with pytest.raises(RoborockException, match="Unexpected data type for response"): - await send_decoded_command(fake_channel, Q7RequestMessage(dps=10000, command="prop.get", params=[])) # type: ignore[arg-type] - - -async def test_send_decoded_command_error_code(fake_channel: FakeChannel, message_builder: B01MessageBuilder): - """Test that non-zero error codes from device are properly handled.""" - message = message_builder.build({}, code=5001) - fake_channel.response_queue.append(message) - - with pytest.raises(RoborockException, match="B01 command failed with code 5001"): - await send_decoded_command(fake_channel, Q7RequestMessage(dps=10000, command="prop.get", params=[])) # type: ignore[arg-type] - - -async def test_send_decoded_command_allows_ok_string_ack(fake_channel: FakeChannel, message_builder: B01MessageBuilder): - """Command ACKs may return plain string payloads like ``ok``.""" - message = message_builder.build("ok") - fake_channel.response_queue.append(message) - - result = await send_decoded_command( - cast(Any, fake_channel), - Q7RequestMessage(dps=10000, command="service.set_room_clean", params=[]), # type: ignore[arg-type] - ) - - assert result == "ok" - - -async def test_q7_api_set_fan_speed( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_set_fan_speed(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test setting fan speed.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.set_fan_speed(SCWindMapping.STRONG) - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "prop.set" - assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.WIND: SCWindMapping.STRONG.code} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_PROP + assert params == {RoborockB01Props.WIND: SCWindMapping.STRONG.code} -async def test_q7_api_set_water_level( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_set_water_level(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test setting water level.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.set_water_level(WaterLevelMapping.HIGH) - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "prop.set" - assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.WATER: WaterLevelMapping.HIGH.code} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_PROP + assert params == {RoborockB01Props.WATER: WaterLevelMapping.HIGH.code} @pytest.mark.parametrize("volume", [0, 50, 100]) async def test_q7_api_set_volume( volume: int, q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): """Test setting the robot voice volume.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.set_volume(volume) - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "prop.set" - assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.VOLUME: volume} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_PROP + assert params == {RoborockB01Props.VOLUME: volume} @pytest.mark.parametrize( @@ -178,18 +121,16 @@ async def test_q7_api_set_child_lock( enabled: bool, expected_code: int, q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): """Test toggling the child lock.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.set_child_lock(enabled) - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "prop.set" - assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.CHILD_LOCK: expected_code} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_PROP + assert params == {RoborockB01Props.CHILD_LOCK: expected_code} @pytest.mark.parametrize("enabled, expected_is_open", [(True, 1), (False, 0)]) @@ -197,17 +138,16 @@ async def test_q7_api_set_do_not_disturb( enabled: bool, expected_is_open: int, q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): """Test do-not-disturb is set as a whole via service.set_quiet_time.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.set_do_not_disturb(enabled, 1200, 420) - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "service.set_quiet_time" - assert payload_data["dps"]["10000"]["params"] == { + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_QUIET_TIME + assert params == { "is_open": expected_is_open, "quiet_begin_time": 1200, "quiet_end_time": 420, @@ -222,13 +162,13 @@ async def test_q7_api_set_do_not_disturb_invalid_time( begin_time: int, end_time: int, q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, + fake_channel: FakeQ7Channel, ): """Test out-of-range times raise ValueError and nothing is sent.""" with pytest.raises(ValueError, match="minutes since midnight"): await q7_api.set_do_not_disturb(True, begin_time, end_time) - assert len(fake_channel.published_messages) == 0 + assert len(fake_channel.published_commands) == 0 @pytest.mark.parametrize( @@ -243,112 +183,94 @@ async def test_q7_api_set_mode( mode: CleanTypeMapping, expected_code: int, q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): """Test setting cleaning mode (vacuum, mop, or both).""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.set_mode(mode) - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "prop.set" - assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.MODE: expected_code} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_PROP + assert params == {RoborockB01Props.MODE: expected_code} -async def test_q7_api_start_clean( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_start_clean(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test starting cleaning.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.start_clean() - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" - assert payload_data["dps"]["10000"]["params"] == { + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_ROOM_CLEAN + assert params == { "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.START.code, "room_ids": [], } -async def test_q7_api_pause_clean( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_pause_clean(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test pausing cleaning.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.pause_clean() - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" - assert payload_data["dps"]["10000"]["params"] == { + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_ROOM_CLEAN + assert params == { "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.PAUSE.code, "room_ids": [], } -async def test_q7_api_stop_clean( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_stop_clean(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test stopping cleaning.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.stop_clean() - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" - assert payload_data["dps"]["10000"]["params"] == { + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_ROOM_CLEAN + assert params == { "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.STOP.code, "room_ids": [], } -async def test_q7_api_return_to_dock( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_return_to_dock(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test returning to dock.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.return_to_dock() - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "service.start_recharge" - assert payload_data["dps"]["10000"]["params"] == {} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.START_RECHARGE + assert params == {} -async def test_q7_api_find_me(q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder): +async def test_q7_api_find_me(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test locating the device.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.find_me() - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "service.find_device" - assert payload_data["dps"]["10000"]["params"] == {} + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.FIND_DEVICE + assert params == {} -async def test_q7_api_clean_segments( - q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder -): +async def test_q7_api_clean_segments(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): """Test room/segment cleaning helper for Q7.""" - fake_channel.response_queue.append(message_builder.build({"result": "ok"})) + fake_channel.response_queue.append({"result": "ok"}) await q7_api.clean_segments([10, 11]) - assert len(fake_channel.published_messages) == 1 - message = fake_channel.published_messages[0] - payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" - assert payload_data["dps"]["10000"]["params"] == { + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.SET_ROOM_CLEAN + assert params == { "clean_type": CleanTaskTypeMapping.ROOM.code, "ctrl_value": SCDeviceCleanParam.START.code, "room_ids": [10, 11], diff --git a/tests/devices/traits/b01/q7/test_map.py b/tests/devices/traits/b01/q7/test_map.py index 04b6a7df1..e20115012 100644 --- a/tests/devices/traits/b01/q7/test_map.py +++ b/tests/devices/traits/b01/q7/test_map.py @@ -1,36 +1,17 @@ -from roborock.data import Q7MapList, Q7MapListEntry +from roborock.data.b01_q7.b01_q7_containers import Q7MapListEntry from roborock.devices.traits.b01.q7 import Q7PropertiesApi -from tests.fixtures.channel_fixtures import FakeChannel +from roborock.roborock_typing import RoborockB01Q7Methods -from . import B01MessageBuilder +from .conftest import FakeQ7Channel -async def test_q7_api_map_trait_refresh_populates_cached_values( - q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, -): - """Map trait follows refresh + cached-value access pattern.""" - fake_channel.response_queue.append(message_builder.build({"map_list": [{"id": 101, "cur": True}]})) - - assert q7_api.map.map_list == [] - assert q7_api.map.current_map_id is None - +async def test_q7_map_refresh(q7_api: Q7PropertiesApi, fake_channel: FakeQ7Channel): + """Test retrieving lists of saved maps.""" + fake_channel.response_queue.append({"map_list": [{"id": 111, "name": "Map 1"}]}) await q7_api.map.refresh() - assert len(fake_channel.published_messages) == 1 - assert q7_api.map.map_list[0].id == 101 - assert q7_api.map.map_list[0].cur is True - assert q7_api.map.current_map_id == 101 - - -def test_q7_map_list_current_map_id_prefers_marked_current(): - """Current-map resolution prefers the entry marked current.""" - map_list = Q7MapList( - map_list=[ - Q7MapListEntry(id=111, cur=False), - Q7MapListEntry(id=222, cur=True), - ] - ) - - assert map_list.current_map_id == 222 + assert len(fake_channel.published_commands) == 1 + command, params = fake_channel.published_commands[0] + assert command == RoborockB01Q7Methods.GET_MAP_LIST + assert params == {} + assert q7_api.map.map_list == [Q7MapListEntry(id=111)] diff --git a/tests/devices/traits/b01/q7/test_map_content.py b/tests/devices/traits/b01/q7/test_map_content.py index 7ec111120..b8753d778 100644 --- a/tests/devices/traits/b01/q7/test_map_content.py +++ b/tests/devices/traits/b01/q7/test_map_content.py @@ -1,28 +1,24 @@ -import json from unittest.mock import patch import pytest -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad from vacuum_map_parser_base.map_data import MapData from roborock.devices.traits.b01.q7 import Q7PropertiesApi from roborock.exceptions import RoborockException from roborock.map.b01_map_parser import ParsedMapData -from tests.fixtures.channel_fixtures import FakeChannel +from roborock.roborock_typing import RoborockB01Q7Methods -from . import B01MessageBuilder +from .conftest import FakeQ7Channel async def test_q7_map_content_refresh_populates_cached_values( q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): fake_channel.response_queue.extend( [ - message_builder.build({"map_list": [{"id": 1772093512, "cur": True}]}), - message_builder.build_map_response(b"raw-map-payload"), + {"map_list": [{"id": 1772093512, "cur": True}]}, + b"inflated-payload", ] ) @@ -34,16 +30,10 @@ async def test_q7_map_content_refresh_populates_cached_values( image_content=b"pngbytes", map_data=dummy_map_data, ) - with ( - patch( - "roborock.devices.rpc.b01_q7_channel.decode_map_payload", - return_value=b"inflated-payload", - ), - patch( - "roborock.devices.traits.b01.q7.map_content.B01MapParser.parse", - return_value=parsed_map_data, - ) as parse, - ): + with patch( + "roborock.devices.traits.b01.q7.map_content.B01MapParser.parse", + return_value=parsed_map_data, + ) as parse: await q7_api.map_content.refresh() assert q7_api.map_content.image_content == b"pngbytes" @@ -52,27 +42,24 @@ async def test_q7_map_content_refresh_populates_cached_values( parse.assert_called_once_with(b"inflated-payload") - assert len(fake_channel.published_messages) == 2 - first = fake_channel.published_messages[0] - first_payload = json.loads(unpad(first.payload, AES.block_size)) - assert first_payload["dps"]["10000"]["method"] == "service.get_map_list" + assert len(fake_channel.published_commands) == 2 + cmd, params = fake_channel.published_commands[0] + assert cmd == RoborockB01Q7Methods.GET_MAP_LIST - second = fake_channel.published_messages[1] - second_payload = json.loads(unpad(second.payload, AES.block_size)) - assert second_payload["dps"]["10000"]["method"] == "service.upload_by_mapid" - assert second_payload["dps"]["10000"]["params"] == {"map_id": 1772093512} + map_cmd, map_params = fake_channel.published_commands[1] + assert map_cmd == RoborockB01Q7Methods.UPLOAD_BY_MAPID + assert map_params == {"map_id": 1772093512} async def test_q7_map_content_refresh_falls_back_to_first_map( q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): """If no current map marker exists, first map in list is used.""" fake_channel.response_queue.extend( [ - message_builder.build({"map_list": [{"id": 111}, {"id": 222, "cur": False}]}), - message_builder.build_map_response(b"raw-map-payload"), + {"map_list": [{"id": 111}, {"id": 222, "cur": False}]}, + b"inflated-payload", ] ) @@ -80,30 +67,23 @@ async def test_q7_map_content_refresh_falls_back_to_first_map( await q7_api.map.refresh() dummy_map_data = MapData() - with ( - patch( - "roborock.devices.rpc.b01_q7_channel.decode_map_payload", - return_value=b"inflated-payload", - ), - patch( - "roborock.devices.traits.b01.q7.map_content.B01MapParser.parse", - return_value=type("X", (), {"image_content": b"pngbytes", "map_data": dummy_map_data})(), - ), + with patch( + "roborock.devices.traits.b01.q7.map_content.B01MapParser.parse", + return_value=type("X", (), {"image_content": b"pngbytes", "map_data": dummy_map_data})(), ): await q7_api.map_content.refresh() - second = fake_channel.published_messages[1] - second_payload = json.loads(unpad(second.payload, AES.block_size)) - assert second_payload["dps"]["10000"]["params"] == {"map_id": 111} + assert len(fake_channel.published_commands) == 2 + map_cmd, map_params = fake_channel.published_commands[1] + assert map_params == {"map_id": 111} async def test_q7_map_content_refresh_errors_without_map_list( q7_api: Q7PropertiesApi, - fake_channel: FakeChannel, - message_builder: B01MessageBuilder, + fake_channel: FakeQ7Channel, ): """Refresh should fail clearly when map list is unusable.""" - fake_channel.response_queue.append(message_builder.build({"map_list": []})) + fake_channel.response_queue.extend([{"map_list": []}]) with pytest.raises(RoborockException, match="Unable to determine current map ID"): await q7_api.map_content.refresh() From 32f9747b368c4d58a78691a0106a8c9676a07e2c Mon Sep 17 00:00:00 2001 From: "Andrew L. Yeats" Date: Sun, 5 Jul 2026 23:36:57 -0400 Subject: [PATCH 13/21] feat: decode Q10 (B01/ss07) dpFault codes with a YXFault enum (#863) * feat: decode Q10 (B01/ss07) dpFault codes into a YXFault map Add a Q10-specific YXFault enum (seeded from the ss07 app fault i18n) and an additive Q10Status.fault_name property. The raw fault int is untouched, so unknown/new codes are never lost. Addresses #855. * refactor: type Q10Status.fault as YXFault, mirroring the Q7 fault field Retype the fault field from a raw int to YXFault | None and make fault_name a str accessor, matching B01Props.fault / fault_name on the Q7. Update the enum provenance notes from live hardware confirmation (10 codes) and drop the unconfirmed 569 label. * docs: note fault 501 fires per completed task * docs: mark fault 5 hw-confirmed (physically ground-truthed brush jams) * docs: mark fault 12 hw-confirmed (fired at 14% battery, corpus recount) * docs: mark fault 407 hw-confirmed (deterministically reproduced: due schedule ignored mid-clean) * docs: mark fault 46 hw-confirmed (docked with dust bag removed) * feat: re-add fault 569 as too_frequent_emptying, hw-confirmed (dock refuses a too-soon empty) * docs: add 569 back to the docstring's Q7-divergence list * docs: mark fault 2 hw-confirmed (bumper held during commanded motion) * docs: note the error state can carry no fault code (observed live) * docs: refer to the ss07 fault spec, not the app (review feedback) --------- Co-authored-by: Andrew Yeats --- .../data/b01_q10/b01_q10_code_mappings.py | 64 +++++++++++++++++++ roborock/data/b01_q10/b01_q10_containers.py | 8 ++- .../b01_q10/test_b01_q10_code_mappings.py | 39 ++++++++++- tests/devices/traits/b01/q10/test_status.py | 3 +- 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index 78fc1b2c3..203df3f20 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -289,6 +289,70 @@ class YXCarpetCleanType(RoborockModeEnum): CROSS = "cross", 3 +class YXFault(RoborockModeEnum): + """Q10 (B01/ss07) ``dpFault`` (90) codes, from the ss07 fault spec. + + ``dpFault`` is *overloaded*: several values are lifecycle/status rather than + errors (e.g. 400 = scheduled clean starting, 501 = returning to dock, + 502 = recharge). A non-zero fault is not necessarily a blocking error. + The converse also holds: the device can sit in its error state (``dpStatus`` + 12) with ``dpFault`` still 0 (observed live with the dust-bin module + removed), so a ``None``/``NONE`` fault does not imply the absence of an + error condition. + + These labels differ from the Q7 ``B01Fault`` for several shared numbers + (500, 501, 503, 569, 570) -- so this is a Q10-specific map, not a reuse of + ``B01Fault``. Codes marked "hw-confirmed" were observed firing live on a + physical ss07 in a context matching the label; the rest are from the spec + only, not yet observed live (single device). + """ + + UNKNOWN = "unknown", -1 + NONE = "none", 0 + LIDAR_BLOCKED = "lidar_blocked", 1 + BUMPER_STUCK = "bumper_stuck", 2 # hw-confirmed (bumper held in during commanded motion) + ROBOT_SUSPENDED = "robot_suspended", 3 # hw-confirmed (wheels lifted off the floor mid-task) + CLIFF_SENSOR_ERROR = "cliff_sensor_error", 4 # hw-confirmed (one side lifted mid-task) + MAIN_BRUSH_STUCK = "main_brush_stuck", 5 # hw-confirmed (two physical brush jams) + MAIN_WHEELS_STUCK = "main_wheels_stuck", 7 + ROBOT_TRAPPED = "robot_trapped", 8 # hw-confirmed + CHECK_DUSTBIN_FILTER = "check_dustbin_filter", 9 + LOW_BATTERY = "low_battery", 12 # hw-confirmed (fired at 14% mid clean) + TEMPERATURE_THRESHOLD = "temperature_threshold", 14 + ROBOT_TILTED = "robot_tilted", 16 + LIDAR_COVER_OBSTRUCTED = "lidar_cover_obstructed", 21 + NO_GO_ZONE_DETECTED = "no_go_zone_detected", 24 + MOPPING_MODULE_STUCK = "mopping_module_stuck", 27 + CARPET_AVOIDANCE = "carpet_avoidance", 28 + CANNOT_CROSS_CARPET = "cannot_cross_carpet", 29 + INSTALL_DUST_BAG = "install_dust_bag", 46 # hw-confirmed (docked with bag removed; fires at dock contact) + MOP_MOUNT_FELL_OFF = "mop_mount_fell_off", 54 + LIDAR_DIRTY = "lidar_dirty", 58 + FILTER_SERVICE_LIFE = "filter_service_life", 301 + MAIN_BRUSH_SERVICE_LIFE = "main_brush_service_life", 302 + SIDE_BRUSH_SERVICE_LIFE = "side_brush_service_life", 303 + SENSOR_NEEDS_CLEANING = "sensor_needs_cleaning", 304 # hw-confirmed (fired during auto-empty) + DUST_BAG_FULL = "dust_bag_full", 310 # inferred from auto-empty context; not hw-confirmed + STARTING_SCHEDULED_CLEAN = "starting_scheduled_clean", 400 # hw-confirmed x3; lifecycle, not an error + # hw-confirmed (a due scheduled clean fired mid-clean and was ignored); lifecycle, not an error + CLEANING_IN_PROGRESS = "cleaning_in_progress", 407 + EMPTY_DUSTBIN = "empty_dustbin", 500 # ss07 != Q7 B01Fault (lidar_blocked); spec-only + # hw-confirmed, fires per completed task; ss07 != Q7 (robot_suspended) + CLEANING_COMPLETED_RETURNING = "cleaning_completed_returning", 501 + LOW_BATTERY_RESUME = "low_battery_resume", 502 # hw-confirmed; lifecycle + DOCKING_ERROR = "docking_error", 503 # hw-confirmed; ss07 != Q7 (dustbin_not_installed) + POSITIONING_FAILED = "positioning_failed", 556 # hw-confirmed; relocalization + # hw-confirmed (3rd auto-empty in ~15 min: dock refuses to run the cycle); ss07 != Q7 (main_wheels_entangled) + TOO_FREQUENT_EMPTYING = "too_frequent_emptying", 569 + CANNOT_REACH_TARGET = "cannot_reach_target", 570 # hw-confirmed; ss07 != Q7 (main_brush_entangled) + OFFLINE_WARNING_ASLEEP = "offline_warning_asleep", 588 + OFFLINE_WARNING_LOW_BATTERY = "offline_warning_low_battery", 589 + DND_AUTO_TOPUP_DISABLED = "dnd_auto_topup_disabled", 591 + CLEAN_CARPET_ULTRASONIC_SENSORS = "clean_carpet_ultrasonic_sensors", 707 + ROBOT_ERROR_RESET = "robot_error_reset", 1002 + VOICE_PACK_UPDATE_AVAILABLE = "voice_pack_update_available", 3001 + + class RemoteCommand(IntEnum): FORWARD = 0 LEFT = 2 diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 7b0351a69..2223cc862 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -23,6 +23,7 @@ YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXFault, YXStartMethod, YXWaterLevel, ) @@ -165,7 +166,7 @@ class Q10Status(RoborockBase): clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE}) back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE}) cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS}) - fault: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT}) + fault: YXFault | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT}) # Additional state reported in the device's full status dump. clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE}) @@ -203,6 +204,11 @@ class Q10Status(RoborockBase): filter_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FILTER_LIFE}) sensor_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SENSOR_LIFE}) + @property + def fault_name(self) -> str | None: + """Returns the name of the current fault.""" + return self.fault.value if self.fault is not None else None + @dataclass class SoundVolume(RoborockBase): diff --git a/tests/data/b01_q10/test_b01_q10_code_mappings.py b/tests/data/b01_q10/test_b01_q10_code_mappings.py index 63fdd14de..06789a23b 100644 --- a/tests/data/b01_q10/test_b01_q10_code_mappings.py +++ b/tests/data/b01_q10/test_b01_q10_code_mappings.py @@ -2,7 +2,7 @@ import pytest -from roborock.data.b01_q10 import YXDeviceState +from roborock.data.b01_q10 import Q10Status, YXDeviceState, YXFault @pytest.mark.parametrize( @@ -33,3 +33,40 @@ def test_q10_status_values_are_canonical(state: YXDeviceState, string: str) -> N def test_q10_status_codes_map_to_canonical_values(code: int, expected_state: YXDeviceState) -> None: """Code-based mapping should return canonical status values.""" assert YXDeviceState.from_code(code) is expected_state + + +@pytest.mark.parametrize( + "code, expected", + [ + (0, YXFault.NONE), + (503, YXFault.DOCKING_ERROR), # ss07 docking error, not the Q7 "dustbin_not_installed" + (570, YXFault.CANNOT_REACH_TARGET), # ss07 cannot-reach, not the Q7 "main_brush_entangled" + (556, YXFault.POSITIONING_FAILED), + ], +) +def test_q10_fault_codes_map_to_ss07_labels(code: int, expected: YXFault) -> None: + """dpFault codes should decode to the ss07-correct labels.""" + assert YXFault.from_code(code) is expected + + +def test_q10_status_fault_field_and_name() -> None: + """``fault`` is a typed ``YXFault`` field; ``fault_name`` mirrors the Q7 accessor.""" + status = Q10Status(fault=YXFault.CANNOT_REACH_TARGET) + assert status.fault is YXFault.CANNOT_REACH_TARGET + assert status.fault_name == "cannot_reach_target" + + assert Q10Status(fault=None).fault_name is None + + +def test_q10_status_fault_decodes_from_dict() -> None: + """A raw dpFault int decodes to the enum through the standard conversion path.""" + status = Q10Status.from_dict({"fault": 503}) + assert status is not None + assert status.fault is YXFault.DOCKING_ERROR + + # An unmapped code follows the library-wide enum-field behavior: + # the failed conversion is logged and the field is left None. + unmapped = Q10Status.from_dict({"fault": 99999}) + assert unmapped is not None + assert unmapped.fault is None + assert unmapped.fault_name is None diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 8105142a3..edd4bcb14 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -18,6 +18,7 @@ YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXFault, YXWaterLevel, ) from roborock.data.b01_q10.b01_q10_containers import dpNetInfo, dpNotDisturbExpand, dpTimeZone @@ -150,7 +151,7 @@ async def test_status_trait_refresh( assert q10_api.consumable.filter_life == 0 assert q10_api.consumable.sensor_life == 0 assert q10_api.status.cleaning_progress == 100 - assert q10_api.status.fault == 0 + assert q10_api.status.fault is YXFault.NONE assert q10_api.status.clean_mode == YXCleanType.VAC_AND_MOP assert q10_api.status.water_level == YXWaterLevel.LOW From 9f1a4e0e212e48773cfee98198e44b29000d32c8 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 6 Jul 2026 03:39:04 +0000 Subject: [PATCH 14/21] 5.26.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d81ba5238..052415193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,71 @@ +## v5.26.0 (2026-07-06) + +### Documentation + +- Add 569 back to the docstring's Q7-divergence list + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Mark fault 12 hw-confirmed (fired at 14% battery, corpus recount) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Mark fault 2 hw-confirmed (bumper held during commanded motion) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Mark fault 407 hw-confirmed (deterministically reproduced: due schedule ignored mid-clean) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Mark fault 46 hw-confirmed (docked with dust bag removed) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Mark fault 5 hw-confirmed (physically ground-truthed brush jams) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Note fault 501 fires per completed task + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Note the error state can carry no fault code (observed live) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Refer to the ss07 fault spec, not the app (review feedback) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +### Features + +- Decode Q10 (B01/ss07) dpFault codes into a YXFault map + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Decode Q10 (B01/ss07) dpFault codes with a YXFault enum + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +- Re-add fault 569 as too_frequent_emptying, hw-confirmed (dock refuses a too-soon empty) + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + +### Refactoring + +- Decouple B01 (Q7/Q10) protocol layer from transport layer + ([#859](https://github.com/Python-roborock/python-roborock/pull/859), + [`b00d60b`](https://github.com/Python-roborock/python-roborock/commit/b00d60b9f70d6a5da9089e470db60b04437c0b14)) + +- Type Q10Status.fault as YXFault, mirroring the Q7 fault field + ([#863](https://github.com/Python-roborock/python-roborock/pull/863), + [`32f9747`](https://github.com/Python-roborock/python-roborock/commit/32f9747b368c4d58a78691a0106a8c9676a07e2c)) + + ## v5.25.0 (2026-07-03) ### Chores diff --git a/pyproject.toml b/pyproject.toml index 4bd120268..7d74d1e9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.25.0" +version = "5.26.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 9078a748f..0f0793247 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.25.0" +version = "5.26.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From c963ad79b7d7206dd1b97cd7f377305a67f90364 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Jul 2026 17:07:11 -0700 Subject: [PATCH 15/21] =?UTF-8?q?Add=20roborock.testing=20=E2=80=94=20stat?= =?UTF-8?q?eful=20device=20simulators=20for=20integration=20testing=20(#86?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add roborock.testing module for stateful integration testing Introduces a new roborock.testing package that provides stateful firmware simulators, fake transport channels, and cloud environment fakes. This allows downstream consumers (like the Home Assistant integration) to write high-fidelity integration tests using the real client library classes instead of fragile top-level mocks. New modules: - channel.py: FakeChannel in-memory transport implementing Channel protocol - simulator.py: RoborockDeviceSimulator base class - v1_simulator.py: V1VacuumSimulator with stateful command handlers - cloud.py: FakeRoborockCloud with HTTP endpoint mocking Tests split by module: - test_channel.py: FakeChannel subscribe/publish/notify - test_cloud.py: Discovery, login errors, dynamic device addition - test_v1_simulator.py: Trait refresh/reset, state transitions, push updates * docs: add comment explaining dss field in v1_simulator * feat: expose in_cleaning, in_returning, charge_status, and dss on V1VacuumSimulator * feat: expose dock_type configuration in V1VacuumSimulator * feat: honor publish_side_effect in simulator and raise NotImplementedError in base _handle_publish * refactor: inherit FakeChannel from Channel Protocol * refactor: type simulator consumables state using Consumable dataclass * refactor: type simulator dnd, clean summary and record using real dataclasses * refactor: type simulator status, network info and init status using real dataclasses * refactor: expose raw status and app_init dataclasses on simulator for direct test manipulation * refactor: export and define all simulator state default constants at package level * refactor: update _handle_app_get_init_status to serialize self.app_init and dynamically resolve status_info fields from self.status * test: add E2E test case for multiple simulators with independent network info overrides * refactor: replace FakeUserState with library UserData dataclass on FakeRoborockCloud * refactor: decouple default product id from device duid and share DEFAULT_PRODUCT_ID constant * refactor: rename self.servers to self.simulated_devices and restore hardcoded qiCNieZa in mock_data * refactor: delete redundant properties from V1VacuumSimulator and update trigger_push_update --- roborock/testing/__init__.py | 101 ++++++++ roborock/testing/channel.py | 126 +++++++++ roborock/testing/cloud.py | 247 ++++++++++++++++++ roborock/testing/simulator.py | 110 ++++++++ roborock/testing/v1_simulator.py | 395 +++++++++++++++++++++++++++++ tests/fixtures/channel_fixtures.py | 62 +---- tests/testing/__init__.py | 0 tests/testing/test_channel.py | 32 +++ tests/testing/test_cloud.py | 106 ++++++++ tests/testing/test_v1_simulator.py | 264 +++++++++++++++++++ 10 files changed, 1383 insertions(+), 60 deletions(-) create mode 100644 roborock/testing/__init__.py create mode 100644 roborock/testing/channel.py create mode 100644 roborock/testing/cloud.py create mode 100644 roborock/testing/simulator.py create mode 100644 roborock/testing/v1_simulator.py create mode 100644 tests/testing/__init__.py create mode 100644 tests/testing/test_channel.py create mode 100644 tests/testing/test_cloud.py create mode 100644 tests/testing/test_v1_simulator.py diff --git a/roborock/testing/__init__.py b/roborock/testing/__init__.py new file mode 100644 index 000000000..4eed0b52b --- /dev/null +++ b/roborock/testing/__init__.py @@ -0,0 +1,101 @@ +"""Testing fakes and simulators for python-roborock. + +This package provides stateful firmware simulators (e.g. `V1VacuumSimulator`), +fake transport channels (`FakeChannel`), and cloud orchestration simulators (`FakeRoborockCloud`) +to allow downstream consumers (such as Home Assistant integrations) to write high-fidelity +integration tests using the real client library classes instead of fragile top-level mocks. + +Testing Architecture & Boundaries +--------------------------------- +We fake communication at two boundaries: +1. **Network HTTP API Interception**: `FakeRoborockCloud.patch_device_manager()` routes + HTTP requests (such as discovery, login, home details) to custom mock endpoints using + `aioresponses` under the hood. No Python client methods are mocked; the real EAPI client + executes fully. +2. **Plaintext RPC Message Interception**: Device communication is intercepted at the + plaintext JSON RPC level (Layer 2). The real client classes (`V1Channel`, `MqttChannel`) + run under test, but their transport calls are intercepted by our stateful simulators. + + ┌────────────────────────────────────────────────────────┐ + │ TESTED CLIENT (REAL CODE) │ + │ │ + │ RoborockDevice / Traits / V1RpcChannel / V1Channel │ + └──────────────────────────┬─────────────────────────────┘ + │ + ROBOROCKMESSAGE PAYLOADS + (Plaintext JSON commands) + │ + ┌──────────────────────────▼─────────────────────────────┐ + │ SIMULATOR (TEST FAKE) │ + │ │ + │ FakeChannel (Intercepts publish/subscribe) │ + │ RoborockDeviceSimulator (Stateful firmware simulator) │ + └────────────────────────────────────────────────────────┘ + +Integration Usage Example +------------------------- +```python +from roborock.testing import FakeRoborockCloud, V1VacuumSimulator + +async def test_start_vacuum_service(): + # Setup cloud state and add a simulated vacuum device + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="living_room_s7", battery=100, state=RoborockStateCode.charging) + cloud.add_device(fake_device) + + # Patch channels and API calls using our cloud context manager + with cloud.patch_device_manager(): + # Create the real client manager (logins and discovers natively via mock HTTP) + manager = await create_device_manager( + user_params=UserParams(username="test_user", user_data=USER_DATA), + cache=InMemoryCache(), + ) + + # Fetch the discovered device client + devices = await manager.get_devices() + device = devices[0] + + # Trigger client start command + await device.v1_properties.command.send("app_start") + + # Assert against the simulated vacuum state + assert fake_device.state == RoborockStateCode.cleaning +``` +""" + +from roborock.testing.channel import FakeChannel +from roborock.testing.cloud import FakeRoborockCloud, FakeWebApiClient +from roborock.testing.simulator import ( + DEFAULT_KEY_T, + DEFAULT_LOCAL_KEY, + DEFAULT_PRODUCT_ID, + RoborockDeviceSimulator, +) +from roborock.testing.v1_simulator import ( + DEFAULT_APP_INIT, + DEFAULT_CLEAN_SUMMARY, + DEFAULT_CONSUMABLE, + DEFAULT_DND_TIMER, + DEFAULT_LAST_CLEAN_RECORD, + DEFAULT_NETWORK_INFO, + DEFAULT_STATUS, + V1VacuumSimulator, +) + +__all__ = [ + "DEFAULT_APP_INIT", + "DEFAULT_CLEAN_SUMMARY", + "DEFAULT_CONSUMABLE", + "DEFAULT_DND_TIMER", + "DEFAULT_KEY_T", + "DEFAULT_LAST_CLEAN_RECORD", + "DEFAULT_LOCAL_KEY", + "DEFAULT_NETWORK_INFO", + "DEFAULT_PRODUCT_ID", + "DEFAULT_STATUS", + "FakeChannel", + "FakeRoborockCloud", + "FakeWebApiClient", + "RoborockDeviceSimulator", + "V1VacuumSimulator", +] diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py new file mode 100644 index 000000000..1550393bf --- /dev/null +++ b/roborock/testing/channel.py @@ -0,0 +1,126 @@ +"""Fake channel transport implementation for python-roborock. + +This module defines `FakeChannel`, which simulates low-level connection, +subscription, and publishing logic at the message boundary. It acts as an +in-memory replacement for `MqttChannel` and `LocalChannel` during testing. +""" + +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from roborock.devices.transport.channel import Channel +from roborock.mqtt.health_manager import HealthManager +from roborock.protocols.v1_protocol import LocalProtocolVersion +from roborock.roborock_message import RoborockMessage + + +class FakeChannel(Channel): + """A stateful, in-memory transport simulator implementing the Channel protocol. + + It captures all published messages in `published_messages`, maintains a registry + of active callbacks in `subscribers`, and enables tests or stateful simulators to + unconditionally push unsolicited messages using `notify_subscribers`. + + Caller API + ---------- + The public interface consists of `AsyncMock` / `MagicMock` attributes that + wrap internal implementations. Because they are mocks, callers can: + + - **Inspect calls**: ``channel.publish.assert_called_once()`` + - **Inject failures**: ``channel.publish.side_effect = RoborockException(...)`` + to simulate transport errors on the next publish. + - **Replace behavior**: ``channel.connect.side_effect = my_custom_connect`` + to substitute entirely custom logic. + - **Queue canned responses**: Append to ``channel.response_queue`` to have + the channel automatically deliver a response to subscribers on the next + publish (useful for low-level RPC request/response testing). + - **Push unsolicited messages**: Call ``channel.notify_subscribers(msg)`` + to simulate the device broadcasting a state change. + """ + + subscribe: Any + + def __init__(self, is_local: bool = False): + """Initialize the fake channel.""" + self.subscribers: list[Callable[[RoborockMessage], None]] = [] + self.published_messages: list[RoborockMessage] = [] + self.response_queue: list[RoborockMessage] = [] + self._is_connected = False + self._is_local = is_local + + # Set this to an exception instance to make the next publish raise it. + # This is a convenience shortcut; callers can also replace + # ``publish.side_effect`` directly for more control. + self.publish_side_effect: Exception | None = None + + # AsyncMock wrapping _publish. Callers can replace side_effect to + # inject transport errors, e.g.: + # channel.publish.side_effect = RoborockException("timeout") + self.publish = AsyncMock(side_effect=self._publish) + + # AsyncMock wrapping _subscribe. Callers can replace side_effect to + # simulate subscription failures, e.g.: + # channel.subscribe.side_effect = RoborockException("sub failed") + self.subscribe = AsyncMock(side_effect=self._subscribe) # type: ignore[assignment] + + # AsyncMock wrapping _connect. Callers can replace side_effect to + # simulate connection failures, e.g.: + # channel.connect.side_effect = RoborockException("refused") + self.connect = AsyncMock(side_effect=self._connect) + + # MagicMock wrapping _close. Callers can assert close was called + # or inject errors on teardown. + self.close = MagicMock(side_effect=self._close) + + self.protocol_version = LocalProtocolVersion.V1 + self.restart = AsyncMock() + self.health_manager = HealthManager(self.restart) + + async def _connect(self) -> None: + self._is_connected = True + + def _close(self) -> None: + self._is_connected = False + + @property + def is_connected(self) -> bool: + """Return true if connected.""" + return self._is_connected + + @property + def is_local_connected(self) -> bool: + """Return true if locally connected.""" + return self._is_connected and self._is_local + + async def _publish(self, message: RoborockMessage) -> None: + """Default publish implementation. + + Records the message in ``published_messages`` and, if + ``response_queue`` is non-empty, pops the first response and + delivers it to all current subscribers (simulating a + request/response round-trip). + """ + self.published_messages.append(message) + if self.publish_side_effect: + raise self.publish_side_effect + if self.response_queue: + response = self.response_queue.pop(0) + self.notify_subscribers(response) + + async def _subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: + """Default subscribe implementation. + + Registers the callback and returns an unsubscribe function. + """ + self.subscribers.append(callback) + return lambda: self.subscribers.remove(callback) + + def notify_subscribers(self, message: RoborockMessage) -> None: + """Deliver a message to all current subscribers. + + Use this to simulate the channel receiving an unsolicited message + from the device (e.g. a state change broadcast). + """ + for subscriber in list(self.subscribers): + subscriber(message) diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py new file mode 100644 index 000000000..704e0f548 --- /dev/null +++ b/roborock/testing/cloud.py @@ -0,0 +1,247 @@ +"""Cloud environment simulator for python-roborock testing. + +This module provides `FakeRoborockCloud` which acts as a central registry +for all simulated devices, dynamically faking HTTP endpoints via aioresponses +to simulate physical devices connected to the Roborock Cloud. +""" + +import contextlib +import re +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioresponses import CallbackResult, aioresponses + +from roborock.data import HomeData, Reference, RRiot, UserData +from roborock.devices.rpc.v1_channel import create_v1_channel as original_create_v1_channel +from roborock.devices.transport.mqtt_channel import create_mqtt_channel as original_create_mqtt_channel +from roborock.testing.simulator import RoborockDeviceSimulator +from roborock.testing.v1_simulator import V1VacuumSimulator + +# EAPI Base URL pattern constants +IOT_API_BASE_URL = r"https://.*iot\.roborock\.com/api/v1" +REST_API_BASE_URL = r"https://api-.*\.roborock\.com" + +DEFAULT_USER_DATA = UserData( + uid=123456, + tokentype="token_type", + token="abc123", + rruid="abc123", + region="us", + countrycode="1", + country="US", + nickname="user_nickname", + rriot=RRiot( + u="user123", + s="pass123", + h="unknown123", + k="qiCNieZa", + r=Reference( + r="US", + a="https://api-us.roborock.com", + l="https://wood-us.roborock.com", + m="tcp://mqtt-us.roborock.com:8883", + ), + ), +) + + +class FakeWebApiClient: + """Fakes the EAPI at the HTTP network boundary using aioresponses. + + Exposes attributes that allow test suites (like Home Assistant) to easily + override response payloads, status codes, and simulate API errors. + """ + + def __init__(self, cloud: "FakeRoborockCloud"): + self.cloud = cloud + self.url_by_email_status = 200 + self.url_by_email_payload: dict[str, Any] | None = None # Synthesized if None + self.login_status = 200 + self.login_payload: dict[str, Any] | None = None # Synthesized if None + self.home_detail_status = 200 + self.home_detail_payload: dict[str, Any] | None = None # Synthesized if None + self.homes_status = 200 + self.homes_payload_override: dict[str, Any] | None = None + + def get_url_by_email_payload(self) -> dict[str, Any]: + """Synthesize getUrlByEmail payload.""" + if self.url_by_email_payload is not None: + return self.url_by_email_payload + return { + "code": 200, + "data": { + "country": self.cloud.user_data.country, + "countrycode": self.cloud.user_data.countrycode, + "url": f"https://{self.cloud.user_data.region}iot.roborock.com", + }, + "msg": "success", + } + + def get_login_payload(self) -> dict[str, Any]: + """Synthesize login payload using the cloud user profile state.""" + if self.login_payload is not None: + return self.login_payload + return { + "code": 200, + "data": self.cloud.user_data.as_dict(), + "msg": "success", + } + + def get_home_detail_payload(self) -> dict[str, Any]: + """Synthesize getHomeDetail payload using the cloud home state.""" + if self.home_detail_payload is not None: + return self.home_detail_payload + return { + "code": 200, + "data": { + "deviceListOrder": None, + "id": self.cloud.home_id, + "name": self.cloud.home_name, + "rrHomeId": self.cloud.home_id, + "tuyaHomeId": 0, + }, + "msg": "success", + } + + def mock_requests(self, mocked: aioresponses) -> None: + """Register EAPI endpoint mocks with aioresponses.""" + # getUrlByEmail Endpoint Mocking + mocked.post( + re.compile(rf"{IOT_API_BASE_URL}/getUrlByEmail.*"), + status=self.url_by_email_status, + payload=self.get_url_by_email_payload(), + ) + + # User Logins Endpoint Mocking + mocked.post( + re.compile(rf"{IOT_API_BASE_URL}/login.*"), + status=self.login_status, + payload=self.get_login_payload(), + ) + mocked.post( + re.compile(rf"{IOT_API_BASE_URL}/loginWithCode.*"), + status=self.login_status, + payload=self.get_login_payload(), + ) + + # getHomeDetail Endpoint Mocking + mocked.get( + re.compile(rf"{IOT_API_BASE_URL}/getHomeDetail.*"), + status=self.home_detail_status, + payload=self.get_home_detail_payload(), + ) + + # Dynamic homes response callback wrapper + def get_homes_callback(url, **kwargs): + if self.homes_status != 200 or self.homes_payload_override is not None: + return CallbackResult( + status=self.homes_status, + payload=self.homes_payload_override, + ) + + devices = [] + products = [] + for server in self.cloud.simulated_devices.values(): + devices.append(server.device_info) + products.append(server.product) + + home_data = HomeData( + id=self.cloud.home_id, + name=self.cloud.home_name, + devices=devices, + products=products, + ) + return CallbackResult( + status=200, + payload={ + "api": None, + "code": 200, + "result": home_data.as_dict(), + "status": "ok", + "success": True, + }, + ) + + # getHomeDetail v2 & v3 callbacks routing + mocked.get( + re.compile(rf"{REST_API_BASE_URL}/v2/user/homes/{self.cloud.home_id}"), + callback=get_homes_callback, + ) + mocked.get( + re.compile(rf"{REST_API_BASE_URL}/v3/user/homes/{self.cloud.home_id}"), + callback=get_homes_callback, + ) + + +class FakeRoborockCloud: + """A central state object representing the Roborock Cloud environment under test.""" + + def __init__( + self, + user_data: UserData | None = None, + home_id: int = 123456, + home_name: str = "Fake Home", + ) -> None: + self.simulated_devices: dict[str, RoborockDeviceSimulator] = {} + self.user_data = user_data or DEFAULT_USER_DATA + self.home_id = home_id + self.home_name = home_name + self.web_api = FakeWebApiClient(self) + + def add_device(self, server: RoborockDeviceSimulator) -> None: + """Register a stateful device simulator in the cloud registry.""" + self.simulated_devices[server.duid] = server + + @contextlib.contextmanager + def patch_device_manager(self): + """Context manager to patch create_v1_channel and create_mqtt_channel. + + This automatically routes communications to the registered device simulators + and intercepts HTTP calls at the network boundary using aioresponses. + """ + + # Wrapper function for create_v1_channel + def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache): + if device.pv in ("A01", "B01"): + raise NotImplementedError( + f"Simulating protocol {device.pv} is not yet supported. " + "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + ) + server = self.simulated_devices.get(device.duid) + if server is not None: + if not isinstance(server, V1VacuumSimulator): + raise TypeError( + f"Device '{device.duid}' is registered with a {type(server).__name__} " + f"simulator, but create_v1_channel requires a V1VacuumSimulator." + ) + return server.v1_channel + return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache) + + # Wrapper function for create_mqtt_channel + def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): + if device.pv in ("A01", "B01"): + raise NotImplementedError( + f"Simulating protocol {device.pv} is not yet supported. " + "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + ) + server = self.simulated_devices.get(device.duid) + if server: + return server.mqtt_channel + return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) + + # Route Web requests using the dynamic FakeWebApiClient + with aioresponses() as mocked: + self.web_api.mock_requests(mocked) + + # Patch Channel factories and rate limiters + with ( + patch( + "roborock.web_api.RoborockApiClient._login_limiter.try_acquire_async", + new=AsyncMock(return_value=True), + ), + patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire", return_value=True), + patch("roborock.devices.device_manager.create_v1_channel", side_effect=mock_create_v1_channel), + patch("roborock.devices.device_manager.create_mqtt_channel", side_effect=mock_create_mqtt_channel), + ): + yield diff --git a/roborock/testing/simulator.py b/roborock/testing/simulator.py new file mode 100644 index 000000000..caebc0009 --- /dev/null +++ b/roborock/testing/simulator.py @@ -0,0 +1,110 @@ +"""Base stateful device firmware simulator for python-roborock testing. + +This module defines `RoborockDeviceSimulator` which intercepts plaintext JSON RPC messages +sent over simulated channels, process them through a local state engine, update internal +variables, and write responses back to client subscribers. +""" + +import logging + +from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory +from roborock.roborock_message import RoborockMessage +from roborock.testing.channel import FakeChannel + +_LOGGER = logging.getLogger(__name__) + +# Shared authentication key constants +DEFAULT_LOCAL_KEY = "fake_localkey_16bytes" +DEFAULT_KEY_T = "qiCNieZa" +DEFAULT_PRODUCT_ID = "product-id-123" + + +class RoborockDeviceSimulator: + """Base class for stateful device firmware simulators. + + It sets up an MQTT fake transport channel (and optionally a local channel), + intercepts published requests, and routes them to `_handle_publish` to + simulate real device response. + + Not all protocols support local connections. V1 devices use both MQTT and + local channels, while A01/B01 devices use MQTT only. Subclasses that need + a local channel should set ``has_local_channel=True`` (the default for + backward compatibility with V1 simulators). + + Caller API + ---------- + Subclasses (like ``RoborockVacuumSimulator``) provide the high-level + interface (state attributes, ``trigger_push_update()``, etc.), but callers + can also reach into the underlying channels for low-level inspection: + + - **Inspect published messages**: ``simulator.mqtt_channel.published_messages`` + (and ``simulator.local_channel.published_messages`` for V1) contain every + ``RoborockMessage`` that the client sent through each transport. + - **Inject transport failures**: Set + ``simulator.mqtt_channel.publish_side_effect = RoborockException(...)`` + to make the next publish raise, simulating a network error. + - **Modify device identity**: Override ``simulator.device_info`` or + ``simulator.product`` before registering with ``FakeRoborockCloud`` to + control the device metadata returned during discovery. + """ + + def __init__( + self, + duid: str = "fake_duid", + device_info: HomeDataDevice | None = None, + product: HomeDataProduct | None = None, + has_local_channel: bool = True, + ): + self.duid = duid + self.product = product or HomeDataProduct( + id=DEFAULT_PRODUCT_ID, + name="Roborock Vacuum", + model="roborock.vacuum.s7", + category=RoborockCategory.VACUUM, + ) + self.device_info = device_info or HomeDataDevice( + duid=self.duid, + name=f"Vacuum {self.duid}", + local_key=DEFAULT_LOCAL_KEY, + product_id=self.product.id, + sn="fake_serial_number", + pv="1.0", + ) + + # MQTT channel is always present — all protocols use it. + self.mqtt_channel = FakeChannel(is_local=False) + self.mqtt_channel.publish.side_effect = self._handle_mqtt_publish + + # Local channel is only used by V1 devices. A01/B01 (MQTT-only) + # simulators should pass has_local_channel=False. + self.local_channel: FakeChannel | None = None + if has_local_channel: + self.local_channel = FakeChannel(is_local=True) + self.local_channel.publish.side_effect = self._handle_local_publish + + async def _handle_local_publish(self, message: RoborockMessage) -> None: + assert self.local_channel is not None + self.local_channel.published_messages.append(message) + if self.local_channel.publish_side_effect: + raise self.local_channel.publish_side_effect + await self._handle_publish(message, self.local_channel) + + async def _handle_mqtt_publish(self, message: RoborockMessage) -> None: + self.mqtt_channel.published_messages.append(message) + if self.mqtt_channel.publish_side_effect: + raise self.mqtt_channel.publish_side_effect + await self._handle_publish(message, self.mqtt_channel) + + async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None: + """To be overridden by subclasses to route commands.""" + raise NotImplementedError("Subclasses must implement _handle_publish") + + def connect(self) -> None: + if self.local_channel is not None: + self.local_channel._is_connected = True + self.mqtt_channel._is_connected = True + + def close(self) -> None: + if self.local_channel is not None: + self.local_channel._is_connected = False + self.mqtt_channel._is_connected = False diff --git a/roborock/testing/v1_simulator.py b/roborock/testing/v1_simulator.py new file mode 100644 index 000000000..686d37843 --- /dev/null +++ b/roborock/testing/v1_simulator.py @@ -0,0 +1,395 @@ +"""Stateful V1/L01 vacuum device firmware simulator. + +This module provides `V1VacuumSimulator` which simulates the firmware state +machine and JSON RPC commands for V1 vacuum cleaners. +""" + +import json +import logging +import time +from collections.abc import Callable +from dataclasses import asdict, replace +from enum import Enum +from typing import Any +from unittest.mock import Mock + +from roborock.data import HomeDataDevice, HomeDataProduct +from roborock.data.v1 import RoborockStateCode +from roborock.data.v1.v1_code_mappings import ( + RoborockChargeStatus, + RoborockCleanType, + RoborockDockErrorCode, + RoborockDockTypeCode, + RoborockErrorCode, + RoborockFinishReason, + RoborockInCleaning, + RoborockStartType, +) +from roborock.data.v1.v1_containers import ( + AppInitStatus, + AppInitStatusLocalInfo, + CleanRecord, + CleanSummary, + Consumable, + DnDTimer, + NetworkInfo, + StatusV2, +) +from roborock.devices.cache import DeviceCache, InMemoryCache +from roborock.devices.rpc.v1_channel import V1Channel +from roborock.protocols.v1_protocol import SecurityData +from roborock.roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol +from roborock.testing.channel import FakeChannel +from roborock.testing.simulator import RoborockDeviceSimulator + +_LOGGER = logging.getLogger(__name__) + + +def _serialize_dataclass(obj: Any) -> dict[str, Any]: + """Helper to convert dataclass instances to dictionaries with serialized enums and filtered Nones.""" + return {k: (v.value if isinstance(v, Enum) else v) for k, v in asdict(obj).items() if v is not None} + + +DEFAULT_STATUS = StatusV2( + msg_ver=2, + msg_seq=458, + state=RoborockStateCode.charging, + battery=100, + clean_time=1176, + clean_area=20965000, + error_code=RoborockErrorCode(0), + map_present=1, + in_cleaning=RoborockInCleaning.complete, + in_returning=0, + in_fresh_state=1, + lab_status=1, + water_box_status=1, + back_type=-1, + wash_phase=0, + wash_ready=0, + fan_power=102, + dnd_enabled=0, + map_status=3, + is_locating=0, + lock_status=0, + water_box_mode=200, + water_box_carriage_status=1, + mop_forbidden_enable=1, + camera_status=3457, + is_exploring=0, + home_sec_status=0, + home_sec_enable_password=0, + adbumper_status=[0, 0, 0], + water_shortage_status=0, + dock_type=RoborockDockTypeCode.s8_dock, + dust_collection_status=0, + auto_dust_collection=1, + avoid_count=19, + mop_mode=300, + debug_mode=0, + collision_avoid_status=1, + switch_map_mode=0, + dock_error_status=RoborockDockErrorCode(0), + charge_status=RoborockChargeStatus.charge_waiting, + unsave_map_reason=0, + unsave_map_flag=0, + dss=169, +) + +DEFAULT_APP_INIT = AppInitStatus( + local_info=AppInitStatusLocalInfo( + location="us", + bom="A.03.0069", + featureset=1, + language="en", + logserver="awsusor0.fds.api.xiaomi.com", + wifiplan="0x39", + timezone="US/Pacific", + name="custom_A.03.0069_FCC", + ), + feature_info=[111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125], + new_feature_info=633887780925447, + new_feature_info_str="0000000000002000", + new_feature_info_2=8192, +) + +DEFAULT_NETWORK_INFO = NetworkInfo( + ip="1.1.1.1", + ssid="test_wifi", + mac="aa:bb:cc:dd:ee:ff", + bssid="aa:bb:cc:dd:ee:ff", + rssi=-50, +) + +DEFAULT_CONSUMABLE = Consumable( + main_brush_work_time=74382, + side_brush_work_time=74383, + filter_work_time=74384, + filter_element_work_time=0, + sensor_dirty_time=74385, + strainer_work_times=65, + dust_collection_work_times=25, + cleaning_brush_work_times=66, +) + +DEFAULT_DND_TIMER = DnDTimer( + start_hour=22, + start_minute=0, + end_hour=7, + end_minute=0, + enabled=1, +) + +DEFAULT_CLEAN_SUMMARY = CleanSummary( + clean_time=74382, + clean_area=1159182500, + clean_count=31, + dust_collection_count=25, + records=[1672543330, 1672458041], +) + +DEFAULT_LAST_CLEAN_RECORD = CleanRecord( + begin=1672543330, + end=1672544638, + duration=1176, + area=20965000, + error=0, + complete=1, + start_type=RoborockStartType.app, + clean_type=RoborockCleanType.select_zone, + finish_reason=RoborockFinishReason.finished_cleaning_4, + dust_collection_status=1, + avoid_count=19, + wash_count=2, + map_flag=0, +) + + +class V1VacuumSimulator(RoborockDeviceSimulator): + """Firmware simulator for a V1/L01 vacuum device. + + This class holds the simulated physical hardware state (such as battery levels, + cleaning state, fan speeds, and consumable wear). When it receives JSON RPC + commands (like `app_start` or `get_consumable`), it updates these state variables + and returns a response corresponding to the expected firmware behavior. + + Default command handlers are mapped in `self.default_handlers` and can be + overridden during initialization by passing `custom_handlers`. + """ + + def __init__( + self, + duid: str = "fake_duid", + status: StatusV2 | None = None, + app_init: AppInitStatus | None = None, + network_info: NetworkInfo | None = None, + consumables: Consumable | None = None, + dnd_timer: DnDTimer | None = None, + clean_summary: CleanSummary | None = None, + last_clean_record: CleanRecord | None = None, + custom_handlers: dict[str, Callable[[list[Any]], Any]] | None = None, + device_info: HomeDataDevice | None = None, + product: HomeDataProduct | None = None, + ): + super().__init__(duid=duid, device_info=device_info, product=product) + self.status = status or replace(DEFAULT_STATUS) + self.app_init = app_init or replace(DEFAULT_APP_INIT) + if app_init is None: + self.app_init.local_info = replace(DEFAULT_APP_INIT.local_info) + self.network_info = network_info or replace(DEFAULT_NETWORK_INFO) + self.consumables = consumables or replace(DEFAULT_CONSUMABLE) + self.dnd_timer = dnd_timer or replace(DEFAULT_DND_TIMER) + self.clean_summary = clean_summary or replace(DEFAULT_CLEAN_SUMMARY) + self.last_clean_record = last_clean_record or replace(DEFAULT_LAST_CLEAN_RECORD) + self.custom_handlers = custom_handlers or {} + + # Set up default handlers dictionary + self.default_handlers: dict[str, Callable[[Any], Any]] = { + "get_status": lambda params: [self.get_status_dict()], + "get_consumable": lambda params: [_serialize_dataclass(self.consumables)], + "get_dnd_timer": lambda params: _serialize_dataclass(self.dnd_timer), + "get_clean_summary": lambda params: _serialize_dataclass(self.clean_summary), + "get_clean_record": lambda params: _serialize_dataclass(self.last_clean_record), + "app_start": self._handle_app_start, + "app_stop": self._handle_app_stop, + "app_charge": self._handle_app_charge, + "set_custom_mode": self._handle_set_custom_mode, + "set_mop_mode": self._handle_set_mop_mode, + "set_water_box_custom_mode": self._handle_set_water_box_custom_mode, + "reset_consumable": self._handle_reset_consumable, + "app_get_init_status": self._handle_app_get_init_status, + "get_network_info": self._handle_get_network_info, + } + + self.device_cache = DeviceCache(self.duid, InMemoryCache()) + self.security_data = SecurityData(endpoint="fake_endpoint", nonce=b"fake_nonce_16bytes") + local_session = Mock(return_value=self.local_channel) + + self._v1_channel = V1Channel( + device_uid=self.duid, + security_data=self.security_data, + mqtt_channel=self.mqtt_channel, # type: ignore[arg-type] + local_session=local_session, + device_cache=self.device_cache, + ) + + @property + def v1_channel(self) -> V1Channel: + """Returns the real V1Channel bound to the fake channels.""" + return self._v1_channel + + @property + def in_cleaning(self) -> RoborockInCleaning: + """Return global_clean_not_complete if cleaning, else complete.""" + return ( + RoborockInCleaning.global_clean_not_complete + if self.status.state == RoborockStateCode.cleaning + else RoborockInCleaning.complete + ) + + @property + def in_returning(self) -> int: + """Return 1 if returning, else 0.""" + return 1 if self.status.state == RoborockStateCode.returning_home else 0 + + @property + def charge_status(self) -> RoborockChargeStatus: + """Return charging if charging, else charge_waiting.""" + return ( + RoborockChargeStatus.charging + if self.status.state == RoborockStateCode.charging + else RoborockChargeStatus.charge_waiting + ) + + def get_status_dict(self) -> dict[str, Any]: + """Generate status dict using the current simulated state.""" + self.status.in_cleaning = self.in_cleaning + self.status.in_returning = self.in_returning + self.status.charge_status = self.charge_status + return _serialize_dataclass(self.status) + + def _handle_app_start(self, params: Any) -> str: + self.status.state = RoborockStateCode.cleaning + return "ok" + + def _handle_app_stop(self, params: Any) -> str: + self.status.state = RoborockStateCode.paused + return "ok" + + def _handle_app_charge(self, params: Any) -> str: + self.status.state = RoborockStateCode.returning_home + return "ok" + + def _handle_set_custom_mode(self, params: Any) -> str: + if isinstance(params, list) and len(params) > 0: + self.status.fan_power = params[0] + elif isinstance(params, dict): + self.status.fan_power = params.get("fan_power", self.status.fan_power) + return "ok" + + def _handle_set_mop_mode(self, params: Any) -> str: + if isinstance(params, list) and len(params) > 0: + self.status.mop_mode = params[0] + return "ok" + + def _handle_set_water_box_custom_mode(self, params: Any) -> str: + if isinstance(params, list) and len(params) > 0: + self.status.water_box_mode = params[0] + return "ok" + + def _handle_reset_consumable(self, params: Any) -> str: + if isinstance(params, list) and len(params) > 0: + consumable_name = params[0] + if hasattr(self.consumables, consumable_name): + setattr(self.consumables, consumable_name, 0) + return "ok" + + def _handle_app_get_init_status(self, params: Any) -> list[dict[str, Any]]: + payload = _serialize_dataclass(self.app_init) + if "new_feature_info_2" in payload: + payload["new_feature_info2"] = payload.pop("new_feature_info_2") + + payload["status_info"] = { + "state": self.status.state.value if self.status.state else 0, + "battery": self.status.battery, + "clean_time": self.status.clean_time or 5610, + "clean_area": self.status.clean_area or 96490000, + "error_code": self.status.error_code.value if self.status.error_code else 0, + "in_cleaning": self.in_cleaning.value, + "in_returning": self.in_returning, + "in_fresh_state": self.status.in_fresh_state or 1, + "lab_status": self.status.lab_status or 1, + "water_box_status": self.status.water_box_status or 0, + "map_status": self.status.map_status or 3, + "is_locating": self.status.is_locating or 0, + "lock_status": self.status.lock_status or 0, + "water_box_mode": self.status.water_box_mode, + "distance_off": self.status.distance_off or 0, + "water_box_carriage_status": self.status.water_box_carriage_status or 0, + "mop_forbidden_enable": self.status.mop_forbidden_enable or 0, + } + return [payload] + + def _handle_get_network_info(self, params: Any) -> dict[str, Any]: + return _serialize_dataclass(self.network_info) + + async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None: + if not message.payload: + return + + try: + payload = json.loads(message.payload.decode()) + dps = payload.get("dps", {}) + if "101" not in dps: + return + inner = json.loads(dps["101"]) + msg_id = inner["id"] + method = inner["method"] + params = inner.get("params", []) + except Exception as e: + _LOGGER.debug("Failed to parse plaintext JSON RPC payload: %s", e, exc_info=True) + return + + result = None + error = None + + # Check custom handlers override first, then fall back to default handlers + handler = self.custom_handlers.get(method) or self.default_handlers.get(method) + if handler: + try: + result = handler(params) + except Exception as e: + error = str(e) + _LOGGER.debug("Error executing command handler for %s: %s", method, e, exc_info=True) + else: + result = "ok" + + response_data = { + "dps": {"102": json.dumps({"id": msg_id, "result": result, "error": error})}, + "t": int(time.time()), + } + + response_msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps(response_data).encode(), seq=msg_id + ) + + channel.notify_subscribers(response_msg) + + def trigger_push_update(self) -> None: + """Trigger an unsolicited push state update to all subscribers.""" + dps_payload = { + str(int(RoborockDataProtocol.STATE)): self.status.state.value if self.status.state else 0, + str(int(RoborockDataProtocol.BATTERY)): self.status.battery, + str(int(RoborockDataProtocol.FAN_POWER)): self.status.fan_power, + str(int(RoborockDataProtocol.WATER_BOX_MODE)): self.status.water_box_mode, + } + + payload = {"dps": dps_payload, "t": int(time.time())} + + push_msg = RoborockMessage( + protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=json.dumps(payload).encode() + ) + + self.mqtt_channel.notify_subscribers(push_msg) + if self.local_channel is not None: + self.local_channel.notify_subscribers(push_msg) diff --git a/tests/fixtures/channel_fixtures.py b/tests/fixtures/channel_fixtures.py index 90ace9faa..83e5b9e62 100644 --- a/tests/fixtures/channel_fixtures.py +++ b/tests/fixtures/channel_fixtures.py @@ -1,61 +1,3 @@ -from collections.abc import Callable -from unittest.mock import AsyncMock, MagicMock +from roborock.testing import FakeChannel -from roborock.mqtt.health_manager import HealthManager -from roborock.protocols.v1_protocol import LocalProtocolVersion -from roborock.roborock_message import RoborockMessage - - -class FakeChannel: - """A fake channel that handles publish and subscribe calls.""" - - def __init__(self): - """Initialize the fake channel.""" - self.subscribers: list[Callable[[RoborockMessage], None]] = [] - self.published_messages: list[RoborockMessage] = [] - self.response_queue: list[RoborockMessage] = [] - self._is_connected = False - self.publish_side_effect: Exception | None = None - self.publish = AsyncMock(side_effect=self._publish) - self.subscribe = AsyncMock(side_effect=self._subscribe) - self.connect = AsyncMock(side_effect=self._connect) - self.close = MagicMock(side_effect=self._close) - self.protocol_version = LocalProtocolVersion.V1 - self.restart = AsyncMock() - self.health_manager = HealthManager(self.restart) - - async def _connect(self) -> None: - self._is_connected = True - - def _close(self) -> None: - self._is_connected = False - - @property - def is_connected(self) -> bool: - """Return true if connected.""" - return self._is_connected - - async def _publish(self, message: RoborockMessage) -> None: - """Simulate publishing a message and triggering a response.""" - self.published_messages.append(message) - if self.publish_side_effect: - raise self.publish_side_effect - # When a message is published, simulate a response - if self.response_queue: - response = self.response_queue.pop(0) - # Give a chance for the subscriber to be registered - for subscriber in list(self.subscribers): - subscriber(response) - - async def _subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: - """Simulate subscribing to messages.""" - self.subscribers.append(callback) - return lambda: self.subscribers.remove(callback) - - def notify_subscribers(self, message: RoborockMessage) -> None: - """Notify subscribers of a message. - - This can be used by tests to simulate the channel receiving a message. - """ - for subscriber in list(self.subscribers): - subscriber(message) +__all__ = ["FakeChannel"] diff --git a/tests/testing/__init__.py b/tests/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/testing/test_channel.py b/tests/testing/test_channel.py new file mode 100644 index 000000000..30a0877dc --- /dev/null +++ b/tests/testing/test_channel.py @@ -0,0 +1,32 @@ +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from roborock.testing import FakeChannel + + +async def test_fake_channel_direct(): + """Verify raw subscription, notification, and publish capturing on FakeChannel.""" + channel = FakeChannel() + messages = [] + + def sub(msg): + messages.append(msg) + + # Trigger message before subscription + channel.notify_subscribers(RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=b"pre-sub")) + assert len(messages) == 0 + + # Subscribe and notify + unsub = await channel.subscribe(sub) + channel.notify_subscribers(RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=b"subbed")) + assert len(messages) == 1 + assert messages[0].payload == b"subbed" + + # Unsubscribe and notify + unsub() + channel.notify_subscribers(RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=b"post-sub")) + assert len(messages) == 1 + + # Test publish logs + test_msg = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=b"sent") + await channel.publish(test_msg) + assert len(channel.published_messages) == 1 + assert channel.published_messages[0] == test_msg diff --git a/tests/testing/test_cloud.py b/tests/testing/test_cloud.py new file mode 100644 index 000000000..2615c94e0 --- /dev/null +++ b/tests/testing/test_cloud.py @@ -0,0 +1,106 @@ +from dataclasses import replace + +import pytest + +from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory, UserData +from roborock.data.v1 import RoborockStateCode +from roborock.devices.cache import InMemoryCache +from roborock.devices.device_manager import UserParams, create_device_manager +from roborock.exceptions import RoborockException +from roborock.testing import DEFAULT_STATUS, FakeRoborockCloud, V1VacuumSimulator +from roborock.web_api import RoborockApiClient +from tests import mock_data + +USER_DATA = UserData.from_dict(mock_data.USER_DATA) + + +async def test_fake_roborock_cloud(): + """Verify that FakeRoborockCloud can discover devices via fake HTTP requests and connect them.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator( + duid="living_room_s7", + status=replace(DEFAULT_STATUS, battery=99, state=RoborockStateCode.charging), + ) + cloud.add_device(fake_device) + + with cloud.patch_device_manager(): + manager = await create_device_manager( + user_params=UserParams(username="test_user", user_data=USER_DATA), + cache=InMemoryCache(), + ) + devices = await manager.get_devices() + + assert len(devices) == 1 + device = devices[0] + assert device.duid == "living_room_s7" + assert device.is_connected + + assert device.v1_properties is not None + await device.v1_properties.status.refresh() + assert device.v1_properties.status.battery == 99 + + +async def test_cloud_unsupported_protocol(): + """Verify that FakeRoborockCloud raises NotImplementedError for A01 or B01 devices.""" + cloud = FakeRoborockCloud() + fake_b01_server = V1VacuumSimulator( + duid="b01_vacuum", + product=HomeDataProduct( + id="product_b01", + name="Q7 Vacuum", + model="roborock.vacuum.sc", + category=RoborockCategory.VACUUM, + ), + device_info=HomeDataDevice( + duid="b01_vacuum", + name="Q7 Vacuum", + local_key="fake_localkey_16bytes", + product_id="product_b01", + pv="B01", + ), + ) + cloud.add_device(fake_b01_server) + + with cloud.patch_device_manager(): + with pytest.raises(NotImplementedError, match="Simulating protocol B01 is not yet supported"): + await create_device_manager( + user_params=UserParams(username="test_user", user_data=USER_DATA), + cache=InMemoryCache(), + ) + + +async def test_cloud_login_error_override(): + """Verify that we can override login status and payloads to test authentication failure handling.""" + cloud = FakeRoborockCloud() + cloud.web_api.login_status = 401 + cloud.web_api.login_payload = {"code": 1002, "msg": "Invalid credentials"} + + with cloud.patch_device_manager(): + client = RoborockApiClient(username="test_user@gmail.com") + with pytest.raises(RoborockException, match="Invalid credentials - response code: 1002"): + await client.pass_login("wrong_password") + + +async def test_cloud_dynamic_device_addition(): + """Verify that adding a device dynamically after patching works due to the callback API.""" + cloud = FakeRoborockCloud() + + with cloud.patch_device_manager(): + fake_device = V1VacuumSimulator( + duid="dynamic_s7", + status=replace(DEFAULT_STATUS, battery=42), + ) + cloud.add_device(fake_device) + + manager = await create_device_manager( + user_params=UserParams(username="test_user", user_data=USER_DATA), + cache=InMemoryCache(), + ) + devices = await manager.get_devices() + + assert len(devices) == 1 + assert devices[0].duid == "dynamic_s7" + + assert devices[0].v1_properties is not None + await devices[0].v1_properties.status.refresh() + assert devices[0].v1_properties.status.battery == 42 diff --git a/tests/testing/test_v1_simulator.py b/tests/testing/test_v1_simulator.py new file mode 100644 index 000000000..f11fd42d2 --- /dev/null +++ b/tests/testing/test_v1_simulator.py @@ -0,0 +1,264 @@ +from dataclasses import replace + +import pytest + +from roborock.data import UserData +from roborock.data.v1 import RoborockStateCode +from roborock.data.v1.v1_code_mappings import RoborockChargeStatus, RoborockDockTypeCode, RoborockInCleaning +from roborock.devices.cache import InMemoryCache +from roborock.devices.device_manager import UserParams, create_device_manager +from roborock.devices.traits.v1.consumeable import ConsumableAttribute +from roborock.exceptions import RoborockException +from roborock.testing import ( + DEFAULT_NETWORK_INFO, + DEFAULT_STATUS, + FakeRoborockCloud, + V1VacuumSimulator, +) +from tests import mock_data + +USER_DATA = UserData.from_dict(mock_data.USER_DATA) + + +async def _create_connected_device(cloud, fake_device): + """Helper to create a connected device from a cloud and simulator.""" + cloud.add_device(fake_device) + with cloud.patch_device_manager(): + manager = await create_device_manager( + user_params=UserParams(username="test_user", user_data=USER_DATA), + cache=InMemoryCache(), + ) + devices = await manager.get_devices() + assert len(devices) == 1 + return devices[0] + + +async def test_trait_consumable_refresh(): + """Verify that the consumable trait can be refreshed from the simulator.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="s7_consumable") + device = await _create_connected_device(cloud, fake_device) + + await device.v1_properties.consumables.refresh() + assert device.v1_properties.consumables.main_brush_work_time == 74382 + assert device.v1_properties.consumables.side_brush_work_time == 74383 + assert device.v1_properties.consumables.filter_work_time == 74384 + + +async def test_trait_consumable_reset(): + """Verify that resetting a consumable updates both the simulator and trait.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="s7_reset") + device = await _create_connected_device(cloud, fake_device) + + await device.v1_properties.consumables.refresh() + assert device.v1_properties.consumables.filter_work_time == 74384 + + # Reset the filter consumable through the trait API + await device.v1_properties.consumables.reset_consumable(ConsumableAttribute.FILTER_WORK_TIME) + + # The simulator state should be updated + assert fake_device.consumables.filter_work_time == 0 + # The trait auto-refreshes after reset, so the client should reflect the change + assert device.v1_properties.consumables.filter_work_time == 0 + + +async def test_trait_dnd_refresh(): + """Verify that the DND timer trait can be refreshed from the simulator.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="s7_dnd") + device = await _create_connected_device(cloud, fake_device) + + await device.v1_properties.dnd.refresh() + assert device.v1_properties.dnd.start_hour == 22 + assert device.v1_properties.dnd.end_hour == 7 + assert device.v1_properties.dnd.enabled == 1 + + +async def test_trait_fan_speed_change(): + """Verify that sending set_custom_mode updates the simulator fan speed and the trait reflects it.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="s7_fan", status=replace(DEFAULT_STATUS, fan_power=102)) + device = await _create_connected_device(cloud, fake_device) + + await device.v1_properties.status.refresh() + assert device.v1_properties.status.fan_power == 102 + + # Change fan speed through the command trait + await device.v1_properties.command.send("set_custom_mode", [105]) + assert fake_device.status.fan_power == 105 + + # Refresh status to pick up the changed value + await device.v1_properties.status.refresh() + assert device.v1_properties.status.fan_power == 105 + + +async def test_trait_clean_summary_refresh(): + """Verify that the clean summary trait can be refreshed from the simulator.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="s7_summary") + device = await _create_connected_device(cloud, fake_device) + + await device.v1_properties.clean_summary.refresh() + assert device.v1_properties.clean_summary.clean_count == 31 + assert device.v1_properties.clean_summary.dust_collection_count == 25 + + +async def test_trait_multiple_state_transitions(): + """Verify a sequence of state transitions through trait commands.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="s7_transitions") + device = await _create_connected_device(cloud, fake_device) + + # Start cleaning + await device.v1_properties.command.send("app_start") + assert fake_device.status.state == RoborockStateCode.cleaning + + # Stop (pauses the vacuum) + await device.v1_properties.command.send("app_stop") + assert fake_device.status.state == RoborockStateCode.paused + + # Send it back to the dock + await device.v1_properties.command.send("app_charge") + assert fake_device.status.state == RoborockStateCode.returning_home + + # Verify the client sees the final state after refresh + await device.v1_properties.status.refresh() + assert device.v1_properties.status.state == RoborockStateCode.returning_home + + +async def test_trait_push_update_propagation(): + """Verify that unsolicited push updates propagate to client traits without refresh.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator( + duid="s7_push", + status=replace(DEFAULT_STATUS, battery=99, state=RoborockStateCode.charging), + ) + device = await _create_connected_device(cloud, fake_device) + + await device.v1_properties.status.refresh() + assert device.v1_properties.status.battery == 99 + + # Mutate the simulator state and push an update + fake_device.status.battery = 45 + fake_device.status.state = RoborockStateCode.returning_home + fake_device.trigger_push_update() + + # The client status properties should be updated immediately without a manual refresh + assert device.v1_properties.status.battery == 45 + assert device.v1_properties.status.state == RoborockStateCode.returning_home + + +async def test_trait_custom_handler_override(): + """Verify that custom_handlers override default behavior for specific commands.""" + + def custom_get_status(params): + return [{"state": RoborockStateCode.cleaning, "battery": 77, "fan_power": 999}] + + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator( + duid="s7_custom", + custom_handlers={"get_status": custom_get_status}, + ) + device = await _create_connected_device(cloud, fake_device) + + # The custom handler returns different values than the simulator's defaults + await device.v1_properties.status.refresh() + assert device.v1_properties.status.battery == 77 + assert device.v1_properties.status.fan_power == 999 + + +async def test_trait_properties_and_dss_config(): + """Verify that properties, dss config, and dock_type config are correctly exposed on the simulator.""" + fake_device = V1VacuumSimulator( + duid="s7_properties", + status=replace( + DEFAULT_STATUS, + state=RoborockStateCode.cleaning, + dss=42, + dock_type=RoborockDockTypeCode(5), + ), + ) + assert fake_device.in_cleaning == RoborockInCleaning.global_clean_not_complete + assert fake_device.in_returning == 0 + assert fake_device.charge_status == RoborockChargeStatus.charge_waiting + assert fake_device.status.dss == 42 + assert fake_device.status.dock_type == RoborockDockTypeCode(5) + + fake_device.status.state = RoborockStateCode.returning_home + assert fake_device.in_cleaning == RoborockInCleaning.complete + assert fake_device.in_returning == 1 + assert fake_device.charge_status == RoborockChargeStatus.charge_waiting + + fake_device.status.state = RoborockStateCode.charging + assert fake_device.in_cleaning == RoborockInCleaning.complete + assert fake_device.in_returning == 0 + assert fake_device.charge_status == RoborockChargeStatus.charging + + +async def test_trait_publish_failure_injection(): + """Verify that publish_side_effect on simulator channels correctly raises errors.""" + cloud = FakeRoborockCloud() + fake_device = V1VacuumSimulator(duid="s7_failing_publish") + device = await _create_connected_device(cloud, fake_device) + + # Make local publish fail + assert fake_device.local_channel is not None + fake_device.local_channel.publish_side_effect = RoborockException("Local network error") + + # The client status refresh should still succeed by falling back to MQTT! + await device.v1_properties.status.refresh() + + # If MQTT also fails, the refresh must fail + fake_device.mqtt_channel.publish_side_effect = RoborockException("MQTT network error") + with pytest.raises(RoborockException, match="MQTT network error"): + await device.v1_properties.status.refresh() + + +async def test_multiple_devices_network_info_override(): + """Verify that multiple devices can coexist and their individual custom network + + info properties are correctly fetched. + """ + cloud = FakeRoborockCloud() + + fake_device1 = V1VacuumSimulator(duid="device_1") + fake_device2 = V1VacuumSimulator( + duid="device_2", + network_info=replace(DEFAULT_NETWORK_INFO, ip="192.168.1.50", ssid="custom_wifi"), + ) + + cloud.add_device(fake_device1) + cloud.add_device(fake_device2) + + with cloud.patch_device_manager(): + manager = await create_device_manager( + user_params=UserParams(username="test_user", user_data=USER_DATA), + cache=InMemoryCache(), + ) + devices = await manager.get_devices() + + assert len(devices) == 2 + + # Sort them by duid to ensure order + devices.sort(key=lambda d: d.duid) + + device1 = devices[0] + device2 = devices[1] + + assert device1.duid == "device_1" + assert device2.duid == "device_2" + + # Refresh and verify network info on device1 (should have defaults) + assert device1.v1_properties is not None + assert device1.v1_properties.network_info is not None + await device1.v1_properties.network_info.refresh() + assert device1.v1_properties.network_info.ip == "1.1.1.1" + assert device1.v1_properties.network_info.ssid == "test_wifi" + + # Refresh and verify network info on device2 (should have overridden values) + assert device2.v1_properties is not None + assert device2.v1_properties.network_info is not None + await device2.v1_properties.network_info.refresh() + assert device2.v1_properties.network_info.ip == "192.168.1.50" + assert device2.v1_properties.network_info.ssid == "custom_wifi" From a5e9cbf460539e5bd03d4f8a67fb2eb533f52c7e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 7 Jul 2026 00:09:16 +0000 Subject: [PATCH 16/21] 5.27.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 052415193..1f1d7c50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,79 @@ +## v5.27.0 (2026-07-07) + +### Documentation + +- Add comment explaining dss field in v1_simulator + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +### Features + +- Add roborock.testing module for stateful integration testing + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Expose dock_type configuration in V1VacuumSimulator + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Expose in_cleaning, in_returning, charge_status, and dss on V1VacuumSimulator + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Honor publish_side_effect in simulator and raise NotImplementedError in base _handle_publish + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +### Refactoring + +- Decouple default product id from device duid and share DEFAULT_PRODUCT_ID constant + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Delete redundant properties from V1VacuumSimulator and update trigger_push_update + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Export and define all simulator state default constants at package level + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Expose raw status and app_init dataclasses on simulator for direct test manipulation + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Inherit FakeChannel from Channel Protocol + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Rename self.servers to self.simulated_devices and restore hardcoded qiCNieZa in mock_data + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Replace FakeUserState with library UserData dataclass on FakeRoborockCloud + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Type simulator consumables state using Consumable dataclass + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Type simulator dnd, clean summary and record using real dataclasses + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Type simulator status, network info and init status using real dataclasses + ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + +- Update _handle_app_get_init_status to serialize self.app_init and dynamically resolve status_info + fields from self.status ([#860](https://github.com/Python-roborock/python-roborock/pull/860), + [`c963ad7`](https://github.com/Python-roborock/python-roborock/commit/c963ad79b7d7206dd1b97cd7f377305a67f90364)) + + ## v5.26.0 (2026-07-06) ### Documentation diff --git a/pyproject.toml b/pyproject.toml index 7d74d1e9b..7ef08222d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.26.0" +version = "5.27.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 0f0793247..6b9a6319f 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.26.0" +version = "5.27.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 73ba2bf152d1c43b4d46d1e7f397043dd195d046 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 8 Jul 2026 05:46:55 -0400 Subject: [PATCH 17/21] feat: add better dock feature control (#867) --- roborock/data/v1/v1_code_mappings.py | 41 ++-- roborock/data/v1/v1_containers.py | 12 + roborock/device_features.py | 219 +++++++++++++++++- roborock/devices/traits/v1/__init__.py | 27 ++- .../devices/traits/v1/dust_collection_mode.py | 8 +- .../devices/traits/v1/smart_wash_params.py | 8 +- roborock/devices/traits/v1/wash_towel_mode.py | 8 +- roborock/testing/v1_simulator.py | 2 +- tests/data/v1/test_v1_containers.py | 14 +- .../devices/__snapshots__/test_v1_device.ambr | 2 +- tests/devices/traits/v1/fixtures.py | 2 +- tests/devices/traits/v1/helpers.py | 13 ++ .../traits/v1/test_dust_collection_mode.py | 15 +- .../traits/v1/test_smart_wash_params.py | 14 +- .../devices/traits/v1/test_wash_towel_mode.py | 22 +- .../__snapshots__/test_device_manager.ambr | 136 +++++------ tests/e2e/test_device_manager.py | 13 +- tests/test_supported_features.py | 73 +++++- 18 files changed, 466 insertions(+), 163 deletions(-) create mode 100644 tests/devices/traits/v1/helpers.py diff --git a/roborock/data/v1/v1_code_mappings.py b/roborock/data/v1/v1_code_mappings.py index 730be40ee..7ceea598b 100644 --- a/roborock/data/v1/v1_code_mappings.py +++ b/roborock/data/v1/v1_code_mappings.py @@ -650,22 +650,31 @@ class RoborockDockErrorCode(RoborockEnum): class RoborockDockTypeCode(RoborockEnum): unknown = -9999 - no_dock = 0 - auto_empty_dock = 1 - empty_wash_fill_dock = 3 - auto_empty_dock_pure = 5 - s7_max_ultra_dock = 6 - s8_dock = 7 - p10_dock = 8 - p10_pro_dock = 9 - s8_maxv_ultra_dock = 10 - qrevo_master_dock = 14 - qrevo_s_dock = 15 - saros_r10_dock = 16 - qrevo_curv_dock = 17 - saros_10_dock = 18 - qrevo_s5v_dock = 22 - saros_20_dock = 27 + o0_dock = 0 + o1_dock = 1 + o2_dock = 2 + o3_dock = 3 + oc_dock = 5 + o3_plus_dock = 6 + o4_dock = 7 + pearl_dock = 8 + pearl_plus_dock = 9 + o5_dock = 10 + shell_2s_dock = 11 + couple_dock = 13 + shell_3_dock = 14 + shell_2c_dock = 15 + shell_3s_dock = 16 + k1_dock = 17 + o6_dock = 18 + k1c_dock = 19 + k1s_dock = 20 + shell_e_dock = 21 + shell_2e_dock = 22 + shell_3c_dock = 23 + type_27_dock = 27 + k1c_lite_dock = 28 + shell_2e_lite_dock = 30 class RoborockDockDustCollectionModeCode(RoborockEnum): diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 46fb32912..477d5e545 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -239,6 +239,12 @@ def current_map(self) -> int | None: return map_flag return None + @property + def has_am(self) -> bool | None: + if self.dss is None: + return None + return (self.dss & 3) == 2 + @property def clear_water_box_status(self) -> ClearWaterBoxStatus | None: if self.dss: @@ -376,6 +382,12 @@ def current_map(self) -> int | None: return map_flag return None + @property + def has_am(self) -> bool | None: + if self.dss is None: + return None + return (self.dss & 3) == 2 + @property def clear_water_box_status(self) -> ClearWaterBoxStatus | None: if self.dss: diff --git a/roborock/device_features.py b/roborock/device_features.py index 9b03b9724..c3db83161 100644 --- a/roborock/device_features.py +++ b/roborock/device_features.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field, fields from enum import IntEnum, StrEnum +from functools import cache from typing import Any, Self from roborock.data.code_mappings import RoborockProductNickname @@ -644,25 +645,219 @@ def get_supported_features(self) -> list[str]: return [k for k, v in vars(self).items() if v] +_NO_DOCK_TYPES = { + RoborockDockTypeCode.unknown, + RoborockDockTypeCode.o0_dock, +} + +_PURE_COLLECT_DOCK_TYPES = { + RoborockDockTypeCode.o1_dock, + RoborockDockTypeCode.oc_dock, +} + +_PURE_WASH_DOCK_TYPES = { + RoborockDockTypeCode.o2_dock, +} + +_COLLECT_WASH_DOCK_TYPES = { + RoborockDockTypeCode.o3_dock, +} + +_ONLY_X_SERIES_DOCK_TYPES = { + RoborockDockTypeCode.o2_dock, + RoborockDockTypeCode.o3_dock, + RoborockDockTypeCode.o3_plus_dock, + RoborockDockTypeCode.o4_dock, + RoborockDockTypeCode.o5_dock, + RoborockDockTypeCode.o6_dock, +} + +_CLEAN_FLUID_AUTO_DELIVERY_DOCK_TYPES = { + RoborockDockTypeCode.o4_dock, + RoborockDockTypeCode.pearl_plus_dock, + RoborockDockTypeCode.shell_3s_dock, + RoborockDockTypeCode.o6_dock, + RoborockDockTypeCode.couple_dock, +} + +_CLEAN_FLUID_AUTO_DELIVERY_AM_DOCK_TYPES = { + RoborockDockTypeCode.o5_dock, + RoborockDockTypeCode.shell_2s_dock, + RoborockDockTypeCode.shell_3_dock, + RoborockDockTypeCode.shell_2c_dock, + RoborockDockTypeCode.k1_dock, + RoborockDockTypeCode.k1s_dock, + RoborockDockTypeCode.shell_e_dock, + RoborockDockTypeCode.shell_2e_dock, + RoborockDockTypeCode.shell_3c_dock, +} + +_AUTO_STERILIZE_DOCK_TYPES = { + RoborockDockTypeCode.shell_3s_dock, +} + +_INNER_CHARGE_HEAD_DOCK_TYPES = { + RoborockDockTypeCode.o0_dock, + RoborockDockTypeCode.o1_dock, + RoborockDockTypeCode.oc_dock, + RoborockDockTypeCode.o2_dock, + RoborockDockTypeCode.o3_dock, + RoborockDockTypeCode.o3_plus_dock, + RoborockDockTypeCode.o4_dock, +} + +_DUST_BUCKET_DOCK_TYPES = { + RoborockDockTypeCode.o3_dock, + RoborockDockTypeCode.o3_plus_dock, + RoborockDockTypeCode.o4_dock, +} + +_CLEAN_CAROUSEL_SELF_CLEAN_DOCK_TYPES = { + RoborockDockTypeCode.shell_3_dock, + RoborockDockTypeCode.k1_dock, + RoborockDockTypeCode.couple_dock, + RoborockDockTypeCode.shell_3s_dock, + RoborockDockTypeCode.k1s_dock, + RoborockDockTypeCode.shell_3c_dock, +} + +_WATER_UPDOWN_DRAIN_DOCK_TYPES = { + RoborockDockTypeCode.o4_dock, + RoborockDockTypeCode.pearl_plus_dock, + RoborockDockTypeCode.couple_dock, +} + +_WATER_UPDOWN_DRAIN_AM_DOCK_TYPES = { + RoborockDockTypeCode.o5_dock, + RoborockDockTypeCode.shell_2s_dock, + RoborockDockTypeCode.shell_3_dock, + RoborockDockTypeCode.shell_2c_dock, + RoborockDockTypeCode.shell_3s_dock, + RoborockDockTypeCode.k1_dock, + RoborockDockTypeCode.o6_dock, + RoborockDockTypeCode.shell_e_dock, + RoborockDockTypeCode.shell_2e_dock, +} + +_HATCH_DOOR_DOCK_COOL_FAN_DOCK_TYPES = { + RoborockDockTypeCode.couple_dock, +} + +_SPECIAL_WASH_TEMP_DOCK_TYPES = { + RoborockDockTypeCode.shell_2e_lite_dock, +} + + +@dataclass(frozen=True) +class RoborockDockFeatures: + """Capabilities derived from a V1 dock type. + + This mirrors the Roborock app's DK capability model: feature availability + should be attached to the dock family instead of repeated per trait. + """ + + dock_type: RoborockDockTypeCode + has_am: bool | None = None + + @classmethod + @cache + def from_dock_type(cls, dock_type: RoborockDockTypeCode | None, has_am: bool | None = None) -> Self: + """Return cached capabilities for a V1 dock type.""" + return cls(dock_type or RoborockDockTypeCode.o0_dock, has_am=has_am) + + def _matches_am_variant(self, dock_types: set[RoborockDockTypeCode]) -> bool: + return self.has_am is True and self.dock_type in dock_types + + def _matches_non_am_variant(self, dock_types: set[RoborockDockTypeCode]) -> bool: + return self.has_am is not True and self.dock_type in dock_types + + @property + def has_dock(self) -> bool: + return self.dock_type not in _NO_DOCK_TYPES + + @property + def is_pure_collect(self) -> bool: + return self.dock_type in _PURE_COLLECT_DOCK_TYPES + + @property + def is_pure_wash(self) -> bool: + return self.dock_type in _PURE_WASH_DOCK_TYPES + + @property + def is_collect_wash(self) -> bool: + return self.dock_type in _COLLECT_WASH_DOCK_TYPES + + @property + def is_collect_wash_dry(self) -> bool: + return self.has_dock and not (self.is_pure_collect or self.is_pure_wash or self.is_collect_wash) + + @property + def is_collectable(self) -> bool: + return self.is_collect_wash or self.is_collect_wash_dry or self.is_pure_collect + + @property + def is_washable(self) -> bool: + return self.is_pure_wash or self.is_collect_wash or self.is_collect_wash_dry + + @property + def is_dryable(self) -> bool: + return self.is_collect_wash_dry + + @property + def is_only_x_series(self) -> bool: + return self.dock_type in _ONLY_X_SERIES_DOCK_TYPES + + @property + def is_cleaning_brush_supported(self) -> bool: + return self.is_only_x_series + + @property + def is_clean_fluid_auto_delivery_supported(self) -> bool: + return self.dock_type in _CLEAN_FLUID_AUTO_DELIVERY_DOCK_TYPES or self._matches_am_variant( + _CLEAN_FLUID_AUTO_DELIVERY_AM_DOCK_TYPES + ) + + @property + def is_auto_sterilize_supported(self) -> bool: + return self.dock_type in _AUTO_STERILIZE_DOCK_TYPES + + @property + def is_inner_charge_head(self) -> bool: + return self.dock_type in _INNER_CHARGE_HEAD_DOCK_TYPES + + @property + def is_dust_bucket_supported(self) -> bool: + return self.dock_type in _DUST_BUCKET_DOCK_TYPES + + @property + def is_clean_carousel_self_clean_supported(self) -> bool: + return self._matches_non_am_variant(_CLEAN_CAROUSEL_SELF_CLEAN_DOCK_TYPES) + + @property + def is_water_updown_drain_supported(self) -> bool: + return self.dock_type in _WATER_UPDOWN_DRAIN_DOCK_TYPES or self._matches_am_variant( + _WATER_UPDOWN_DRAIN_AM_DOCK_TYPES + ) + + @property + def is_hatch_door_dock_cool_fan_supported(self) -> bool: + return self.dock_type in _HATCH_DOOR_DOCK_COOL_FAN_DOCK_TYPES + + @property + def is_special_support_wash_temp(self) -> bool: + return self.dock_type in _SPECIAL_WASH_TEMP_DOCK_TYPES + + WASH_N_FILL_DOCK_TYPES = [ - RoborockDockTypeCode.empty_wash_fill_dock, - RoborockDockTypeCode.s8_dock, - RoborockDockTypeCode.p10_dock, - RoborockDockTypeCode.p10_pro_dock, - RoborockDockTypeCode.s8_maxv_ultra_dock, - RoborockDockTypeCode.qrevo_s_dock, - RoborockDockTypeCode.saros_r10_dock, - RoborockDockTypeCode.qrevo_curv_dock, - RoborockDockTypeCode.qrevo_s5v_dock, - RoborockDockTypeCode.saros_20_dock, + dock_type for dock_type in RoborockDockTypeCode if RoborockDockFeatures.from_dock_type(dock_type).is_washable ] def is_wash_n_fill_dock(dock_type: RoborockDockTypeCode) -> bool: """Check if the dock type is a wash and fill dock.""" - return dock_type in WASH_N_FILL_DOCK_TYPES + return RoborockDockFeatures.from_dock_type(dock_type).is_washable def is_valid_dock(dock_type: RoborockDockTypeCode) -> bool: """Check if device supports a dock.""" - return dock_type != RoborockDockTypeCode.no_dock + return RoborockDockFeatures.from_dock_type(dock_type).has_dock diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index cb17e352c..7cfbd3b7e 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -42,8 +42,8 @@ - `requires_feature` - The string name of the device feature that must be supported for this trait to be enabled. See `DeviceFeaturesTrait` for a list of available features. - - `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode` - and returns a boolean indicating whether the trait is supported for that dock type. + - `requires_dock_features` - If set, this is a function that accepts a `RoborockDockFeatures` + and returns a boolean indicating whether the trait is supported for that dock. Additionally, DeviceFeaturesTrait has a method `is_field_supported` that is used to check individual trait field values. This is a more fine grained version to allow @@ -59,6 +59,7 @@ from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase from roborock.data.v1.v1_code_mappings import RoborockDockTypeCode +from roborock.device_features import RoborockDockFeatures from roborock.devices.cache import DeviceCache from roborock.devices.traits import Trait from roborock.exceptions import RoborockException @@ -262,9 +263,10 @@ async def discover_features(self) -> None: await self.device_features.refresh() # Dock type also acts like a device feature for some traits. dock_type = await self._dock_type() + dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am) # Initialize traits with special arguments before the generic loop - if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_type): + if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features): wash_towel_mode = WashTowelModeTrait(self.device_features) wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] self.wash_towel_mode = wash_towel_mode @@ -280,7 +282,7 @@ async def discover_features(self) -> None: # Union args may not be in declared order item_type = union_args[0] if union_args[1] is type(None) else union_args[1] - if not self._is_supported(item_type, item.name, dock_type): + if not self._is_supported(item_type, item.name, dock_features): _LOGGER.debug("Trait '%s' not supported, skipping", item.name) continue _LOGGER.debug("Trait '%s' is supported, initializing", item.name) @@ -288,11 +290,11 @@ async def discover_features(self) -> None: setattr(self, item.name, trait) trait._rpc_channel = self._get_rpc_channel(trait) - def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool: + def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_features: RoborockDockFeatures) -> bool: """Check if a trait is supported by the device.""" - if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None: - return requires_dock_type(dock_type) + if (requires_dock_features := getattr(trait_type, "requires_dock_features", None)) is not None: + return requires_dock_features(dock_features) if (feature_name := getattr(trait_type, "requires_feature", None)) is None: _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name) @@ -307,16 +309,23 @@ async def _dock_type(self) -> RoborockDockTypeCode: if dock_type is not None: _LOGGER.debug("Using cached dock type: %s", dock_type) try: - return RoborockDockTypeCode(dock_type) + dock_type = RoborockDockTypeCode(dock_type) except ValueError: _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type) + else: + if self.status.dss is None: + await self.status.refresh() + if self.status.dock_type is not None: + dock_type = self.status.dock_type + await self._set_cached_trait_data("dock_type", dock_type) + return dock_type _LOGGER.debug("Starting dock type discovery") await self.status.refresh() _LOGGER.debug("Fetched dock type: %s", self.status.dock_type) if self.status.dock_type is None: # Explicitly set so we reuse cached value next type - dock_type = RoborockDockTypeCode.no_dock + dock_type = RoborockDockTypeCode.o0_dock else: dock_type = self.status.dock_type await self._set_cached_trait_data("dock_type", dock_type) diff --git a/roborock/devices/traits/v1/dust_collection_mode.py b/roborock/devices/traits/v1/dust_collection_mode.py index afda53c1b..e738e30d1 100644 --- a/roborock/devices/traits/v1/dust_collection_mode.py +++ b/roborock/devices/traits/v1/dust_collection_mode.py @@ -1,14 +1,18 @@ """Trait for dust collection mode.""" from roborock.data import DustCollectionMode -from roborock.device_features import is_valid_dock +from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand +def _supports_dust_collection_mode(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_collectable + + class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin): """Trait for dust collection mode.""" command = RoborockCommand.GET_DUST_COLLECTION_MODE converter = common.DefaultConverter(DustCollectionMode) - requires_dock_type = is_valid_dock + requires_dock_features = _supports_dust_collection_mode diff --git a/roborock/devices/traits/v1/smart_wash_params.py b/roborock/devices/traits/v1/smart_wash_params.py index 9df0a53d1..78da74e20 100644 --- a/roborock/devices/traits/v1/smart_wash_params.py +++ b/roborock/devices/traits/v1/smart_wash_params.py @@ -1,14 +1,18 @@ """Trait for smart wash parameters.""" from roborock.data import SmartWashParams -from roborock.device_features import is_wash_n_fill_dock +from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand +def _supports_smart_wash_params(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_washable + + class SmartWashParamsTrait(SmartWashParams, common.V1TraitMixin): """Trait for smart wash parameters.""" command = RoborockCommand.GET_SMART_WASH_PARAMS converter = common.DefaultConverter(SmartWashParams) - requires_dock_type = is_wash_n_fill_dock + requires_dock_features = _supports_smart_wash_params diff --git a/roborock/devices/traits/v1/wash_towel_mode.py b/roborock/devices/traits/v1/wash_towel_mode.py index de607bc7b..909e96b68 100644 --- a/roborock/devices/traits/v1/wash_towel_mode.py +++ b/roborock/devices/traits/v1/wash_towel_mode.py @@ -4,18 +4,22 @@ from typing import Self from roborock.data import WashTowelMode, WashTowelModes, get_wash_towel_modes -from roborock.device_features import is_wash_n_fill_dock +from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait from roborock.roborock_typing import RoborockCommand +def _supports_wash_towel_mode(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_washable + + class WashTowelModeTrait(WashTowelMode, common.V1TraitMixin): """Trait for wash towel mode.""" command = RoborockCommand.GET_WASH_TOWEL_MODE converter = common.DefaultConverter(WashTowelMode) - requires_dock_type = is_wash_n_fill_dock + requires_dock_features = _supports_wash_towel_mode def __init__( self, diff --git a/roborock/testing/v1_simulator.py b/roborock/testing/v1_simulator.py index 686d37843..83bbba99e 100644 --- a/roborock/testing/v1_simulator.py +++ b/roborock/testing/v1_simulator.py @@ -81,7 +81,7 @@ def _serialize_dataclass(obj: Any) -> dict[str, Any]: home_sec_enable_password=0, adbumper_status=[0, 0, 0], water_shortage_status=0, - dock_type=RoborockDockTypeCode.s8_dock, + dock_type=RoborockDockTypeCode.o4_dock, dust_collection_status=0, auto_dust_collection=1, avoid_count=19, diff --git a/tests/data/v1/test_v1_containers.py b/tests/data/v1/test_v1_containers.py index f8625eb35..34b671e7e 100644 --- a/tests/data/v1/test_v1_containers.py +++ b/tests/data/v1/test_v1_containers.py @@ -82,7 +82,7 @@ def test_status(): assert s.home_sec_enable_password == 0 assert s.adbumper_status == [0, 0, 0] assert s.water_shortage_status == 0 - assert s.dock_type == RoborockDockTypeCode.empty_wash_fill_dock + assert s.dock_type == RoborockDockTypeCode.o3_dock assert s.dust_collection_status == 0 assert s.auto_dust_collection == 1 assert s.avoid_count == 19 @@ -194,7 +194,7 @@ def test_status_v2() -> None: assert s.fan_power == 102 assert s.water_box_mode == 203 assert s.mop_mode == 300 - assert s.dock_type == RoborockDockTypeCode.empty_wash_fill_dock + assert s.dock_type == RoborockDockTypeCode.o3_dock assert s.dock_error_status == RoborockDockErrorCode.ok assert s.current_map == 0 @@ -268,10 +268,18 @@ def test_qrevo_s5v_dock_type(): modified_status = STATUS.copy() modified_status["dock_type"] = 22 s = S7MaxVStatus.from_dict(modified_status) - assert s.dock_type == RoborockDockTypeCode.qrevo_s5v_dock + assert s.dock_type == RoborockDockTypeCode.shell_2e_dock assert s.dock_type.value == 22 +def test_has_am_dss_zero_is_not_missing(): + modified_status = STATUS.copy() + modified_status["dss"] = 0 + + assert S7MaxVStatus.from_dict(modified_status).has_am is False + assert StatusV2.from_dict(modified_status).has_am is False + + def test_multi_maps_list_info(snapshot: SnapshotAssertion) -> None: """Test that MultiMapsListInfo can be deserialized correctly.""" data = { diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index 61858ce2b..32f5c5427 100644 --- a/tests/devices/__snapshots__/test_v1_device.ambr +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -870,7 +870,7 @@ }) # --- # name: test_device_trait_command_parsing[status] - StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) + StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], has_am=None, hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) # --- # name: test_device_trait_command_parsing[status].1 dict({ diff --git a/tests/devices/traits/v1/fixtures.py b/tests/devices/traits/v1/fixtures.py index fba0b86a5..58b02cbc7 100644 --- a/tests/devices/traits/v1/fixtures.py +++ b/tests/devices/traits/v1/fixtures.py @@ -112,7 +112,7 @@ def device_fixture( @pytest.fixture(name="dock_type_code", autouse=True) def dock_type_code_fixture(request: pytest.FixtureRequest) -> RoborockDockTypeCode | None: """Fixture to provide the dock type code for parameterized tests.""" - return RoborockDockTypeCode.s7_max_ultra_dock + return RoborockDockTypeCode.o3_plus_dock @pytest.fixture(autouse=True) diff --git a/tests/devices/traits/v1/helpers.py b/tests/devices/traits/v1/helpers.py new file mode 100644 index 000000000..6493ce34c --- /dev/null +++ b/tests/devices/traits/v1/helpers.py @@ -0,0 +1,13 @@ +"""Shared helpers for V1 trait tests.""" + +from roborock.data import RoborockDockTypeCode +from roborock.device_features import RoborockDockFeatures + + +def dock_types_with_capability(capability: str, expected: bool = True) -> list[RoborockDockTypeCode]: + """Return dock types matching a RoborockDockFeatures capability.""" + return [ + dock_type + for dock_type in RoborockDockTypeCode + if getattr(RoborockDockFeatures.from_dock_type(dock_type), capability) is expected + ] diff --git a/tests/devices/traits/v1/test_dust_collection_mode.py b/tests/devices/traits/v1/test_dust_collection_mode.py index 1b68304eb..f8e847b4d 100644 --- a/tests/devices/traits/v1/test_dust_collection_mode.py +++ b/tests/devices/traits/v1/test_dust_collection_mode.py @@ -8,6 +8,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.dust_collection_mode import DustCollectionModeTrait from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability DUST_COLLECTION_MODE_DATA = [{"mode": 2}] @@ -24,14 +25,7 @@ def dust_collection_mode_trait( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s7_max_ultra_dock), - (RoborockDockTypeCode.s8_dock), - (RoborockDockTypeCode.p10_dock), - (RoborockDockTypeCode.qrevo_s_dock), - (RoborockDockTypeCode.qrevo_s5v_dock), - (RoborockDockTypeCode.saros_20_dock), - ], + dock_types_with_capability("is_collectable"), ) async def test_dust_collection_mode_available( dust_collection_mode: DustCollectionModeTrait | None, @@ -56,7 +50,10 @@ async def test_dust_collection_mode_available( assert dust_collection_mode.mode == RoborockDockDustCollectionModeCode.balanced -@pytest.mark.parametrize(("dock_type_code"), [(RoborockDockTypeCode.no_dock)]) +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_collectable", expected=False), +) async def test_unsupported_dust_collection_mode( dust_collection_mode: DustCollectionModeTrait | None, dock_type_code: RoborockDockTypeCode, diff --git a/tests/devices/traits/v1/test_smart_wash_params.py b/tests/devices/traits/v1/test_smart_wash_params.py index 63ec757f5..328d5c649 100644 --- a/tests/devices/traits/v1/test_smart_wash_params.py +++ b/tests/devices/traits/v1/test_smart_wash_params.py @@ -10,6 +10,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.smart_wash_params import SmartWashParamsTrait from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability SMART_WASH_DATA = [{"smart_wash": 5, "wash_interval": 6}] @@ -26,13 +27,7 @@ def smart_wash_params_trait( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s8_dock), - (RoborockDockTypeCode.p10_dock), - (RoborockDockTypeCode.qrevo_s_dock), - (RoborockDockTypeCode.qrevo_s5v_dock), - (RoborockDockTypeCode.saros_20_dock), - ], + dock_types_with_capability("is_washable"), ) async def test_smart_wash_available( smart_wash_params: SmartWashParamsTrait | None, @@ -64,10 +59,7 @@ async def test_smart_wash_available( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s7_max_ultra_dock), # Not in WASH_N_FILL_DOCK_TYPES - (RoborockDockTypeCode.no_dock), - ], + dock_types_with_capability("is_washable", expected=False), ) async def test_unsupported_smart_wash_params( smart_wash_params: SmartWashParamsTrait | None, dock_type_code: RoborockDockTypeCode diff --git a/tests/devices/traits/v1/test_wash_towel_mode.py b/tests/devices/traits/v1/test_wash_towel_mode.py index f3eceea2c..8395c70ce 100644 --- a/tests/devices/traits/v1/test_wash_towel_mode.py +++ b/tests/devices/traits/v1/test_wash_towel_mode.py @@ -11,6 +11,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.wash_towel_mode import WashTowelModeTrait from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability WASH_TOWEL_MODE_DATA = {"wash_mode": WashTowelModes.SMART.code} @@ -27,13 +28,7 @@ def wash_towel_mode_trait( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s8_dock), - (RoborockDockTypeCode.p10_dock), - (RoborockDockTypeCode.qrevo_s_dock), - (RoborockDockTypeCode.qrevo_s5v_dock), - (RoborockDockTypeCode.saros_20_dock), - ], + dock_types_with_capability("is_washable"), ) async def test_wash_towel_mode_available( wash_towel_mode: WashTowelModeTrait, @@ -60,10 +55,7 @@ async def test_wash_towel_mode_available( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s7_max_ultra_dock), - (RoborockDockTypeCode.no_dock), - ], + dock_types_with_capability("is_washable", expected=False), ) async def test_unsupported_wash_towel_mode( wash_towel_mode: WashTowelModeTrait | None, dock_type_code: RoborockDockTypeCode @@ -74,7 +66,7 @@ async def test_unsupported_wash_towel_mode( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) @pytest.mark.parametrize( ("wash_mode"), @@ -101,7 +93,7 @@ async def test_set_wash_towel_mode( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) async def test_start_wash( wash_towel_mode: WashTowelModeTrait, @@ -118,7 +110,7 @@ async def test_start_wash( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) async def test_stop_wash( wash_towel_mode: WashTowelModeTrait, @@ -135,7 +127,7 @@ async def test_stop_wash( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) @pytest.mark.parametrize( ( diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 0e8290eea..90f2398dc 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -534,7 +534,7 @@ 00000000 90 04 00 01 00 00 |......| [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 8c 68 |....1.0......#.h| - 00000010 a6 a2 2c 00 00 b8 95 0e 86 |..,......| + 00000010 a6 a2 2d 00 00 b9 57 64 b1 |..-...Wd.| [local <] 00000000 00 00 00 27 31 2e 30 00 00 00 01 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 2b 00 01 00 10 6d b9 48 37 ed 43 59 7a 90 |..+....m.H7.CYz.| @@ -558,71 +558,71 @@ 00000070 38 a4 39 90 99 6f 4c 84 b5 1b ba |8.9..oL....| [local <] 00000000 00 00 04 37 31 2e 30 00 00 00 02 00 00 00 17 68 |...71.0........h| - 00000010 a6 a2 2d 00 66 04 20 5e 5f 0b 3d ac 87 95 d7 72 |..-.f. ^_.=....r| - 00000020 96 43 49 74 68 c3 35 e3 c7 bd 9d d2 41 b1 15 99 |.CIth.5.....A...| - 00000030 23 aa 66 a2 1a b7 54 da d3 d1 17 a7 d5 96 8c 29 |#.f...T........)| - 00000040 1e a0 17 b7 40 ed 89 49 70 0d f9 1e aa b0 68 73 |....@..Ip.....hs| - 00000050 6c 0e 82 1e 46 6b b7 7f 75 d6 7e b8 a9 4c d1 57 |l...Fk..u.~..L.W| - 00000060 42 95 10 c8 f1 d9 04 9e b9 2c b6 3f dd 1c e5 2f |B........,.?.../| - 00000070 08 1f 0f b7 e9 85 5a 34 4b 69 9b 6f 68 dd 45 74 |......Z4Ki.oh.Et| - 00000080 d4 bc c6 ad 11 07 98 9f fb aa 7b 67 96 2f 67 60 |..........{g./g`| - 00000090 e1 4b 2d 80 72 48 47 09 f0 c3 a2 65 a4 32 3b d2 |.K-.rHG....e.2;.| - 000000a0 02 ac 38 76 72 30 27 9d 75 d6 1a aa 05 e5 08 c5 |..8vr0'.u.......| - 000000b0 2e c4 3e ab 86 72 6b 61 ce d0 77 1e c4 6f 91 6f |..>..rka..w..o.o| - 000000c0 29 d3 19 89 45 21 6b 1e 7a 27 76 e1 1a 71 32 0f |)...E!k.z'v..q2.| - 000000d0 af bb 54 7d ea 30 19 e6 95 0b db 0b 88 0b eb 19 |..T}.0..........| - 000000e0 18 f1 03 80 3e 2d d3 dd 47 61 93 d1 5a b9 b1 13 |....>-..Ga..Z...| - 000000f0 c6 5c b1 f6 52 0a 94 6f 27 05 fe 67 80 3d 3a 51 |.\..R..o'..g.=:Q| - 00000100 d6 65 28 10 23 4f 42 9c d6 40 c3 3e c6 64 13 a8 |.e(.#OB..@.>.d..| - 00000110 c1 7d 13 17 d2 a0 a1 7b 6d 35 a5 62 73 75 f0 9b |.}.....{m5.bsu..| - 00000120 e9 32 96 62 14 0e 97 b3 73 b1 df 48 b4 4c 96 b1 |.2.b....s..H.L..| - 00000130 52 8d d5 5b 2f b2 21 f0 e6 81 9b e4 a3 68 11 6c |R..[/.!......h.l| - 00000140 bd d8 6c 4c ba a3 1a fd 12 4a c8 e1 39 ce 74 f5 |..lL.....J..9.t.| - 00000150 52 71 4d 28 f1 d4 8e a6 68 95 fb e2 b9 f6 62 10 |RqM(....h.....b.| - 00000160 a0 51 1b 44 13 53 71 1d 32 94 54 e8 d8 3a d9 d1 |.Q.D.Sq.2.T..:..| - 00000170 15 e3 f9 09 91 38 2a ba 4c e1 ca a8 6e 3f cf 38 |.....8*.L...n?.8| - 00000180 8a 8f 38 f7 da d1 b4 13 17 df 84 d4 74 0c 2a 65 |..8.........t.*e| - 00000190 64 96 ec 5e 9b fb 4d f5 eb ab e8 9a 91 ae 3b d1 |d..^..M.......;.| - 000001a0 ac 54 66 a1 18 65 92 f9 7c 67 a0 e2 d2 7e 79 3f |.Tf..e..|g...~y?| - 000001b0 5a 9d 31 8d f4 98 cd cf 1b c5 51 04 12 05 34 6c |Z.1.......Q...4l| - 000001c0 36 4e 9a 00 2b 10 9c 53 1f cf fe ff 28 d3 ab fe |6N..+..S....(...| - 000001d0 dc 7c 85 fb 23 06 00 58 2f 4d 36 fb 1b 04 76 a4 |.|..#..X/M6...v.| - 000001e0 7f 73 b8 58 b1 1a 22 2c e6 27 29 09 c7 48 72 f7 |.s.X..",.')..Hr.| - 000001f0 63 ce 7b ff 61 60 23 8a 92 06 a0 fa cc fd 6a d0 |c.{.a`#.......j.| - 00000200 13 e5 24 33 f7 2c c2 95 06 06 d8 c8 28 21 09 c9 |..$3.,......(!..| - 00000210 d8 d5 52 67 a6 81 68 80 00 f1 8b d9 dc bf 16 ea |..Rg..h.........| - 00000220 8f 76 2f bd 6e a8 89 77 94 d3 58 52 0e da aa 06 |.v/.n..w..XR....| - 00000230 2c 4d 35 89 23 45 8a 58 d0 b1 19 fb d4 05 da f0 |,M5.#E.X........| - 00000240 6f 2f 00 e5 11 85 4e 34 a8 55 13 39 a9 2a 46 75 |o/....N4.U.9.*Fu| - 00000250 52 e1 1f 66 e3 b8 2a 55 ab b0 ad ea 9b b6 4b 4d |R..f..*U......KM| - 00000260 9c 79 9b 09 d1 b6 61 48 89 37 b0 98 3c bf bb 4a |.y....aH.7..<..J| - 00000270 f1 22 e2 dd ad e6 3c 98 f6 d0 1f 14 c1 73 ed 7e |."....<......s.~| - 00000280 f8 9a fb 4e 64 e9 2f d9 f9 94 58 b1 f4 9d 67 13 |...Nd./...X...g.| - 00000290 6d 08 4b 6e 1b ca d6 76 02 3f b0 2c fc 0b 0b 32 |m.Kn...v.?.,...2| - 000002a0 ce 38 a1 05 86 d3 db ce dd 06 63 49 e8 c1 d1 41 |.8........cI...A| - 000002b0 00 e0 3b 0a ed 7b 7a b1 04 86 a8 bb 1b 18 52 63 |..;..{z.......Rc| - 000002c0 db 42 2a c3 41 d8 1c 3c 31 ef 6b 72 7e c2 54 a5 |.B*.A..<1.kr~.T.| - 000002d0 20 94 23 b9 8d d0 5e 94 ef 85 b0 73 47 5d 7c f0 | .#...^....sG]|.| - 000002e0 ef ac 82 55 13 04 ba ca 4f 1b af fb 5c 0c b9 e7 |...U....O...\...| - 000002f0 21 c7 97 75 d3 db 18 51 d1 92 5c 51 97 8a ba 3e |!..u...Q..\Q...>| - 00000300 79 80 5c c9 21 8f 6b 8f 04 9e 9a 47 ed ef fe 66 |y.\.!.k....G...f| - 00000310 fe d3 76 5b ff dc 31 d9 f9 94 58 b1 f4 9d 67 13 |..v[..1...X...g.| - 00000320 6d 08 4b 6e 1b ca d6 7f 0c de c5 f4 a5 0f 49 cf |m.Kn..........I.| - 00000330 f9 e5 74 88 96 8a 14 bd 8a 19 0a ce 93 be 30 b8 |..t...........0.| - 00000340 35 1a 1e bb f3 0b ed 34 28 7f 73 ca 76 4a 8d 03 |5......4(.s.vJ..| - 00000350 81 0a 2c 2c cc fe e8 c6 e2 dd 9d e0 be 45 63 57 |..,,.........EcW| - 00000360 ae 34 40 e1 c4 5c 2b ee 40 68 5e d0 35 83 94 ce |.4@..\+.@h^.5...| - 00000370 be 2a 27 08 a5 dc 00 d5 b6 af 48 b3 6b 57 99 80 |.*'.......H.kW..| - 00000380 ba e8 2d 7e b5 57 4e 91 5e 15 7a 13 af 9d ed 24 |..-~.WN.^.z....$| - 00000390 03 6a 35 fb 75 b0 56 36 90 70 ff 59 f9 3f dd 7e |.j5.u.V6.p.Y.?.~| - 000003a0 09 1b 05 a1 38 dc 1c 12 5b 9c 4d 82 b2 32 95 1f |....8...[.M..2..| - 000003b0 80 4f 29 53 51 9f b3 12 72 1a b4 b9 79 d5 7b 74 |.O)SQ...r...y.{t| - 000003c0 99 94 71 90 83 b5 51 1a 47 4e 99 ae 54 78 98 fe |..q...Q.GN..Tx..| - 000003d0 38 91 1e 5e 67 72 1b f1 b8 7d 66 83 55 21 94 7c |8..^gr...}f.U!.|| - 000003e0 d6 79 8b 61 65 0e 0d 46 c4 3b 6f e8 0e f2 ec 4b |.y.ae..F.;o....K| - 000003f0 b6 60 24 40 83 2b 94 06 de a8 88 76 d6 c4 7b 17 |.`$@.+.....v..{.| - 00000400 77 50 dd e5 02 1f 70 7f 9b 8e 2a 66 11 62 f2 18 |wP....p...*f.b..| - 00000410 e7 61 68 58 95 9f 05 25 06 86 73 14 ce 85 1c c9 |.ahX...%..s.....| - 00000420 0c f5 7b 1a aa 95 74 e4 5c e7 c1 2a 55 4b c8 96 |..{...t.\..*UK..| - 00000430 23 c2 9b 9c e9 e2 68 96 7d d6 86 |#.....h.}..| + 00000010 a6 a2 2c 00 66 04 20 18 b5 83 e4 47 b0 8e 1b ef |..,.f. ....G....| + 00000020 12 9a 66 1b b4 1c b5 6b 71 79 d5 5d 6a 00 7e d0 |..f....kqy.]j.~.| + 00000030 60 35 d0 cb 55 bf 7e f4 39 8f 13 eb ac fb d5 b0 |`5..U.~.9.......| + 00000040 58 b0 8f da c7 a9 03 d4 a1 17 02 e4 eb 07 e5 c3 |X...............| + 00000050 85 f4 57 89 4f 01 15 b8 87 ad 28 12 93 2c 4a 76 |..W.O.....(..,Jv| + 00000060 49 1d 7f cc 3c bf 08 64 43 df 22 1d ea 5d 89 a4 |I...<..dC."..]..| + 00000070 09 a5 0a 7d 3d 06 a5 19 45 87 ea 3f f5 bf 8d 1d |...}=...E..?....| + 00000080 b9 ca 34 b0 58 9e e6 b0 68 ed a2 b8 b2 11 4c 76 |..4.X...h.....Lv| + 00000090 c7 99 e2 27 87 8b 45 76 9d 5e 21 43 09 34 e5 c6 |...'..Ev.^!C.4..| + 000000a0 6b ac 5a 66 d9 59 6d 5e 13 2a 21 e7 c1 2e 91 6e |k.Zf.Ym^.*!....n| + 000000b0 84 bc bd db 86 be 0b 86 db ef 40 75 34 bf 9a 5d |..........@u4..]| + 000000c0 7a 22 b0 1a 22 b6 5c 9e 32 3c 8f a6 39 83 ff bb |z"..".\.2<..9...| + 000000d0 b3 80 29 3f 12 31 50 df 18 d1 31 bb 7e 04 68 c7 |..)?.1P...1.~.h.| + 000000e0 d3 39 79 ab d0 8f 24 64 20 85 a5 33 31 b7 51 4a |.9y...$d ..31.QJ| + 000000f0 3b 2f b8 5c 12 08 1e 15 e9 88 3b 9a c7 5e 01 fe |;/.\......;..^..| + 00000100 e7 4b 4a 30 21 73 26 8d 50 b7 6b 18 b2 94 fa f9 |.KJ0!s&.P.k.....| + 00000110 a9 30 24 7a 1d a6 1d d9 fa 84 de eb d3 c4 1e 64 |.0$z...........d| + 00000120 3a ba d6 5e 7c 7c 32 87 98 9f d7 64 b7 19 ae ac |:..^||2....d....| + 00000130 a5 0e 3a 0b 4a c2 78 67 4e 8c 16 50 31 c0 95 70 |..:.J.xgN..P1..p| + 00000140 61 75 5d 90 fd d9 41 92 64 f4 cd 58 4e 2f 21 2e |au]...A.d..XN/!.| + 00000150 e3 d8 0f 9c 27 fc 95 03 13 32 54 e5 5a b4 ba b2 |....'....2T.Z...| + 00000160 2d 9b 90 21 84 96 4c f5 0b 36 53 ae 9a 57 01 44 |-..!..L..6S..W.D| + 00000170 3e 5b e2 b2 70 e1 2d a5 3c 05 d2 cd 41 7c f2 b4 |>[..p.-.<...A|..| + 00000180 98 95 d4 9f 6e aa 68 c0 43 1f f9 69 cc 09 41 5f |....n.h.C..i..A_| + 00000190 8a 37 13 e9 75 72 51 07 ad 01 61 e6 36 2b 14 b2 |.7..urQ...a.6+..| + 000001a0 da fc 34 74 33 e8 08 ce 6b 5a 3b 36 36 c0 8f c5 |..4t3...kZ;66...| + 000001b0 54 27 bc 7b 49 94 c2 13 4a f7 ec ef 74 28 26 e6 |T'.{I...J...t(&.| + 000001c0 29 a9 e3 f2 6e 99 22 80 85 85 00 02 23 5b 47 c9 |)...n.".....#[G.| + 000001d0 06 d5 77 70 10 5a 16 3e ba cd 4c d1 e9 83 6a 62 |..wp.Z.>..L...jb| + 000001e0 33 6e f8 30 55 ab cd ea 62 e0 86 9e cb 64 8e 2a |3n.0U...b....d.*| + 000001f0 10 5d 3f 67 c8 9d ac 12 62 79 62 ce 50 0d b3 60 |.]?g....byb.P..`| + 00000200 15 c2 5a 8f 13 fb 6a d9 75 56 54 d2 05 8a 51 7e |..Z...j.uVT...Q~| + 00000210 55 7e 53 f3 34 da e3 dc 2c b2 c8 4e 32 0d 38 cf |U~S.4...,..N2.8.| + 00000220 7a 9d d9 91 33 dc 68 7b ed 86 ce 27 58 1f 90 eb |z...3.h{...'X...| + 00000230 a9 83 43 2e ed 29 60 79 81 89 00 9c 24 66 a2 44 |..C..)`y....$f.D| + 00000240 8b d9 ba 14 86 cd b6 66 2d 6b e7 1f 0c ce b7 bb |.......f-k......| + 00000250 95 04 97 61 b1 4f 9b ed 08 44 c3 41 5e 31 64 57 |...a.O...D.A^1dW| + 00000260 5d 6d 94 14 14 a7 5b 43 3b 58 79 b2 b8 f7 11 2c |]m....[C;Xy....,| + 00000270 81 4f c1 bd e6 a6 77 79 07 e5 a9 34 5c 8b 0e c7 |.O....wy...4\...| + 00000280 8c ee a0 c2 ce d2 69 e2 a0 bd b3 23 ad 15 17 25 |......i....#...%| + 00000290 62 0b b3 e0 0f a4 43 34 24 de 17 09 73 5d 15 67 |b.....C4$...s].g| + 000002a0 dd 29 eb 91 34 fe 95 07 c0 6e ae ad 8b 1c c5 f7 |.)..4....n......| + 000002b0 e6 5e a2 09 65 d1 ce 2e 48 6d b6 d8 82 fa 4d 60 |.^..e...Hm....M`| + 000002c0 f0 35 4b 78 9c 18 c3 c1 e4 41 ce 8e 6a 5b ae b4 |.5Kx.....A..j[..| + 000002d0 9d 80 e8 09 68 b3 eb ac a4 9d 74 36 7f c3 bb 17 |....h.....t6....| + 000002e0 f6 19 c5 db db 99 cc 7f 8b e4 03 75 fa 21 a2 ae |...........u.!..| + 000002f0 a6 a3 2c 56 6c 67 e6 31 69 54 2b 89 74 3f 21 99 |..,Vlg.1iT+.t?!.| + 00000300 78 bb da 36 77 6c a0 92 ce fc 41 d1 03 01 59 e5 |x..6wl....A...Y.| + 00000310 ef de d3 df 5a 8a f2 e2 a0 bd b3 23 ad 15 17 25 |....Z......#...%| + 00000320 62 0b b3 e0 0f a4 43 b0 7f 70 ef 1f 2b 0c 4e 51 |b.....C..p..+.NQ| + 00000330 26 4a e7 0b db 41 85 b7 32 33 bc 18 2f c2 a4 db |&J...A..23../...| + 00000340 7f db 4d fe 88 c4 2f 68 9a 4f 29 55 4b 1c 96 b6 |..M.../h.O)UK...| + 00000350 16 fc 5a 61 67 95 96 03 ea 3d bf 84 d7 9a d3 12 |..Zag....=......| + 00000360 f2 f1 f6 51 8c 02 6a e5 17 57 c3 ab 34 bf 17 61 |...Q..j..W..4..a| + 00000370 7c ae a6 c1 f0 70 a5 6e fe fd bd 8e 06 1e 10 f2 ||....p.n........| + 00000380 3c 7d f7 af 66 f9 44 c6 ec 62 9a 90 1c be 2c fb |<}..f.D..b....,.| + 00000390 b0 e6 9a 24 b0 09 fa ea 9d d3 8b 16 92 c7 da f1 |...$............| + 000003a0 f9 9c eb 38 d2 01 d2 51 d4 ad 5e f3 e3 63 20 a9 |...8...Q..^..c .| + 000003b0 aa 87 e2 18 9f b4 04 35 dd 4e af 43 00 95 b2 fe |.......5.N.C....| + 000003c0 68 02 0b c8 f0 ad 44 e4 c2 f7 0d 8b 45 1c a1 d2 |h.....D.....E...| + 000003d0 49 aa 3b db b2 5a 73 7b ae 7a 17 b4 bf f0 1b a9 |I.;..Zs{.z......| + 000003e0 16 a1 e8 b6 89 d8 b1 d2 a9 80 df b8 59 fe f8 8d |............Y...| + 000003f0 03 e4 af c5 c2 0b c3 31 d9 95 06 8f b5 65 7d 85 |.......1.....e}.| + 00000400 a0 b1 a9 ea d6 bb 43 26 4a fc 3e d1 74 a9 6d 13 |......C&J.>.t.m.| + 00000410 85 60 dc 0b e3 d4 14 f4 07 0f b3 57 09 27 5f b2 |.`.........W.'_.| + 00000420 dd 70 6b d2 45 f1 e7 2d 63 3c cf 59 a1 88 62 21 |.pk.E..-c<.Y..b!| + 00000430 b7 c3 14 ce e8 ab 03 56 7c d6 16 |.......V|..| # --- diff --git a/tests/e2e/test_device_manager.py b/tests/e2e/test_device_manager.py index 449d6fbe8..951c2abc2 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -291,6 +291,7 @@ async def test_v1_device( local_response_queue.put_nowait( response_builder.build_v1(protocol=RoborockMessageProtocol.HELLO_RESPONSE, payload=b"ok") ) + local_response_queue.put_nowait(response_builder.build_v1_rpc(data={"id": 9101, "result": [mock_data.STATUS]})) device_manager = await device_manager_factory(user_params) @@ -310,18 +311,10 @@ async def test_v1_device( assert device.v1_properties.device_features.is_customized_clean_supported assert not device.v1_properties.device_features.is_matter_supported - # In the previous test, the dock information is fetched and has the side effect of - # populating the status trait. This test gets dock information from the cache so - # we have to manually refresh status the first time (like other traits). + # Dock type is loaded from the cache, but status is refreshed during trait discovery + # so AM dock capabilities can be evaluated. assert device.v1_properties assert device.v1_properties.status - assert device.v1_properties.status.state_name is None - - # Exercise a GET_STATUS call. id is deterministic based on deterministic_message_fixtures - local_response_queue.put_nowait(response_builder.build_v1_rpc(data={"id": 9101, "result": [mock_data.STATUS]})) - - # Verify GET_STATUS response - await device.v1_properties.status.refresh() assert device.v1_properties.status.state_name == "charging" assert snapshot == log diff --git a/tests/test_supported_features.py b/tests/test_supported_features.py index 8f3aa853c..e72d2e2ed 100644 --- a/tests/test_supported_features.py +++ b/tests/test_supported_features.py @@ -5,7 +5,8 @@ from roborock import SHORT_MODEL_TO_ENUM from roborock.data.code_mappings import RoborockProductNickname -from roborock.device_features import DeviceFeatures +from roborock.data.v1 import RoborockDockTypeCode +from roborock.device_features import DeviceFeatures, RoborockDockFeatures, is_valid_dock, is_wash_n_fill_dock from tests import mock_data @@ -79,6 +80,76 @@ def test_new_feature_str_missing(): assert not device_features.is_show_clean_finish_reason_supported +@pytest.mark.parametrize( + ("dock_type", "is_collectable", "is_washable"), + [ + (RoborockDockTypeCode.unknown, False, False), + (RoborockDockTypeCode.o0_dock, False, False), + (RoborockDockTypeCode.o1_dock, True, False), + (RoborockDockTypeCode.o2_dock, False, True), + (RoborockDockTypeCode.o3_dock, True, True), + (RoborockDockTypeCode.oc_dock, True, False), + (RoborockDockTypeCode.o3_plus_dock, True, True), + (RoborockDockTypeCode.o4_dock, True, True), + (RoborockDockTypeCode.pearl_dock, True, True), + (RoborockDockTypeCode.pearl_plus_dock, True, True), + (RoborockDockTypeCode.o5_dock, True, True), + (RoborockDockTypeCode.shell_2s_dock, True, True), + (RoborockDockTypeCode.couple_dock, True, True), + (RoborockDockTypeCode.shell_3_dock, True, True), + (RoborockDockTypeCode.shell_2c_dock, True, True), + (RoborockDockTypeCode.shell_3s_dock, True, True), + (RoborockDockTypeCode.k1_dock, True, True), + (RoborockDockTypeCode.o6_dock, True, True), + (RoborockDockTypeCode.k1c_dock, True, True), + (RoborockDockTypeCode.k1s_dock, True, True), + (RoborockDockTypeCode.shell_e_dock, True, True), + (RoborockDockTypeCode.shell_2e_dock, True, True), + (RoborockDockTypeCode.shell_3c_dock, True, True), + (RoborockDockTypeCode.type_27_dock, True, True), + (RoborockDockTypeCode.k1c_lite_dock, True, True), + (RoborockDockTypeCode.shell_2e_lite_dock, True, True), + ], +) +def test_dock_features(dock_type: RoborockDockTypeCode, is_collectable: bool, is_washable: bool) -> None: + dock_features = RoborockDockFeatures.from_dock_type(dock_type) + + assert dock_features.has_dock is (dock_type not in {RoborockDockTypeCode.unknown, RoborockDockTypeCode.o0_dock}) + assert dock_features.is_collectable is is_collectable + assert dock_features.is_washable is is_washable + assert is_valid_dock(dock_type) is dock_features.has_dock + assert is_wash_n_fill_dock(dock_type) is dock_features.is_washable + + +def test_dock_feature_flags_from_rr_api() -> None: + """Verify dock-specific feature flags that are not currently trait gates.""" + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.shell_3s_dock).is_auto_sterilize_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o4_dock).is_cleaning_brush_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o4_dock).is_clean_fluid_auto_delivery_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.couple_dock).is_hatch_door_dock_cool_fan_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.shell_2e_lite_dock).is_special_support_wash_temp + + +def test_dock_feature_flags_with_am_variants_from_rr_api() -> None: + """Verify dock flags where the app distinguishes AM and non-AM docks by the same type code.""" + assert not RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.shell_3_dock + ).is_clean_fluid_auto_delivery_supported + assert RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.shell_3_dock, has_am=True + ).is_clean_fluid_auto_delivery_supported + + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.shell_3_dock).is_clean_carousel_self_clean_supported + assert not RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.shell_3_dock, has_am=True + ).is_clean_carousel_self_clean_supported + + assert not RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o5_dock).is_water_updown_drain_supported + assert RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.o5_dock, has_am=True + ).is_water_updown_drain_supported + + @pytest.mark.parametrize( ("device_filename"), list(mock_data.DEVICE_PRODUCT_PAIRS.keys()), From 862aab58e298160586c8930720d274703c6dd951 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 8 Jul 2026 09:49:12 +0000 Subject: [PATCH 18/21] 5.28.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1d7c50d..799adb5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v5.28.0 (2026-07-08) + +### Features + +- Add better dock feature control + ([#867](https://github.com/Python-roborock/python-roborock/pull/867), + [`73ba2bf`](https://github.com/Python-roborock/python-roborock/commit/73ba2bf152d1c43b4d46d1e7f397043dd195d046)) + + ## v5.27.0 (2026-07-07) ### Documentation diff --git a/pyproject.toml b/pyproject.toml index 7ef08222d..f691cd85f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.27.0" +version = "5.28.0" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" diff --git a/uv.lock b/uv.lock index 6b9a6319f..b831fd697 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.27.0" +version = "5.28.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 89c80743e2c560f36f56e0c17c85c0d14544f406 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 9 Jul 2026 22:17:00 -0700 Subject: [PATCH 19/21] refactor: use stateful fakes and fixtures in integration tests (#868) --- roborock/testing/channel.py | 32 +- roborock/testing/cloud.py | 45 +- roborock/testing/simulator.py | 10 +- .../__snapshots__/test_device_manager.ambr | 16 +- tests/devices/test_device_manager.py | 447 +++++++++--------- 5 files changed, 286 insertions(+), 264 deletions(-) diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 1550393bf..7ffccc2de 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -5,7 +5,7 @@ in-memory replacement for `MqttChannel` and `LocalChannel` during testing. """ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -37,6 +37,9 @@ class FakeChannel(Channel): publish (useful for low-level RPC request/response testing). - **Push unsolicited messages**: Call ``channel.notify_subscribers(msg)`` to simulate the device broadcasting a state change. + - **Intercept published messages**: Register a handler/callback via + ``channel.publish_handler = my_handler`` (e.g. stateful simulator) + to reactively process commands. """ subscribe: Any @@ -49,6 +52,11 @@ def __init__(self, is_local: bool = False): self._is_connected = False self._is_local = is_local + # A callback to intercept published messages (e.g., bound simulator handler). + # Can be either synchronous or asynchronous: Callable[[RoborockMessage], Awaitable[Any]]. + # By default, routes to self._default_publish_handler to handle the response_queue. + self.publish_handler: Callable[[RoborockMessage], Awaitable[Any]] | None = self._default_publish_handler + # Set this to an exception instance to make the next publish raise it. # This is a convenience shortcut; callers can also replace # ``publish.side_effect`` directly for more control. @@ -96,14 +104,16 @@ def is_local_connected(self) -> bool: async def _publish(self, message: RoborockMessage) -> None: """Default publish implementation. - Records the message in ``published_messages`` and, if - ``response_queue`` is non-empty, pops the first response and - delivers it to all current subscribers (simulating a - request/response round-trip). + Records the message in ``published_messages`` and executes ``publish_handler``. """ self.published_messages.append(message) if self.publish_side_effect: raise self.publish_side_effect + if self.publish_handler: + await self.publish_handler(message) + + async def _default_publish_handler(self, message: RoborockMessage) -> None: + """Default handler that pops canned responses from response_queue.""" if self.response_queue: response = self.response_queue.pop(0) self.notify_subscribers(response) @@ -124,3 +134,15 @@ def notify_subscribers(self, message: RoborockMessage) -> None: """ for subscriber in list(self.subscribers): subscriber(message) + + def inject_error(self, exception: Exception) -> None: + """Inject a transient failure into all channel operations (publish, subscribe, connect).""" + self.publish.side_effect = exception + self.subscribe.side_effect = exception + self.connect.side_effect = exception + + def clear_error(self) -> None: + """Restore default success behaviors on all channel operations.""" + self.publish.side_effect = self._publish + self.subscribe.side_effect = self._subscribe + self.connect.side_effect = self._connect diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 704e0f548..9fee2bb2f 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -6,6 +6,7 @@ """ import contextlib +import datetime import re from typing import Any from unittest.mock import AsyncMock, patch @@ -173,6 +174,44 @@ def get_homes_callback(url, **kwargs): callback=get_homes_callback, ) + def get_default_home_data(self) -> HomeData: + """Construct the default HomeData using simulated devices registered in the cloud.""" + devices = [d.device_info for d in self.cloud.simulated_devices.values()] + products = [d.product for d in self.cloud.simulated_devices.values()] + return HomeData( + id=self.cloud.home_id, + name=self.cloud.home_name, + devices=devices, + products=products, + received_devices=[], + rooms=[], + ) + + def set_homes_response( + self, + home_data: HomeData | None = None, + status: int = 200, + ) -> None: + """Easily set the faked response payload for the homes endpoint using a HomeData dataclass. + + If home_data is None, it defaults to the active get_default_home_data() state. + """ + self.homes_status = status + if status != 200: + self.homes_payload_override = None + return + + if home_data is None: + home_data = self.get_default_home_data() + + self.homes_payload_override = { + "api": None, + "code": 200, + "result": home_data.as_dict(), + "status": "ok", + "success": True, + } + class FakeRoborockCloud: """A central state object representing the Roborock Cloud environment under test.""" @@ -234,7 +273,9 @@ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): with aioresponses() as mocked: self.web_api.mock_requests(mocked) - # Patch Channel factories and rate limiters + # Mock sleep logic to speed up tests. + sleep_time = datetime.timedelta(seconds=0.001) + # Patch Channel factories, rate limiters, and backoff sleep intervals with ( patch( "roborock.web_api.RoborockApiClient._login_limiter.try_acquire_async", @@ -243,5 +284,7 @@ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire", return_value=True), patch("roborock.devices.device_manager.create_v1_channel", side_effect=mock_create_v1_channel), patch("roborock.devices.device_manager.create_mqtt_channel", side_effect=mock_create_mqtt_channel), + patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time), + patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time), ): yield diff --git a/roborock/testing/simulator.py b/roborock/testing/simulator.py index caebc0009..6dd05240d 100644 --- a/roborock/testing/simulator.py +++ b/roborock/testing/simulator.py @@ -73,26 +73,20 @@ def __init__( # MQTT channel is always present — all protocols use it. self.mqtt_channel = FakeChannel(is_local=False) - self.mqtt_channel.publish.side_effect = self._handle_mqtt_publish + self.mqtt_channel.publish_handler = self._handle_mqtt_publish # Local channel is only used by V1 devices. A01/B01 (MQTT-only) # simulators should pass has_local_channel=False. self.local_channel: FakeChannel | None = None if has_local_channel: self.local_channel = FakeChannel(is_local=True) - self.local_channel.publish.side_effect = self._handle_local_publish + self.local_channel.publish_handler = self._handle_local_publish async def _handle_local_publish(self, message: RoborockMessage) -> None: assert self.local_channel is not None - self.local_channel.published_messages.append(message) - if self.local_channel.publish_side_effect: - raise self.local_channel.publish_side_effect await self._handle_publish(message, self.local_channel) async def _handle_mqtt_publish(self, message: RoborockMessage) -> None: - self.mqtt_channel.published_messages.append(message) - if self.mqtt_channel.publish_side_effect: - raise self.mqtt_channel.publish_side_effect await self._handle_publish(message, self.mqtt_channel) async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None: diff --git a/tests/devices/__snapshots__/test_device_manager.ambr b/tests/devices/__snapshots__/test_device_manager.ambr index 98017c92e..558e04e89 100644 --- a/tests/devices/__snapshots__/test_device_manager.ambr +++ b/tests/devices/__snapshots__/test_device_manager.ambr @@ -382,7 +382,7 @@ 'debugMode': 0, 'dndEnabled': 0, 'dockErrorStatus': 0, - 'dockType': 3, + 'dockType': 7, 'dss': 169, 'dustCollectionStatus': 0, 'errorCode': 0, @@ -409,7 +409,7 @@ 'washPhase': 0, 'washReady': 0, 'waterBoxCarriageStatus': 1, - 'waterBoxMode': 203, + 'waterBoxMode': 200, 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), @@ -595,18 +595,6 @@ 'receivedDevices': list([ ]), 'rooms': list([ - dict({ - 'id': 2362048, - 'name': '**REDACTED**', - }), - dict({ - 'id': 2362044, - 'name': '**REDACTED**', - }), - dict({ - 'id': 2362041, - 'name': '**REDACTED**', - }), ]), }), }) diff --git a/tests/devices/test_device_manager.py b/tests/devices/test_device_manager.py index bd2ed1256..d5851935b 100644 --- a/tests/devices/test_device_manager.py +++ b/tests/devices/test_device_manager.py @@ -1,120 +1,64 @@ """Tests for the DeviceManager class.""" import asyncio -import datetime -from collections.abc import Generator, Iterator +from collections.abc import Generator +from dataclasses import replace from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch import pytest import syrupy from roborock.data import HomeData, UserData +from roborock.data.containers import HomeDataDevice, HomeDataProduct, RoborockCategory from roborock.devices.cache import InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper from roborock.exceptions import RoborockException, RoborockInvalidCredentials +from roborock.testing import FakeRoborockCloud, V1VacuumSimulator from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) USER_PARAMS = UserParams(username="test_user", user_data=USER_DATA) +HOME_DATA = HomeData.from_dict(mock_data.HOME_DATA_RAW) NETWORK_INFO = mock_data.NETWORK_INFO -@pytest.fixture(autouse=True, name="mqtt_session") -def setup_mqtt_session() -> Generator[Mock, None, None]: - """Fixture to set up the MQTT session for the tests.""" - with patch("roborock.devices.device_manager.create_lazy_mqtt_session") as mock_create_session: - yield mock_create_session +@pytest.fixture(name="cloud") +def cloud_fixture() -> FakeRoborockCloud: + """Fixture to set up FakeRoborockCloud.""" + return FakeRoborockCloud(user_data=USER_DATA) -@pytest.fixture(autouse=True, name="mock_rpc_channel") -def rpc_channel_fixture() -> AsyncMock: - """Fixture to set up the channel for tests.""" - return AsyncMock() +@pytest.fixture(name="fake_device") +def fake_device_fixture(cloud: FakeRoborockCloud) -> V1VacuumSimulator: + """Fixture to set up a stateful V1VacuumSimulator registered to the cloud.""" + device = V1VacuumSimulator( + duid="abc123", + device_info=HOME_DATA.devices[0], + product=HOME_DATA.products[0], + ) + cloud.add_device(device) + return device -@pytest.fixture(autouse=True) -async def discover_features_fixture( - mock_rpc_channel: AsyncMock, -) -> None: - """Fixture to handle device feature discovery.""" - mock_rpc_channel.send_command.side_effect = [ - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - ] - - -@pytest.fixture(autouse=True) -def channel_fixture(mock_rpc_channel: AsyncMock) -> Generator[Mock, None, None]: - """Fixture to set up the local session for the tests.""" - with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: - mock_unsub = Mock() - mock_channel.return_value.subscribe = AsyncMock() - mock_channel.return_value.subscribe.return_value = mock_unsub - mock_channel.return_value.rpc_channel = mock_rpc_channel - yield mock_channel - - -@pytest.fixture(autouse=True) -def mock_sleep() -> Generator[None, None, None]: - """Mock sleep logic to speed up tests.""" - sleep_time = datetime.timedelta(seconds=0.001) - with ( - patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time), - patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time), - ): +@pytest.fixture(name="patch_device_manager") +def patch_device_manager_fixture(cloud: FakeRoborockCloud) -> Generator[None, None, None]: + """Fixture to patch the device manager and HTTP client.""" + with cloud.patch_device_manager(): yield -@pytest.fixture(name="channel_exception") -def channel_failure_exception_fixture(mock_rpc_channel: AsyncMock) -> Exception: - """Fixture that provides the exception to be raised by the failing channel.""" - return RoborockException("Connection failed") - - -@pytest.fixture(name="channel_failure") -def channel_failure_fixture(mock_rpc_channel: AsyncMock, channel_exception: Exception) -> Generator[Mock, None, None]: - """Fixture that makes channel subscribe fail.""" - with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: - mock_channel.return_value.subscribe = AsyncMock(side_effect=channel_exception) - mock_channel.return_value.is_connected = False - mock_channel.return_value.rpc_channel = mock_rpc_channel - yield mock_channel - - -@pytest.fixture(name="home_data_no_devices") -def home_data_no_devices_fixture() -> Iterator[HomeData]: - """Mock home data API that returns no devices.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData( - id=1, - name="Test Home", - devices=[], - products=[], - ) - mock_home_data.return_value = home_data - yield home_data - - -@pytest.fixture(name="home_data") -def home_data_fixture() -> Iterator[HomeData]: - """Mock home data API that returns devices.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) - mock_home_data.return_value = home_data - yield home_data - - -async def test_no_devices(home_data_no_devices: HomeData) -> None: +async def test_no_devices(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test the DeviceManager created with no devices returned from the API.""" - device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() assert devices == [] -async def test_with_device(home_data: HomeData) -> None: +async def test_with_device( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test the DeviceManager created with devices returned from the API.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() @@ -130,7 +74,9 @@ async def test_with_device(home_data: HomeData) -> None: await device_manager.close() -async def test_get_non_existent_device(home_data: HomeData) -> None: +async def test_get_non_existent_device( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test getting a non-existent device.""" device_manager = await create_device_manager(USER_PARAMS) device = await device_manager.get_device("non_existent_duid") @@ -140,7 +86,6 @@ async def test_get_non_existent_device(home_data: HomeData) -> None: async def test_create_home_data_api_exception() -> None: """Test that exceptions from the home data API are propagated through the wrapper.""" - with patch("roborock.devices.device_manager.RoborockApiClient.get_home_data_v3") as mock_get_home_data: mock_get_home_data.side_effect = RoborockException("Test exception") user_params = UserParams(username="test_user", user_data=USER_DATA) @@ -164,7 +109,13 @@ async def test_device_manager_unauthorized_hook() -> None: @pytest.mark.parametrize(("prefer_cache", "expected_call_count"), [(True, 1), (False, 2)]) -async def test_cache_logic(prefer_cache: bool, expected_call_count: int) -> None: +async def test_cache_logic( + cloud: FakeRoborockCloud, + fake_device: V1VacuumSimulator, + patch_device_manager: None, + prefer_cache: bool, + expected_call_count: int, +) -> None: """Test that the cache logic works correctly.""" call_count = 0 @@ -173,15 +124,15 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: call_count += 1 return HomeData.from_dict(mock_data.HOME_DATA_RAW) - # First call happens during create_device_manager initialization with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, ): device_manager = await create_device_manager(USER_PARAMS, cache=InMemoryCache()) + # First call happens during create_device_manager initialization assert call_count == 1 - # Second call should use cache, not increment call_count + # Second call should use cache, not increment call_count if prefer_cache=True devices2 = await device_manager.discover_devices(prefer_cache=prefer_cache) assert call_count == expected_call_count assert len(devices2) == 1 @@ -193,9 +144,10 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: await device_manager.close() -async def test_home_data_api_fails_with_cache_fallback() -> None: +async def test_home_data_api_fails_with_cache_fallback( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that home data exceptions may still fall back to use the cache when available.""" - cache = InMemoryCache() cache_data = await cache.get() cache_data.home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) @@ -216,10 +168,13 @@ async def test_home_data_api_fails_with_cache_fallback() -> None: await device_manager.close() -async def test_ready_callback(home_data: HomeData) -> None: +async def test_ready_callback( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that the ready callback is invoked when a device connects.""" ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) + await device_manager.get_devices() # Callback should be called for the discovered device assert len(ready_devices) == 1 @@ -236,41 +191,32 @@ async def test_ready_callback(home_data: HomeData) -> None: await device_manager.close() -@pytest.mark.parametrize( - ("channel_exception"), - [ - RoborockException("Connection failed"), - ], -) -async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: +async def test_start_connect_failure( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that start_connect retries when connection fails.""" + # Make connection fail initially + channel_exception = RoborockException("Connection failed") + fake_device.mqtt_channel.inject_error(channel_exception) + assert fake_device.local_channel is not None + fake_device.local_channel.inject_error(channel_exception) + ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) devices = await device_manager.get_devices() - # The device should attempt to connect in the background at least once - # by the time this function returns. - subscribe_mock = channel_failure.return_value.subscribe - assert subscribe_mock.call_count > 0 - # Device should exist but not be connected assert len(devices) == 1 assert not devices[0].is_connected assert not ready_devices - # Verify retry attempts - assert channel_failure.return_value.subscribe.call_count >= 1 - - # Reset the mock channel so that it succeeds on the next attempt - mock_unsub = Mock() - subscribe_mock = AsyncMock() - subscribe_mock.return_value = mock_unsub - channel_failure.return_value.subscribe = subscribe_mock - channel_failure.return_value.is_connected = True + # Clear error so connection succeeds on retry + fake_device.mqtt_channel.clear_error() + fake_device.local_channel.clear_error() - # Wait for the device to attempt to connect again + # Wait for the device to attempt to connect again and succeed attempts = 0 - while subscribe_mock.call_count < 1: + while not devices[0].is_connected: await asyncio.sleep(0.01) attempts += 1 assert attempts < 10, "Device did not connect after multiple attempts" @@ -280,10 +226,11 @@ async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, assert len(ready_devices) == 1 await device_manager.close() - assert mock_unsub.call_count == 1 -async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: +async def test_rediscover_devices( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that we can discover devices multiple times and discover new devices.""" raw_devices: list[dict[str, Any]] = mock_data.HOME_DATA_RAW["devices"] assert len(raw_devices) > 0 @@ -291,8 +238,7 @@ async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: home_data_responses = [ HomeData.from_dict(mock_data.HOME_DATA_RAW), - # New device added on second call. We make a copy and updated fields to simulate - # a new device. + # New device added on second call. HomeData.from_dict( { **mock_data.HOME_DATA_RAW, @@ -310,19 +256,31 @@ async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: ), ] - mock_rpc_channel.send_command.side_effect = [ - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - # Device #2 - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - ] - async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: nonlocal home_data_responses return home_data_responses.pop(0) - # First call happens during create_device_manager initialization + # The new device is also a V1 vacuum simulator + new_device_product = HomeDataProduct( + id="product-id-123", + name="New Device", + model="roborock.newmodel.v1", + category=RoborockCategory.VACUUM, + ) + fake_device2 = V1VacuumSimulator( + duid="new_device_duid", + device_info=HomeDataDevice( + duid="new_device_duid", + name="New Device", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-123", + sn="fake_sn_new", + pv="1.0", + ), + product=new_device_product, + ) + cloud.add_device(fake_device2) + with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, @@ -344,26 +302,31 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: assert device_1.name == "Roborock S7 MaxV" new_device = await device_manager.get_device("new_device_duid") - assert new_device + assert new_device is not None assert new_device.name == "New Device" - # Ensure closing again works without error await device_manager.close() -@pytest.mark.parametrize( - ("channel_exception"), - [ - Exception("Unexpected error"), - ], -) -async def test_start_connect_unexpected_error(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: +async def test_start_connect_unexpected_error( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that some unexpected errors from start_connect are propagated.""" + # Raise generic Exception + fake_device.mqtt_channel.inject_error(Exception("Unexpected error")) + assert fake_device.local_channel is not None + fake_device.local_channel.inject_error(Exception("Unexpected error")) + with pytest.raises(Exception, match="Unexpected error"): await create_device_manager(USER_PARAMS) -async def test_diagnostics_collection(home_data: HomeData, snapshot: syrupy.SnapshotAssertion) -> None: +async def test_diagnostics_collection( + cloud: FakeRoborockCloud, + fake_device: V1VacuumSimulator, + patch_device_manager: None, + snapshot: syrupy.SnapshotAssertion, +) -> None: """Test that diagnostics are collected correctly in the DeviceManager.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() @@ -381,107 +344,119 @@ async def test_diagnostics_collection(home_data: HomeData, snapshot: syrupy.Snap await device_manager.close() -async def test_unsupported_protocol_version() -> None: +async def test_unsupported_protocol_version(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test the DeviceManager with some supported and unsupported product IDs.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict( - { - "id": 1, - "name": "Test Home", - "devices": [ - { - "duid": "device-uid-1", - "name": "Device 1", - "pv": "1.0", - "productId": "product-id-1", - "localKey": mock_data.LOCAL_KEY, - }, - { - "duid": "device-uid-2", - "name": "Device 2", - "pv": "unknown-pv", # Fake new protocol version we've never seen - "productId": "product-id-2", - "localKey": mock_data.LOCAL_KEY, - }, - ], - "products": [ - { - "id": "product-id-1", - "name": "Roborock S7 MaxV", - "model": "roborock.vacuum.a27", - "category": "robot.vacuum.cleaner", - }, - { - "id": "product-id-2", - "name": "New Roborock Model", - "model": "roborock.vacuum.newmodel", - "category": "robot.vacuum.cleaner", - }, - ], - } - ) - mock_home_data.return_value = home_data + fake_device = V1VacuumSimulator( + duid="device-uid-1", + device_info=HomeDataDevice( + duid="device-uid-1", + name="Device 1", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-1", + pv="1.0", + ), + product=HomeDataProduct( + id="product-id-1", + name="Roborock S7 MaxV", + model="roborock.vacuum.a27", + category=RoborockCategory.VACUUM, + ), + ) + cloud.add_device(fake_device) + + # Fetch default home data and modify it + home_data = cloud.web_api.get_default_home_data() + modified_home_data = replace( + home_data, + devices=list(home_data.devices) + + [ + HomeDataDevice( + duid="device-uid-2", + name="Device 2", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-2", + pv="unknown-pv", + ), + ], + products=list(home_data.products) + + [ + HomeDataProduct( + id="product-id-2", + name="New Roborock Model", + model="roborock.vacuum.newmodel", + category=RoborockCategory.VACUUM, + ), + ], + ) + cloud.web_api.set_homes_response(modified_home_data) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + assert [device.duid for device in devices] == ["device-uid-1"] - device_manager = await create_device_manager(USER_PARAMS) - # Only the supported device should be created. The other device is ignored - devices = await device_manager.get_devices() - assert [device.duid for device in devices] == ["device-uid-1"] + diagnostics = device_manager.diagnostic_data() + diagnostics_data = diagnostics.get("diagnostics") + assert diagnostics_data + assert diagnostics_data.get("supported_devices") == {"1.0": 1} + assert diagnostics_data.get("unsupported_devices") == {"unknown-pv": 1} - # Verify diagnostics - diagnostics = device_manager.diagnostic_data() - diagnostics_data = diagnostics.get("diagnostics") - assert diagnostics_data - assert diagnostics_data.get("supported_devices") == {"1.0": 1} - assert diagnostics_data.get("unsupported_devices") == {"unknown-pv": 1} + await device_manager.close() -async def test_unsupported_v1_category() -> None: +async def test_unsupported_v1_category(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test that non-vacuum V1 devices are skipped as unsupported.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict( - { - "id": 1, - "name": "Test Home", - "devices": [ - { - "duid": "device-uid-1", - "name": "Device 1", - "pv": "1.0", - "productId": "product-id-1", - "localKey": mock_data.LOCAL_KEY, - }, - { - "duid": "device-uid-2", - "name": "Device 2", - "pv": "1.0", - "productId": "product-id-2", - "localKey": mock_data.LOCAL_KEY, - }, - ], - "products": [ - { - "id": "product-id-1", - "name": "Roborock S7 MaxV", - "model": "roborock.vacuum.a27", - "category": "robot.vacuum.cleaner", - }, - { - "id": "product-id-2", - "name": "Roborock RockNeo", - "model": "roborock.mower.q105", - "category": "roborock.mower", - }, - ], - } - ) - mock_home_data.return_value = home_data - - device_manager = await create_device_manager(USER_PARAMS) - devices = await device_manager.get_devices() - assert [device.duid for device in devices] == ["device-uid-1"] - - diagnostics = device_manager.diagnostic_data() - diagnostics_data = diagnostics.get("diagnostics") - assert diagnostics_data - assert diagnostics_data.get("supported_devices") == {"1.0": 1} - assert diagnostics_data.get("unsupported_devices") == {"1.0": 1} + fake_device = V1VacuumSimulator( + duid="device-uid-1", + device_info=HomeDataDevice( + duid="device-uid-1", + name="Device 1", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-1", + pv="1.0", + ), + product=HomeDataProduct( + id="product-id-1", + name="Roborock S7 MaxV", + model="roborock.vacuum.a27", + category=RoborockCategory.VACUUM, + ), + ) + cloud.add_device(fake_device) + + # Fetch default home data and modify it + home_data = cloud.web_api.get_default_home_data() + modified_home_data = replace( + home_data, + devices=list(home_data.devices) + + [ + HomeDataDevice( + duid="device-uid-2", + name="Device 2", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-2", + pv="1.0", + ), + ], + products=list(home_data.products) + + [ + HomeDataProduct( + id="product-id-2", + name="Roborock RockNeo", + model="roborock.mower.q105", + category=RoborockCategory.MOWER, + ), + ], + ) + cloud.web_api.set_homes_response(modified_home_data) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + assert [device.duid for device in devices] == ["device-uid-1"] + + diagnostics = device_manager.diagnostic_data() + diagnostics_data = diagnostics.get("diagnostics") + assert diagnostics_data + assert diagnostics_data.get("supported_devices") == {"1.0": 1} + assert diagnostics_data.get("unsupported_devices") == {"1.0": 1} + + await device_manager.close() From 784321ee2b5a11a6ad1e7ed2551ebb24b959e24d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jul 2026 14:16:44 -0700 Subject: [PATCH 20/21] feat: implement Q10VacuumSimulator and export it --- roborock/testing/__init__.py | 6 + roborock/testing/b01_q10_simulator.py | 213 ++++++++++++++++++++++++++ roborock/testing/cloud.py | 16 +- tests/testing/test_q10_simulator.py | 162 ++++++++++++++++++++ 4 files changed, 389 insertions(+), 8 deletions(-) create mode 100644 roborock/testing/b01_q10_simulator.py create mode 100644 tests/testing/test_q10_simulator.py diff --git a/roborock/testing/__init__.py b/roborock/testing/__init__.py index 4eed0b52b..12e86e562 100644 --- a/roborock/testing/__init__.py +++ b/roborock/testing/__init__.py @@ -63,6 +63,10 @@ async def test_start_vacuum_service(): ``` """ +from roborock.testing.b01_q10_simulator import ( + DEFAULT_Q10_STATUS, + Q10VacuumSimulator, +) from roborock.testing.channel import FakeChannel from roborock.testing.cloud import FakeRoborockCloud, FakeWebApiClient from roborock.testing.simulator import ( @@ -92,10 +96,12 @@ async def test_start_vacuum_service(): "DEFAULT_LOCAL_KEY", "DEFAULT_NETWORK_INFO", "DEFAULT_PRODUCT_ID", + "DEFAULT_Q10_STATUS", "DEFAULT_STATUS", "FakeChannel", "FakeRoborockCloud", "FakeWebApiClient", + "Q10VacuumSimulator", "RoborockDeviceSimulator", "V1VacuumSimulator", ] diff --git a/roborock/testing/b01_q10_simulator.py b/roborock/testing/b01_q10_simulator.py new file mode 100644 index 000000000..859619dc4 --- /dev/null +++ b/roborock/testing/b01_q10_simulator.py @@ -0,0 +1,213 @@ +"""Stateful simulator for Roborock Q10 (B01) devices.""" + +import copy +import json +import logging +from typing import Any + +from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from roborock.testing.simulator import RoborockDeviceSimulator + +_LOGGER = logging.getLogger(__name__) + +B01_VERSION = b"B01" +DEFAULT_PRODUCT_ID = "product-id-q10" + +DEFAULT_Q10_STATUS = { + 101: { + 104: 0, + 105: False, + 109: "us", + 207: 0, + 25: 1, + 26: 74, + 29: 0, + 30: 0, + 31: 0, + 37: 1, + 40: 1, + 45: 0, + 47: 0, + 50: 0, + 51: True, + 53: False, + 6: 0, + 60: 1, + 67: 0, + 7: 0, + 76: 0, + 78: 0, + 79: {"timeZoneCity": "America/Los_Angeles", "timeZoneSec": -28800}, + 80: 0, + 81: {"ipAdress": "1.1.1.2", "mac": "99:AA:88:BB:77:CC", "signal": -50, "wifiName": "wifi-network-name"}, + 83: 1, + 86: 1, + 87: 100, + 88: 0, + 90: 0, + 92: {"disturb_dust_enable": 1, "disturb_light": 1, "disturb_resume_clean": 1, "disturb_voice": 1}, + 93: 1, + 96: 0, + }, + 121: 8, + 122: 100, + 123: 2, + 124: 1, + 125: 0, + 126: 0, + 127: 0, + 136: 1, + 137: 1, + 138: 0, + 139: 5, +} + + +class Q10VacuumSimulator(RoborockDeviceSimulator): + """Stateful firmware simulator for Roborock Q10 (B01 ss07) devices.""" + + def __init__( + self, + duid: str, + status: dict[int, Any] | None = None, + device_info: HomeDataDevice | None = None, + product: HomeDataProduct | None = None, + ): + product = product or HomeDataProduct( + id=DEFAULT_PRODUCT_ID, + name="Roborock Q10", + model="roborock.vacuum.ss07", + category=RoborockCategory.VACUUM, + ) + device_info = device_info or HomeDataDevice( + duid=duid, + name="Roborock Q10", + local_key="fake_localkey_16bytes", + product_id=product.id, + sn="fake_serial_number", + pv="B01", + ) + super().__init__(duid, device_info, product, has_local_channel=False) + self.status = copy.deepcopy(status or DEFAULT_Q10_STATUS) + + async def _handle_publish(self, message: RoborockMessage, channel: Any) -> None: + """Process incoming Q10 RPC command payload.""" + if not message.payload: + return + + try: + payload = json.loads(message.payload.decode()) + except (json.JSONDecodeError, UnicodeDecodeError) as ex: + _LOGGER.warning("Simulator failed to parse incoming B01 payload: %s", ex) + return + + datapoints = payload.get("dps", {}) + if not isinstance(datapoints, dict): + return + + updated_dps: dict[int, Any] = {} + + for code_str, params in datapoints.items(): + try: + code = int(code_str) + except ValueError: + continue + + # Command: REQUEST_DPS (102) -> trigger status push + if code == 102: + # REQUEST_DPS triggers a dump of all status properties + # (both root properties and nested properties under 101) + self.trigger_push_update() + continue + + # Command: START_CLEAN (201) + elif code == 201: + # 1 = sweep/mop, cmd inside dict = segment clean + self.status[121] = 5 # YXDeviceState.CLEANING + if isinstance(params, dict) and "cmd" in params: + self.status[138] = params.get("cmd", 0) # clean_task_type + else: + self.status[138] = 1 # CLEANING + updated_dps[121] = self.status[121] + updated_dps[138] = self.status[138] + + # Command: START_BACK (202) + elif code == 202: + # 5 = returning to charge + self.status[121] = 6 # YXDeviceState.RETURNING_HOME + self.status[139] = 5 # BACK_CHARGING + updated_dps[121] = self.status[121] + updated_dps[139] = self.status[139] + + # Command: START_DOCK_TASK (203) + elif code == 203: + # Empty dustbin + self.status[121] = 22 # EMPTYING_THE_BIN + updated_dps[121] = self.status[121] + + # Command: PAUSE (204) + elif code == 204: + self.status[121] = 10 # YXDeviceState.PAUSED + updated_dps[121] = self.status[121] + + # Command: RESUME (205) + elif code == 205: + self.status[121] = 5 # YXDeviceState.CLEANING + updated_dps[121] = self.status[121] + + # Command: STOP (206) + elif code == 206: + self.status[121] = 3 # YXDeviceState.IDLE + self.status[138] = 0 # YXDeviceCleanTask.UNKNOWN/IDLE + updated_dps[121] = self.status[121] + updated_dps[138] = self.status[138] + + # Command: FAN_LEVEL (123) + elif code == 123: + self.status[123] = params + updated_dps[123] = params + + # Command: WATER_LEVEL (124) + elif code == 124: + self.status[124] = params + updated_dps[124] = params + + # Command: CLEAN_MODE (137) + elif code == 137: + self.status[137] = params + updated_dps[137] = params + + # Custom settings properties routed to sub-traits (DND, child lock, volume, dust collections) + elif code in (26, 47, 25, 37, 50): + # These live in the nested dpCommon (101) dict + common_dict = self.status[101] + if isinstance(common_dict, dict): + common_dict[code] = params + # We return them nested under 101 + if 101 not in updated_dps: + updated_dps[101] = {} + updated_dps[101][code] = params + + if updated_dps: + # Send the changed datapoints back to the client + self.push_dps(updated_dps) + + def push_dps(self, dps_updates: dict[int, Any]) -> None: + """Push a set of status datapoint changes to the client.""" + payload = { + "dps": { + str(k): ({str(sub_k): sub_v for sub_k, sub_v in v.items()} if isinstance(v, dict) and k == 101 else v) + for k, v in dps_updates.items() + } + } + message = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + version=B01_VERSION, + payload=json.dumps(payload).encode("utf-8"), + ) + self.mqtt_channel.notify_subscribers(message) + + def trigger_push_update(self) -> None: + """Send the full status dump to the client.""" + self.push_dps(self.status) diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 9fee2bb2f..03b596b7e 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -242,11 +242,6 @@ def patch_device_manager(self): # Wrapper function for create_v1_channel def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache): - if device.pv in ("A01", "B01"): - raise NotImplementedError( - f"Simulating protocol {device.pv} is not yet supported. " - "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." - ) server = self.simulated_devices.get(device.duid) if server is not None: if not isinstance(server, V1VacuumSimulator): @@ -255,18 +250,23 @@ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_ f"simulator, but create_v1_channel requires a V1VacuumSimulator." ) return server.v1_channel + if device.pv in ("A01", "B01"): + raise NotImplementedError( + f"Simulating protocol {device.pv} is not yet supported. " + "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + ) return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache) # Wrapper function for create_mqtt_channel def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): + server = self.simulated_devices.get(device.duid) + if server: + return server.mqtt_channel if device.pv in ("A01", "B01"): raise NotImplementedError( f"Simulating protocol {device.pv} is not yet supported. " "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." ) - server = self.simulated_devices.get(device.duid) - if server: - return server.mqtt_channel return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) # Route Web requests using the dynamic FakeWebApiClient diff --git a/tests/testing/test_q10_simulator.py b/tests/testing/test_q10_simulator.py new file mode 100644 index 000000000..ed6e84168 --- /dev/null +++ b/tests/testing/test_q10_simulator.py @@ -0,0 +1,162 @@ +"""Unit tests for Q10VacuumSimulator.""" + +import json +from typing import Any + +import pytest + +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from roborock.testing import DEFAULT_Q10_STATUS, Q10VacuumSimulator + +B01_VERSION = b"B01" + + +@pytest.fixture(name="q10_sim") +def q10_sim_fixture() -> Q10VacuumSimulator: + """Fixture to create a Q10VacuumSimulator instance.""" + return Q10VacuumSimulator(duid="test_q10_duid") + + +def test_default_status(q10_sim: Q10VacuumSimulator) -> None: + """Test that the simulator initializes with default Q10 status.""" + assert q10_sim.status == DEFAULT_Q10_STATUS + assert q10_sim.status[121] == 8 # Charging + assert q10_sim.status[122] == 100 # Battery + assert isinstance(q10_sim.status[101], dict) + assert q10_sim.status[101][26] == 74 # Volume + + +async def test_request_dps(q10_sim: Q10VacuumSimulator) -> None: + """Test that the simulator responds to REQUEST_DPS with a full status push.""" + notified_messages = [] + + def mock_notify(message: RoborockMessage) -> None: + notified_messages.append(message) + + await q10_sim.mqtt_channel.subscribe(mock_notify) + + # Send REQUEST_DPS command (102) + req_payload: dict[str, Any] = {"dps": {"102": {}}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + + assert len(notified_messages) == 1 + resp_msg = notified_messages[0] + assert resp_msg.protocol == RoborockMessageProtocol.RPC_RESPONSE + assert resp_msg.version == B01_VERSION + assert resp_msg.payload is not None + + resp_payload = json.loads(resp_msg.payload.decode()) + assert "dps" in resp_payload + # The response is keyed by strings representing DP integer codes + assert resp_payload["dps"]["121"] == 8 + assert resp_payload["dps"]["122"] == 100 + # Nested dictionary 101 must also have string keys + assert resp_payload["dps"]["101"]["26"] == 74 + + +async def test_clean_pause_resume_stop(q10_sim: Q10VacuumSimulator) -> None: + """Test clean, pause, resume, and stop command sequences on Q10 simulator.""" + notified_messages = [] + + def mock_notify(message: RoborockMessage) -> None: + notified_messages.append(message) + + await q10_sim.mqtt_channel.subscribe(mock_notify) + + # 1. Start Clean + req_payload: dict[str, Any] = {"dps": {"201": 1}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 5 # Cleaning + assert q10_sim.status[138] == 1 # Standard Clean + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 5, "138": 1} + + # 2. Pause + req_payload = {"dps": {"204": 0}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 10 # Paused + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 10} + + # 3. Resume + req_payload = {"dps": {"205": 0}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 5 # Cleaning + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 5} + + # 4. Stop + req_payload = {"dps": {"206": 0}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 3 # Idle + assert q10_sim.status[138] == 0 # Task type idle + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 3, "138": 0} + + +async def test_setters(q10_sim: Q10VacuumSimulator) -> None: + """Test setting fan speed, water level, and sound volume.""" + notified_messages = [] + + def mock_notify(message: RoborockMessage) -> None: + notified_messages.append(message) + + await q10_sim.mqtt_channel.subscribe(mock_notify) + + # Set Fan level (123) to TURBO (3) + req_payload: dict[str, Any] = {"dps": {"123": 3}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[123] == 3 + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"123": 3} + + # Set Volume (26) to 50 + req_payload = {"dps": {"26": 50}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert isinstance(q10_sim.status[101], dict) + assert q10_sim.status[101][26] == 50 + # The output payload should map nested 101 dict keys to string + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"101": {"26": 50}} From d2fa621813b670dd6bbb11e3e31626a89055fa99 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jul 2026 14:20:00 -0700 Subject: [PATCH 21/21] feat: add Q10 integration test and support stream streaming on FakeChannel --- roborock/testing/channel.py | 19 ++++++- roborock/testing/cloud.py | 11 +++- tests/devices/test_device_manager.py | 79 +++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 7ffccc2de..022d449bd 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -5,7 +5,8 @@ in-memory replacement for `MqttChannel` and `LocalChannel` during testing. """ -from collections.abc import Awaitable, Callable +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -53,7 +54,7 @@ def __init__(self, is_local: bool = False): self._is_local = is_local # A callback to intercept published messages (e.g., bound simulator handler). - # Can be either synchronous or asynchronous: Callable[[RoborockMessage], Awaitable[Any]]. + # Must be asynchronous: Callable[[RoborockMessage], Awaitable[Any]]. # By default, routes to self._default_publish_handler to handle the response_queue. self.publish_handler: Callable[[RoborockMessage], Awaitable[Any]] | None = self._default_publish_handler @@ -146,3 +147,17 @@ def clear_error(self) -> None: self.publish.side_effect = self._publish self.subscribe.side_effect = self._subscribe self.connect.side_effect = self._connect + + async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]: + """Stream messages received via this channel.""" + queue: asyncio.Queue[RoborockMessage] = asyncio.Queue() + + def callback(message: RoborockMessage) -> None: + queue.put_nowait(message) + + unsub = await self.subscribe(callback) + try: + while True: + yield await queue.get() + finally: + unsub() diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 03b596b7e..61a0c5435 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -16,6 +16,7 @@ from roborock.data import HomeData, Reference, RRiot, UserData from roborock.devices.rpc.v1_channel import create_v1_channel as original_create_v1_channel from roborock.devices.transport.mqtt_channel import create_mqtt_channel as original_create_mqtt_channel +from roborock.testing.b01_q10_simulator import Q10VacuumSimulator from roborock.testing.simulator import RoborockDeviceSimulator from roborock.testing.v1_simulator import V1VacuumSimulator @@ -253,7 +254,7 @@ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_ if device.pv in ("A01", "B01"): raise NotImplementedError( f"Simulating protocol {device.pv} is not yet supported. " - "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + "TODO: Implement stateful simulators for B01 (Q7) and A01 (Zeo/Dyad) devices." ) return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache) @@ -261,11 +262,17 @@ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): server = self.simulated_devices.get(device.duid) if server: + if device.pv == "B01" and not isinstance(server, Q10VacuumSimulator): + raise NotImplementedError( + f"Simulating protocol {device.pv} is not yet supported. " + "Only Q10VacuumSimulator is supported for B01 protocols currently." + ) + server.mqtt_channel._is_connected = True return server.mqtt_channel if device.pv in ("A01", "B01"): raise NotImplementedError( f"Simulating protocol {device.pv} is not yet supported. " - "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + "TODO: Implement stateful simulators for B01 (Q7) and A01 (Zeo/Dyad) devices." ) return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) diff --git a/tests/devices/test_device_manager.py b/tests/devices/test_device_manager.py index d5851935b..734c20657 100644 --- a/tests/devices/test_device_manager.py +++ b/tests/devices/test_device_manager.py @@ -15,7 +15,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper from roborock.exceptions import RoborockException, RoborockInvalidCredentials -from roborock.testing import FakeRoborockCloud, V1VacuumSimulator +from roborock.testing import FakeRoborockCloud, Q10VacuumSimulator, V1VacuumSimulator from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) @@ -56,6 +56,83 @@ async def test_no_devices(cloud: FakeRoborockCloud, patch_device_manager: None) assert devices == [] +async def test_with_q10_device(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: + """Test DeviceManager with a Q10 device simulator registered.""" + product = HomeDataProduct( + id="product-id-q10", + name="Roborock Q10", + model="roborock.vacuum.ss07", + category=RoborockCategory.VACUUM, + ) + device_info = HomeDataDevice( + duid="q10_duid", + name="My Q10", + local_key="key123key123key1", + product_id=product.id, + sn="q10_serial", + pv="B01", + ) + + home_data = cloud.web_api.get_default_home_data() + home_data.devices.append(device_info) + home_data.products.append(product) + cloud.web_api.set_homes_response(home_data) + + q10_sim = Q10VacuumSimulator( + duid="q10_duid", + device_info=device_info, + product=product, + ) + cloud.add_device(q10_sim) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + + # The setup includes fake_device (V1) by default because of the fake_device fixture + # which is not requested here (we only request cloud and patch_device_manager), + # but the mock EAPI returns the full home_data layout containing our Q10 device. + assert len(devices) == 1 + + q10_device = await device_manager.get_device("q10_duid") + assert q10_device is not None + assert q10_device.name == "My Q10" + assert q10_device.product.model == "roborock.vacuum.ss07" + + # Wait for background connect to establish + for _ in range(20): + if q10_device.is_connected: + break + await asyncio.sleep(0.05) + assert q10_device.is_connected + + assert q10_device.b01_q10_properties is not None + assert q10_device.b01_q10_properties.status.status is None + + await q10_device.b01_q10_properties.refresh() + + for _ in range(20): + if q10_device.b01_q10_properties.status.battery == 100: + break + await asyncio.sleep(0.05) + + assert q10_device.b01_q10_properties.status.battery == 100 + from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState + + assert q10_device.b01_q10_properties.status.status == YXDeviceState.CHARGING + + await q10_device.b01_q10_properties.vacuum.start_clean() + + for _ in range(20): + if q10_device.b01_q10_properties.status.status == YXDeviceState.CLEANING: + break + await asyncio.sleep(0.05) + + assert q10_device.b01_q10_properties.status.status == YXDeviceState.CLEANING + assert q10_sim.status[121] == 5 + + await device_manager.close() + + async def test_with_device( cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None ) -> None: