Skip to content

feat: implement Q10VacuumSimulator for B01 integration testing - #2

Closed
allenporter wants to merge 21 commits into
mainfrom
feat-b01-testing-support
Closed

allenporter wants to merge 21 commits into
mainfrom
feat-b01-testing-support

Conversation

@allenporter

Copy link
Copy Markdown
Owner

Summary

Adds stateful device simulation support for B01 protocol devices (specifically Roborock Q10 / ss07 model) in roborock.testing.

Changes

  • Implemented Q10VacuumSimulator in roborock/testing/b01_q10_simulator.py with stateful DP (Data Point) tracking.
  • Handled state transitions for clean, pause, resume, stop, return home, and dock emptying.
  • Added DP 101 (dpCommon) sub-property mapping (sound volume, DND, auto-dust collection, child lock).
  • Added stream-streaming support on FakeChannel.
  • Added unit tests in tests/testing/test_q10_simulator.py.

Verification

Ran test suite (uv run python -m pytest), all 748 tests passed.

tubededentifrice and others added 21 commits June 28, 2026 09:37
…oborock#851)

* feat: expand Q10 (B01/ss07) status support and add device info

Verified against two physical Roborock Q10 (roborock.vacuum.ss07) devices:

- Add the roborock.vacuum.ss07 schema to device_info.yaml (captured via
  `roborock get-device-info`), per CONTRIBUTING.
- Expand Q10Status to capture the ~25 additional fields the device reports in
  its full status dump (volume, child_lock, DND, net_info, time_zone, etc.).
- Fix dpNetInfo/dpTimeZone container field names so the decamelized device keys
  actually parse (they previously failed to construct).
- Add a CLI `status` path for B01 Q10 devices, which refreshes and prints the
  decoded status (previously `status` only worked for V1 devices).
- Add a second real (scrubbed) full-status protocol fixture and extend the
  status trait tests to cover the new fields.

* feat: add Q10 (B01/ss07) settings writers

Adds a SettingsTrait so Q10 settings the status trait already reads can also be
written: volume, child lock, Do Not Disturb, indicator LED, and dust collection.

Q10 setting writes were reverse-engineered + validated against a live ss07
robot: the bare data point write ({"dps": {"26": v}}) is silently ignored; the
value must be wrapped in the dpCommon (101) data point
({"dps": {"101": {"26": v}}}). Verified live by setting volume and child lock and
reading the new value back from the status trait, then restoring.

- traits/b01/q10/settings.py: SettingsTrait wired into Q10PropertiesApi.
- cli: q10-set-volume / -child-lock / -dnd / -led / -dust-collection.
- Unit tests assert the exact dpCommon-wrapped payload for each setter.

* feat: add Q10 dust-collection frequency writer

Adds SettingsTrait.set_dust_collection_frequency (dpDustSetting 50 via the
dpCommon wrapper) accepting YXDeviceDustCollectionFrequency or its int code
(0=daily, 15/30/45/60 cleans), plus a q10-set-dust-frequency CLI command.
Validated live on an R1: dust_setting 0 -> 15 -> restored to 0.

* refactor: split Q10 status/settings into per-concern traits

Address PR Python-roborock#846 review feedback:

- Split the monolithic Q10Status read-model + catch-all SettingsTrait into
  per-concern read/write traits mirroring the v1 layout: SoundVolumeTrait,
  ChildLockTrait, DoNotDisturbTrait, DustCollectionTrait, ButtonLightTrait,
  NetworkInfoTrait, ConsumableTrait. Each owns its read-model and setters.
- Add an UpdatableTrait base (q10/common.py) that wires a RoborockBase
  read-model to the DPS push stream; Q10PropertiesApi fans each decoded
  message out to every read-model trait.
- Convert clear on/off flags to bool (child_lock, not_disturb, dust_switch,
  auto_boost, multi_map_switch, line_laser_obstacle_avoidance) and map
  dust_setting to YXDeviceDustCollectionFrequency.
- set_frequency now takes YXDeviceDustCollectionFrequency only (no int union).
- Drop redundant decamelize comments on dpNetInfo/dpTimeZone; keep the note
  documenting the device's "ipAdress" typo.
- Update CLI Q10 setters and tests for the new trait layout.

* feat: decode Q10 carpet/area/mop/floor-direction status into enums+bools

Reverse-engineered against live Q10 hardware (toggling each setting in the
app and diffing the status dump):

- area_unit -> YXAreaUnit (square_meter=0, square_feet=1)
- carpet_clean_type -> YXCarpetCleanType (rise=0, avoid=1, ignore=2, cross=3)
- mop_state -> bool (mop module attached)
- ground_clean -> bool ("clean along floor direction")
- Rename YXDeviceDustCollectionFrequency.DAILY -> REGULAR to match the app
  ("regular" vs "frequent" 15/30/45/60); codes unchanged.

breakpoint_clean / add_clean_state / timer_type / user_plan / robot_type
remain int (no app control found, runtime-only, or constant).

* feat: decode Q10 add_clean_state as a bool

Observed on live hardware: dpAddCleanState (96) pulses 0->1 while the app's
"re cleaning" (draw-a-rectangle / add-area) request is in progress, then back
to 0 once the robot has the area. Model it as a bool.

* fix: stop unmapped Q10 data points from logging "not a valid code" warnings

ss07 hardware pushes data points this library does not model (confirmed live:
DPs 112 and 113, which stay 0 across docked/charging, segment cleaning,
lifted-off-ground fault, returning to dock and dustbin-removed states, and are
absent from the official app's vacuum plugin). Decoding every status push ran
them through `from_code_optional`, which routed through `from_code` and logged a
WARNING before returning None -- so each unknown DP spammed
"<code> is not a valid code for B01_Q10_DP".

- Make `from_code_optional` (and the int path of `from_any_optional`) do their
  own silent lookup instead of going through `from_code`. `from_code` stays
  strict (warns + raises) for callers that require a known code.
- Remove the duplicate-coded legacy entries `JUMP_SCAN` (101) and
  `CLIFF_RESTRICTED_AREA` (102): they collided with the confirmed
  `COMMON` (101) / `REQUEST_DPS` (102) codes, shadowing them in `from_code`, and
  are unused with unverifiable codes. Also drop the orphaned
  `CLIFF_RESTRICTED_AREA_UP` (103).
- Document DPs 112/113 as observed-but-unidentified.
- Tests: assert `from_code_optional` is silent, `from_code` still warns, and the
  B01 decode drops 112/113 without logging.

* fix: avoid Q10 Consumable/NetworkInfo shadowing v1 in roborock.data

The new per-concern Q10 read-models `Consumable` and `NetworkInfo` were
star-exported into the `roborock.data` namespace where v1 also exports those
names, so `from roborock.data import Consumable` silently resolved to the v1
class (and mypy flagged the incompatible re-import). Rename the Q10 read-models
to `Q10Consumable` / `Q10NetworkInfo`; the traits already import them by fully
qualified path.

* fix: keep Q10 CLIFF_RESTRICTED_AREA_UP (103); ss07 pushes it

While removing the duplicate-coded legacy entries I also dropped DP 103, but
live capture shows ss07 hardware does push data point 103 (an empty list when no
cliff-restricted areas are configured). Restore CLIFF_RESTRICTED_AREA_UP at 103;
only the genuinely-colliding dpJumpScan (101) and dpCliffRestrictedArea (102)
stay removed.

* fix: correct Q10 vacuum command payloads, verified against ss07 hardware

The VacuumTrait movement commands were unverified guesses (with TODO notes) and
sent payload shapes the device ignores. Reverse-engineered the correct format
from the official app's vacuum plugin and confirmed each one live against an
ss07 robot (watching the DP stream react):

- start_clean:    {"201": 1}  (was {"201": {"cmd": 1}})  -> clean_task_type 1
- pause_clean:    {"204": 0}  (was {"204": {}})
- resume_clean:   {"205": 0}  (was {"205": {}})
- stop_clean:     {"206": 0}  (was {"206": {}})
- return_to_dock: {"202": 5}  (was {"203": {}}; dpStartBack=charge, and DP 203
                               is not the dock-return command)
- empty_dustbin:  {"203": 2}  (already correct; confirmed -> status emptying)

Also add spot_clean ({"201": 5} -> clean_task_type 5), confirmed live, plus a
`q10-vacuum-spot` CLI command. set_fan_level / set_clean_mode were confirmed to
work as bare DP writes (no dpCommon wrapper needed, unlike the settings).

Segment ({"201": 2}), zone ({"201": 3}) and build-map ({"201": 4}) clean are
documented but not exposed: they need a room/zone selection payload whose shape
could not be verified (the broker rejects subscribing to the app's command
topic, so the app's exact request can't be captured).

* fix: show all Q10 read-model traits in status, wait for fresh push

The Q10 status command only printed properties.status, omitting the other
read-model traits that refresh() populates (volume, child lock, do-not-disturb,
dust collection, network info, consumables). It also gated on
properties.status.status is not None, which the persistent subscribe loop can
satisfy with stale data pushed before the command ran.

Register update listeners on all refreshed traits, fire refresh(), and wait
for a fresh update (with a short settle window for multi-packet pushes) before
dumping every trait. Verified live against ss07 (R1).

* feat: Q10 (B01/ss07) room/segment cleaning

Add VacuumTrait.clean_segments(segment_ids) for the Q10. dpStartClean (201)
carries the room selection as an object: {"cmd": 2, "clean_paramters": [ids]}
(cmd 2 = segment clean; "clean_paramters" mirrors the firmware's misspelling).
Segment ids are the same room ids reported by the map (map.rooms).

Captured from the official app and verified live against ss07 hardware
(clean_task_type -> 2 / electoral). Adds a q10-clean-segments CLI command and
trait payload tests.

* refactor: use YXDeviceCleanTask.ELECTORAL and inline misspelling note

Address review feedback on clean_segments: replace the literal 2 with
YXDeviceCleanTask.ELECTORAL.code, move the 'clean_paramters' misspelling
note inline next to the params, and drop the official-app comment.
Automatically generated by python-semantic-release
* feat: add Q10 (B01/ss07) clean-record history trait

Q10 clean history is push-driven over dpCleanRecord (DP 52), unlike the Q7's
synchronous get_record_list RPC. Adds:
- Q10CleanRecord: parses the 12-field op:list underscore string (raw retained),
  with crash-safe enum accessors .scope/.work/.result/.started_by (IntEnums that
  return None for an unmapped code, never raising like the YX from_code path)
- CleanHistoryTrait: refresh() sends {op:list}; update_from_dps decodes both the
  full list and the single op:notify clean-finished push (upsert by record_id,
  newest-first); registered as a read-model trait in the dispatch loop

Field layout is the device app's own (setHoldData), cross-confirmed by ioBroker's
Q10CleanRecordService. Tests cover decode, enum labels + unmapped-safety, and the
list/notify paths.

* refactor: widen parse_record's parameter type to Any | None

Co-authored-by: Allen Porter <allen.porter@gmail.com>

* refactor: call the static parse_record via the class, not self

parse_record is a @staticmethod, so call it as CleanRecordConverter.parse_record(...)
rather than self.parse_record(...).

---------

Co-authored-by: Andrew Yeats <ayeats@users-MacBook-Air.local>
Co-authored-by: Allen Porter <allen.porter@gmail.com>
Automatically generated by python-semantic-release
…roborock#858)

* fix: verify MQTT connection requires successful subscription

* docs: clarify is_mqtt_connected docstring with motivation

* refactor: remove unused fixtures in tests
Automatically generated by python-semantic-release
…it (Python-roborock#861)

This change enables Home Assistant to dynamically add and remove maps/camera entities without restarting. Resolves HA issue: home-assistant/core#175400
Automatically generated by python-semantic-release
* feat: add better cleaning mode support

* chore: address comments from copilot

* chore: address comments from copilot

* chore: address comments from copilot

* chore: address comments

* chore: drop int typing and improve comment

* chore: add some comments that better explain 'get_mop_only_vacuum_mode'
* chore: add dataclasses for Roborock mower

* chore: apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Automatically generated by python-semantic-release
…-roborock#863)

* feat: decode Q10 (B01/ss07) dpFault codes into a YXFault map

Add a Q10-specific YXFault enum (seeded from the ss07 app fault i18n) and an
additive Q10Status.fault_name property. The raw fault int is untouched, so
unknown/new codes are never lost. Addresses Python-roborock#855.

* refactor: type Q10Status.fault as YXFault, mirroring the Q7 fault field

Retype the fault field from a raw int to YXFault | None and make fault_name
a str accessor, matching B01Props.fault / fault_name on the Q7. Update the
enum provenance notes from live hardware confirmation (10 codes) and drop
the unconfirmed 569 label.

* docs: note fault 501 fires per completed task

* docs: mark fault 5 hw-confirmed (physically ground-truthed brush jams)

* docs: mark fault 12 hw-confirmed (fired at 14% battery, corpus recount)

* docs: mark fault 407 hw-confirmed (deterministically reproduced: due schedule ignored mid-clean)

* docs: mark fault 46 hw-confirmed (docked with dust bag removed)

* feat: re-add fault 569 as too_frequent_emptying, hw-confirmed (dock refuses a too-soon empty)

* docs: add 569 back to the docstring's Q7-divergence list

* docs: mark fault 2 hw-confirmed (bumper held during commanded motion)

* docs: note the error state can carry no fault code (observed live)

* docs: refer to the ss07 fault spec, not the app (review feedback)

---------

Co-authored-by: Andrew Yeats <ayeats@users-MacBook-Air.local>
Automatically generated by python-semantic-release
…ting (Python-roborock#860)

* feat: add roborock.testing module for stateful integration testing

Introduces a new roborock.testing package that provides stateful firmware
simulators, fake transport channels, and cloud environment fakes. This
allows downstream consumers (like the Home Assistant integration) to write
high-fidelity integration tests using the real client library classes
instead of fragile top-level mocks.

New modules:
- channel.py: FakeChannel in-memory transport implementing Channel protocol
- simulator.py: RoborockDeviceSimulator base class
- v1_simulator.py: V1VacuumSimulator with stateful command handlers
- cloud.py: FakeRoborockCloud with HTTP endpoint mocking

Tests split by module:
- test_channel.py: FakeChannel subscribe/publish/notify
- test_cloud.py: Discovery, login errors, dynamic device addition
- test_v1_simulator.py: Trait refresh/reset, state transitions, push updates

* docs: add comment explaining dss field in v1_simulator

* feat: expose in_cleaning, in_returning, charge_status, and dss on V1VacuumSimulator

* feat: expose dock_type configuration in V1VacuumSimulator

* feat: honor publish_side_effect in simulator and raise NotImplementedError in base _handle_publish

* refactor: inherit FakeChannel from Channel Protocol

* refactor: type simulator consumables state using Consumable dataclass

* refactor: type simulator dnd, clean summary and record using real dataclasses

* refactor: type simulator status, network info and init status using real dataclasses

* refactor: expose raw status and app_init dataclasses on simulator for direct test manipulation

* refactor: export and define all simulator state default constants at package level

* refactor: update _handle_app_get_init_status to serialize self.app_init and dynamically resolve status_info fields from self.status

* test: add E2E test case for multiple simulators with independent network info overrides

* refactor: replace FakeUserState with library UserData dataclass on FakeRoborockCloud

* refactor: decouple default product id from device duid and share DEFAULT_PRODUCT_ID constant

* refactor: rename self.servers to self.simulated_devices and restore hardcoded qiCNieZa in mock_data

* refactor: delete redundant properties from V1VacuumSimulator and update trigger_push_update
Automatically generated by python-semantic-release
Automatically generated by python-semantic-release
@allenporter allenporter closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants