diff --git a/CHANGELOG.md b/CHANGELOG.md index 259f43b11..799adb5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,305 @@ +## 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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..f691cd85f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-roborock" -version = "5.21.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/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/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/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index c3e1771c9..203df3f20 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. @@ -252,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 7883ed739..2223cc862 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,16 @@ YXAreaUnit, YXBackType, YXCarpetCleanType, + YXCleaningResult, YXCleanLine, + YXCleanScope, YXCleanType, YXDeviceCleanTask, YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXFault, + YXStartMethod, YXWaterLevel, ) @@ -32,6 +37,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 @@ -115,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}) @@ -153,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/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/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/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/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/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/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 8f4a52026..b85007f02 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -4,14 +4,14 @@ 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 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 +27,7 @@ "Q10PropertiesApi", "ButtonLightTrait", "ChildLockTrait", + "CleanHistoryTrait", "ConsumableTrait", "DoNotDisturbTrait", "DustCollectionTrait", @@ -78,7 +79,10 @@ class Q10PropertiesApi(Trait): map: MapContentTrait """Trait for fetching the current parsed map (image + rooms).""" - def __init__(self, channel: MqttChannel) -> None: + clean_history: CleanHistoryTrait + """Trait for fetching the device clean-record history (``dpCleanRecord``).""" + + def __init__(self, channel: B01Q10Channel) -> None: """Initialize the B01Props API.""" self._channel = channel self.command = CommandTrait(channel) @@ -93,6 +97,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 +107,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 @@ -127,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: @@ -150,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/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/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/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/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/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/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/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/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/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/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/roborock/testing/__init__.py b/roborock/testing/__init__.py new file mode 100644 index 000000000..12e86e562 --- /dev/null +++ b/roborock/testing/__init__.py @@ -0,0 +1,107 @@ +"""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.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 ( + 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_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/channel.py b/roborock/testing/channel.py new file mode 100644 index 000000000..022d449bd --- /dev/null +++ b/roborock/testing/channel.py @@ -0,0 +1,163 @@ +"""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. +""" + +import asyncio +from collections.abc import AsyncGenerator, Awaitable, 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. + - **Intercept published messages**: Register a handler/callback via + ``channel.publish_handler = my_handler`` (e.g. stateful simulator) + to reactively process commands. + """ + + 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 + + # A callback to intercept published messages (e.g., bound simulator handler). + # 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 + + # 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 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) + + 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) + + 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 + + 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 new file mode 100644 index 000000000..61a0c5435 --- /dev/null +++ b/roborock/testing/cloud.py @@ -0,0 +1,297 @@ +"""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 datetime +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.b01_q10_simulator import Q10VacuumSimulator +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, + ) + + 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.""" + + 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): + 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 + if device.pv in ("A01", "B01"): + raise NotImplementedError( + f"Simulating protocol {device.pv} is not yet supported. " + "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) + + # 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: + 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) and A01 (Zeo/Dyad) devices." + ) + 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) + + # 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", + 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), + 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 new file mode 100644 index 000000000..6dd05240d --- /dev/null +++ b/roborock/testing/simulator.py @@ -0,0 +1,104 @@ +"""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_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_handler = self._handle_local_publish + + async def _handle_local_publish(self, message: RoborockMessage) -> None: + assert self.local_channel is not None + await self._handle_publish(message, self.local_channel) + + async def _handle_mqtt_publish(self, message: RoborockMessage) -> None: + 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..83bbba99e --- /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.o4_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/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/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 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_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/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index 2f109c5b3..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, 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=[, , , , , , ], 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/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/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, diff --git a/tests/devices/test_device_manager.py b/tests/devices/test_device_manager.py index bd2ed1256..734c20657 100644 --- a/tests/devices/test_device_manager.py +++ b/tests/devices/test_device_manager.py @@ -1,120 +1,141 @@ """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, Q10VacuumSimulator, 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(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(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), - ): - yield +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_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() -@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") + # 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" -@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 + # 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 -@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 + 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) -@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 + 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 -async def test_no_devices(home_data_no_devices: HomeData) -> None: - """Test the DeviceManager created with no devices returned from the API.""" + await q10_device.b01_q10_properties.vacuum.start_clean() - device_manager = await create_device_manager(USER_PARAMS) - devices = await device_manager.get_devices() - assert devices == [] + 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(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 +151,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 +163,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 +186,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 +201,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 +221,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 +245,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 +268,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 + # Clear error so connection succeeds on retry + fake_device.mqtt_channel.clear_error() + fake_device.local_channel.clear_error() - # 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 - - # 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 +303,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 +315,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 +333,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 +379,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 +421,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) - # 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"] + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + assert [device.duid for device in devices] == ["device-uid-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} + 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() 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 new file mode 100644 index 000000000..10fce2b64 --- /dev/null +++ b/tests/devices/traits/b01/q10/test_clean_history.py @@ -0,0 +1,177 @@ +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 .conftest import FakeB01Q10Channel + +# 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="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: FakeB01Q10Channel) -> None: + await q10_api.clean_history.refresh() + + 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..edd4bcb14 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 @@ -19,46 +18,43 @@ YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXFault, YXWaterLevel, ) 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 +62,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 +85,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 +108,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 +124,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) @@ -159,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 @@ -199,11 +191,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 e736d28c2..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 @@ -30,6 +20,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}), @@ -41,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() 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_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 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_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] 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/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/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} 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()), 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_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}} 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" diff --git a/uv.lock b/uv.lock index f62221174..b831fd697 100644 --- a/uv.lock +++ b/uv.lock @@ -1335,7 +1335,7 @@ wheels = [ [[package]] name = "python-roborock" -version = "5.21.0" +version = "5.28.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },