diff --git a/roborock/data/b01_q10/b01_q10_containers.py b/roborock/data/b01_q10/b01_q10_containers.py index 0e805593..99b3d48d 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -51,3 +51,49 @@ class dpVoiceVersion(RoborockBase): class dpTimeZone(RoborockBase): timeZoneCity: str timeZoneSec: int + + +class Q10Status(RoborockBase): + """Status for Q10 devices.""" + + clean_time: int | None = None + clean_area: int | None = None + battery: int | None = None + status: int | None = None + fun_level: int | None = None + water_level: int | None = None + clean_count: int | None = None + clean_mode: int | None = None + clean_task_type: int | None = None + back_type: int | None = None + cleaning_progress: int | None = None + + +class Q10Consumable(RoborockBase): + """Consumable status for Q10 devices.""" + + main_brush_life: int | None = None + side_brush_life: int | None = None + filter_life: int | None = None + rag_life: int | None = None + sensor_life: int | None = None + + +class Q10DND(RoborockBase): + """DND status for Q10 devices.""" + + enabled: bool | None = None + start_time: str | None = None + end_time: str | None = None + + +class Q10Volume(RoborockBase): + """Volume status for Q10 devices.""" + + volume: int | None = None + + +class Q10ChildLock(RoborockBase): + """Child lock status for Q10 devices.""" + + enabled: bool | None = None diff --git a/roborock/devices/b01_channel.py b/roborock/devices/b01_channel.py index 0c9e06d5..a847d2d2 100644 --- a/roborock/devices/b01_channel.py +++ b/roborock/devices/b01_channel.py @@ -12,7 +12,7 @@ CommandType, ParamsType, decode_rpc_response, - encode_mqtt_payload, + encode_q7_payload, ) from roborock.roborock_message import RoborockMessage from roborock.util import get_next_int @@ -31,6 +31,7 @@ async def send_decoded_command( ) -> dict[str, Any] | None: """Send a command on the MQTT channel and get a decoded response.""" msg_id = str(get_next_int(100000000000, 999999999999)) + roborock_message = encode_q7_payload(dps, command, params, msg_id) _LOGGER.debug( "Sending B01 MQTT command: dps=%s method=%s msg_id=%s params=%s", dps, @@ -38,7 +39,6 @@ async def send_decoded_command( msg_id, params, ) - roborock_message = encode_mqtt_payload(dps, command, params, msg_id) future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() def find_response(response_message: RoborockMessage) -> None: diff --git a/roborock/devices/b01_q10_channel.py b/roborock/devices/b01_q10_channel.py new file mode 100644 index 00000000..068aa10d --- /dev/null +++ b/roborock/devices/b01_q10_channel.py @@ -0,0 +1,162 @@ +"""B01 Q10 MQTT helpers (send + async inbound routing). + +Q10 devices do not reliably correlate request/response via the message sequence +number. Additionally, DP updates ("prop updates") can arrive at any time. + +To avoid race conditions, we route inbound messages through a single async +consumer and then dispatch: +- prop updates (DP changes) -> trait update callbacks + DP waiters +- other response types -> placeholders for future routing +""" + +import asyncio +import logging +from collections.abc import Callable +from typing import Any, Final + +from roborock.exceptions import RoborockException +from roborock.protocols.b01_protocol import decode_rpc_response, encode_b01_mqtt_payload +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol + +from .mqtt_channel import MqttChannel + +_LOGGER = logging.getLogger(__name__) + + +class B01Q10MessageRouter: + """Async router for inbound B01 Q10 messages.""" + + def __init__(self) -> None: + self._queue: asyncio.Queue[RoborockMessage] = asyncio.Queue() + self._task: asyncio.Task[None] | None = None + self._prop_update_callbacks: list[Callable[[dict[int, Any]], None]] = [] + + def add_prop_update_callback(self, callback: Callable[[dict[int, Any]], None]) -> Callable[[], None]: + """Register a callback for prop updates (decoded DP dict).""" + self._prop_update_callbacks.append(callback) + + def remove() -> None: + try: + self._prop_update_callbacks.remove(callback) + except ValueError: + pass + + return remove + + def feed(self, message: RoborockMessage) -> None: + """Feed an inbound message into the router (non-async safe).""" + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._run(), name="b01-q10-message-router") + self._queue.put_nowait(message) + + def close(self) -> None: + """Stop the router task.""" + if self._task and not self._task.done(): + self._task.cancel() + + async def _run(self) -> None: + while True: + message = await self._queue.get() + try: + self._handle_message(message) + except Exception as ex: # noqa: BLE001 + _LOGGER.debug("Unhandled error routing B01 Q10 message: %s", ex) + + def _handle_message(self, message: RoborockMessage) -> None: + # Placeholder for additional response types. + match message.protocol: + case RoborockMessageProtocol.RPC_RESPONSE: + self._handle_rpc_response(message) + case RoborockMessageProtocol.MAP_RESPONSE: + _LOGGER.debug("B01 Q10 map response received (unrouted placeholder)") + case _: + _LOGGER.debug("B01 Q10 message protocol %s received (unrouted placeholder)", message.protocol) + + def _handle_rpc_response(self, message: RoborockMessage) -> None: + try: + decoded = decode_rpc_response(message) + except RoborockException as ex: + _LOGGER.info("Failed to decode B01 Q10 message: %s: %s", message, ex) + return + + # Identify response type and route accordingly. + # + # Based on Hermes Q10: DP changes are delivered as "deviceDpChanged" events. + # Many DPs are delivered nested inside dpCommon (101), so we flatten that + # envelope into regular DP keys for downstream trait updates. + dps = _flatten_q10_dps(decoded) + if not dps: + return + + for cb in list(self._prop_update_callbacks): + try: + cb(dps) + except Exception as ex: # noqa: BLE001 + _LOGGER.debug("Error in B01 Q10 prop update callback: %s", ex) + + +_ROUTER_ATTR: Final[str] = "_b01_q10_router" + + +def get_b01_q10_router(mqtt_channel: MqttChannel) -> B01Q10MessageRouter: + """Get (or create) the per-channel B01 Q10 router.""" + router = getattr(mqtt_channel, _ROUTER_ATTR, None) + if router is None: + router = B01Q10MessageRouter() + setattr(mqtt_channel, _ROUTER_ATTR, router) + return router + + +def _flatten_q10_dps(decoded: dict[int, Any]) -> dict[int, Any]: + """Flatten Q10 dpCommon (101) payload into normal DP keys. + + Example input from device: + {101: {"25": 1, "26": 54, "6": 876}, 122: 88, 123: 2, ...} + + Output: + {25: 1, 26: 54, 6: 876, 122: 88, 123: 2, ...} + """ + flat: dict[int, Any] = {} + for dp, value in decoded.items(): + if dp == 101 and isinstance(value, dict): + for inner_k, inner_v in value.items(): + try: + inner_dp = int(inner_k) + except (TypeError, ValueError): + continue + flat[inner_dp] = inner_v + continue + flat[dp] = value + return flat + + +async def send_b01_dp_command( + mqtt_channel: MqttChannel, + dps: dict[int, Any], +) -> None: + """Send a raw DP command on the MQTT channel. + + Q10 devices can emit DP updates at any time, and do not reliably correlate + request/response via the message sequence number. + + For Q10 we treat **all** outbound messages as fire-and-forget: + - We publish the DP command. + - We do not wait for any response payload. + - Traits are updated via async prop updates routed by `B01Q10MessageRouter`. + + """ + _LOGGER.debug("Sending MQTT DP command: %s", dps) + msg = encode_b01_mqtt_payload(dps) + + _LOGGER.debug("Publishing B01 Q10 MQTT message: %s", msg) + try: + await mqtt_channel.publish(msg) + await mqtt_channel.health_manager.on_success() + except TimeoutError: + await mqtt_channel.health_manager.on_timeout() + _LOGGER.debug("B01 Q10 MQTT publish timed out for dps=%s", dps) + except Exception as ex: # noqa: BLE001 + # Fire-and-forget means callers never see errors; keep the task quiet. + _LOGGER.debug("B01 Q10 MQTT publish failed for dps=%s: %s", dps, ex) + + return None diff --git a/roborock/devices/device.py b/roborock/devices/device.py index e58bac9d..8214098a 100644 --- a/roborock/devices/device.py +++ b/roborock/devices/device.py @@ -65,6 +65,7 @@ def __init__( protocol channel. Use `close()` to clean up all connections. """ TraitsMixin.__init__(self, trait) + self._trait = trait self._duid = device_info.duid self._logger = RoborockLoggerAdapter(duid=self._duid, logger=_LOGGER) self._name = device_info.name @@ -215,10 +216,24 @@ async def close(self) -> None: if self._unsub: self._unsub() self._unsub = None + close_trait = getattr(self._trait, "close", None) + if callable(close_trait): + try: + result = close_trait() + if asyncio.iscoroutine(result): + await result + except Exception as ex: # noqa: BLE001 + self._logger.debug("Error closing trait: %s", ex) def _on_message(self, message: RoborockMessage) -> None: """Handle incoming messages from the device.""" self._logger.debug("Received message from device: %s", message) + on_message = getattr(self._trait, "on_message", None) + if callable(on_message): + try: + on_message(message) + except Exception as ex: # noqa: BLE001 + self._logger.debug("Error in trait on_message handler: %s", ex) def diagnostic_data(self) -> dict[str, Any]: """Return diagnostics information about the device.""" diff --git a/roborock/devices/device_manager.py b/roborock/devices/device_manager.py index 1fb5ec40..5b082921 100644 --- a/roborock/devices/device_manager.py +++ b/roborock/devices/device_manager.py @@ -223,9 +223,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) model_part = product.model.split(".")[-1] if "ss" in model_part: - raise NotImplementedError( - f"Device {device.name} has unsupported version B01_{product.model.strip('.')[-1]}" - ) + trait = b01.q10.create(channel) elif "sc" in model_part: # Q7 devices start with 'sc' in their model naming. trait = b01.q7.create(channel) diff --git a/roborock/devices/traits/b01/__init__.py b/roborock/devices/traits/b01/__init__.py index bf6d8b23..fb451aa4 100644 --- a/roborock/devices/traits/b01/__init__.py +++ b/roborock/devices/traits/b01/__init__.py @@ -1,5 +1,7 @@ """Traits for B01 devices.""" +from . import q7, q10 from .q7 import Q7PropertiesApi +from .q10 import Q10PropertiesApi -__all__ = ["Q7PropertiesApi", "q7", "q10"] +__all__ = ["Q7PropertiesApi", "Q10PropertiesApi", "q7", "q10"] diff --git a/roborock/devices/traits/b01/q10/B01_Q10_COMMANDS.md b/roborock/devices/traits/b01/q10/B01_Q10_COMMANDS.md new file mode 100644 index 00000000..ce3ee512 --- /dev/null +++ b/roborock/devices/traits/b01/q10/B01_Q10_COMMANDS.md @@ -0,0 +1,892 @@ +## Roborock Q10 (B01/Q10) DP command reference + +This document is derived from the Roborock Android app Hermes dump under: + +- `RR_API\hermes\no_package\Roborock Q10 Series\output\module_940.js` (Q10 command implementations) +- `RR_API\hermes\no_package\Roborock Q10 Series\output\module_981.js` (DP id mapping / `YXCommonDP`) + +It documents the **command functions** the app calls, which DP they write, and the **expected value/params shape** as implied by the app. +This is AI generated and not all data may be fully accurate but it is grounded with some reverse engineered code. +## Transport / payload model + +Q10-series devices in this dump are controlled by **publishing DP updates** via `RRDevice.deviceIOT.publishDps(...)`. + +Two patterns appear: + +- **Direct DP write**: publish `{ : }` (the app uses `sendCmdWithDp(dpId, value)`). +- **Public command wrapper**: publish `{ dpCommon(101): { : } }` (the app uses `sendPublicCmd({ : })`). + +## Common enumerations + +These enums come from `python-roborock/roborock/data/b01_q10/b01_q10_code_mappings.py` and match what the app UI exposes: + +### `YXFanLevel` + +- **UNKNOWN**: `-1` (`unknown`) +- **CLOSE**: `0` (`close`) +- **QUITE**: `1` (`quite`) +- **NORMAL**: `2` (`normal`) +- **STRONG**: `3` (`strong`) +- **MAX**: `4` (`max`) +- **SUPER**: `5` (`super`) + +### `YXWaterLevel` + +- **UNKNOWN**: `-1` (`unknown`) +- **CLOSE**: `0` (`close`) +- **LOW**: `1` (`low`) +- **MIDDLE**: `2` (`middle`) +- **HIGH**: `3` (`high`) + +### `YXCleanLine` + +- **FAST**: `0` (`fast`) +- **DAILY**: `1` (`daily`) +- **FINE**: `2` (`fine`) + +### `YXRoomMaterial` + +- **HORIZONTAL_FLOOR_BOARD**: `0` (`horizontalfloorboard`) +- **VERTICAL_FLOOR_BOARD**: `1` (`verticalfloorboard`) +- **CERAMIC_TILE**: `2` (`ceramictile`) +- **OTHER**: `255` (`other`) + +### `YXCleanType` + +- **UNKNOWN**: `-1` (`unknown`) +- **BOTH_WORK**: `1` (`bothwork`) +- **ONLY_SWEEP**: `2` (`onlysweep`) +- **ONLY_MOP**: `3` (`onlymop`) + +### `YXDeviceState` + +- **UNKNOWN**: `-1` (`unknown`) +- **SLEEP_STATE**: `2` (`sleepstate`) +- **STANDBY_STATE**: `3` (`standbystate`) +- **CLEANING_STATE**: `5` (`cleaningstate`) +- **TO_CHARGE_STATE**: `6` (`tochargestate`) +- **REMOTEING_STATE**: `7` (`remoteingstate`) +- **CHARGING_STATE**: `8` (`chargingstate`) +- **PAUSE_STATE**: `10` (`pausestate`) +- **FAULT_STATE**: `12` (`faultstate`) +- **UPGRADE_STATE**: `14` (`upgradestate`) +- **DUSTING**: `22` (`dusting`) +- **CREATING_MAP_STATE**: `29` (`creatingmapstate`) +- **MAP_SAVE_STATE**: `99` (`mapsavestate`) +- **RE_LOCATION_STATE**: `101` (`relocationstate`) +- **ROBOT_SWEEPING**: `102` (`robotsweeping`) +- **ROBOT_MOPING**: `103` (`robotmoping`) +- **ROBOT_SWEEP_AND_MOPING**: `104` (`robotsweepandmoping`) +- **ROBOT_TRANSITIONING**: `105` (`robottransitioning`) +- **ROBOT_WAIT_CHARGE**: `108` (`robotwaitcharge`) + +### `YXBackType` + +- **UNKNOWN**: `-1` (`unknown`) +- **IDLE**: `0` (`idle`) +- **BACK_DUSTING**: `4` (`backdusting`) +- **BACK_CHARGING**: `5` (`backcharging`) + +### `YXDeviceWorkMode` + +- **UNKNOWN**: `-1` (`unknown`) +- **BOTH_WORK**: `1` (`bothwork`) +- **ONLY_SWEEP**: `2` (`onlysweep`) +- **ONLY_MOP**: `3` (`onlymop`) +- **CUSTOMIZED**: `4` (`customized`) +- **SAVE_WORRY**: `5` (`saveworry`) +- **SWEEP_MOP**: `6` (`sweepmop`) + +### `YXDeviceCleanTask` + +- **UNKNOWN**: `-1` (`unknown`) +- **IDLE**: `0` (`idle`) +- **SMART**: `1` (`smart`) +- **ELECTORAL**: `2` (`electoral`) +- **DIVIDE_AREAS**: `3` (`divideareas`) +- **CREATING_MAP**: `4` (`creatingmap`) +- **PART**: `5` (`part`) + +### `YXDeviceDustCollectionFrequency` + +- **DAILY**: `0` (`daily`) +- **INTERVAL_15**: `15` (`interval_15`) +- **INTERVAL_30**: `30` (`interval_30`) +- **INTERVAL_45**: `45` (`interval_45`) +- **INTERVAL_60**: `60` (`interval_60`) + +## Commands + +Notes: + +- Many methods take parameters; the Hermes decompiler replaces some argument names with placeholders. In this doc: + - ``, ``, ... refer to the call arguments to the app-side function. +- Any command that serializes into `` is a **binary blob** packed into bytes and then base64-encoded by the app before sending. + +### `startClean` + +- **Target DP**: `dpStartClean` (`201`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + +```json +{ + "cmd": 1 +} +``` + +### `startElectoralClean` + +- **Target DP**: `dpStartClean` (`201`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + +```json +{ + "cmd": 2, + "clean_paramters": +} +``` + +### `fastCreateMap` + +- **Target DP**: `dpStartClean` (`201`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + +```json +{ + "cmd": 4 +} +``` + +### `continueClean` + +- **Target DP**: `dpResume` (`205`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `0` + +### `stopClean` + +- **Target DP**: `dpStop` (`206`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `0` + +### `partClean` + +- **Target DP**: `dpStartClean` (`201`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + +```json +{ + "cmd": 5 +} +``` + +### `goCharge` + +- **Target DP**: `dpStartBack` (`202`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `5` + +### `pause` + +- **Target DP**: `dpPause` (`204`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `0` + +### `setFunSuction` + +- **Target DP**: `dpfunLevel` (`123`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `r1` +- **Notes**: + - Value is converted via `toFunLevelNumber(...)` before sending. + +### `setWaterLevel` + +- **Target DP**: `dpWaterLevel` (`124`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `` + +### `setCleanCount` + +- **Target DP**: `dpCleanCount` (`136`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `` + +### `setCleaningPreferences` + +- **Target DP**: `dpCleanMode` (`137`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `` + +### `seekDevice` + +- **Target DP**: `dpSeek` (`11`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `remoteTurnLeft` + +- **Target DP**: `dpRemote` (`12`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `remoteTurnRight` + +- **Target DP**: `dpRemote` (`12`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `remoteForward` + +- **Target DP**: `dpRemote` (`12`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `remoteStop` + +- **Target DP**: `dpRemote` (`12`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `remoteExit` + +- **Target DP**: `dpRemote` (`12`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `resetMap` + +- **Target DP**: `dpMapReset` (`13`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `resetSideBrush` + +- **Target DP**: `dpResetSideBrush` (`18`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `resetMainBrush` + +- **Target DP**: `dpResetMainBrush` (`20`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `resetFilterBrush` + +- **Target DP**: `dpResetFilter` (`22`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `resetSensor` + +- **Target DP**: `dpResetSensor` (`68`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `resetRag` + +- **Target DP**: `dpResetRagLife` (`24`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `notDisturbSwitch` + +- **Target DP**: `dpNotDisturb` (`25`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `requsetNotDisturbData` + +- **Target DP**: `dpRequsetNotDisturbData` (`75`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `volumeSet` + +- **Target DP**: `dpVolume` (`26`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `breakCleanSwitch` + +- **Target DP**: `dpBeakClean` (`27`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `requestCleanRecordList` + +- **Target DP**: `dpCleanRecord` (`52`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `requestCleanRecordDetail` + +- **Target DP**: `dpCleanRecord` (`52`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `requestRemoveCleanRecord` + +- **Target DP**: `dpCleanRecord` (`52`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setVirtualWalls` + +- **Target DP**: `dpVirtualWall` (`56`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + - Uses `.points` with `{x, y}` entries; coordinates are multiplied by 10 before packing into 2 bytes. + +### `setForbiddenAreas` + +- **Target DP**: `dpRestrictedZone` (`54`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + - Uses `.points` with `{x, y}` entries; coordinates are multiplied by 10 before packing into 2 bytes. + - Uses `.oriModel` (seen fields: `.type`, `.name`). + +### `setAreaClean` + +- **Target DP**: `dpStartClean` (`201`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + - Uses `.points` with `{x, y}` entries; coordinates are multiplied by 10 before packing into 2 bytes. + - Uses `.oriModel` (seen fields: `.type`, `.name`). + +### `requestAllDps` + +- **Target DP**: `dpRequetdps` (`102`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `1` + +### `autoSaveMapSwitch` + +- **Target DP**: `dpMapSaveSwitch` (`51`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `enableMultiMap` + +- **Target DP**: `dpMultiMapSwitch` (`60`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires multi-map support enabled on the device. + +### `saveMultiMap` + +- **Target DP**: `dpMultiMap` (`61`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires multi-map support enabled on the device. + +### `requestMultiMapList` + +- **Target DP**: `dpMultiMap` (`61`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires multi-map support enabled on the device. + +### `requestMultiMapDetail` + +- **Target DP**: `dpMultiMap` (`61`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires multi-map support enabled on the device. + +### `deleteMultiMap` + +- **Target DP**: `dpMultiMap` (`61`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires multi-map support enabled on the device. + +### `useMultiMap` + +- **Target DP**: `dpMultiMap` (`61`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires multi-map support enabled on the device. + +### `renameMultiMap` + +- **Target DP**: `dpMultiMap` (`61`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires multi-map support enabled on the device. + +### `getCarpetList` + +- **Target DP**: `dpGetCarpet` (`64`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires carpet recognition / carpet config support. + +### `saveCarpet` + +- **Target DP**: `dpGetCarpet` (`64`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires carpet recognition / carpet config support. + +### `getSelfIdentifyingCarpetList` + +- **Target DP**: `dpSelfIdentifyingCarpet` (`66`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires carpet recognition / carpet config support. + +### `saveSelfIdentifyingCarpet` + +- **Target DP**: `dpSelfIdentifyingCarpet` (`66`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires carpet recognition / carpet config support. + +### `requestCustomerClean` + +- **Target DP**: `dpCustomerCleanRequest` (`63`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setCustomerClean` + +- **Target DP**: `dpCustomerClean` (`62`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + - References room ids. + +### `setNotDisturb` + +- **Target DP**: `dpNotDisturbData` (`33`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `dustSwitch` + +- **Target DP**: `dpDustSwitch` (`37`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Likely requires an auto-empty dock / dust collection hardware. + +### `dustSetting` + +- **Target DP**: (not detected) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Likely requires an auto-empty dock / dust collection hardware. + +### `valleyPointChargingSwitch` + +- **Target DP**: `dpValleyPointCharging` (`105`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setValleyPointChargingSetting` + +- **Target DP**: `dpValleyPointChargingDataUp` (`106`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). +- **Device support**: + - Only available if the device firmware supports valley/off-peak charging. + +### `requestTimer` + +- **Target DP**: `dpRequestTimer` (`69`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `createTimer` + +- **Target DP**: `dpTimer` (`32`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `editTimer` + +- **Target DP**: `dpTimer` (`32`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `removeTimer` + +- **Target DP**: `dpTimer` (`32`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `timerSwitch` + +- **Target DP**: `dpTimer` (`32`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `robotVoiceLanguageSetting` + +- **Target DP**: `dpVoicePackage` (`35`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `startDockTask` + +- **Target DP**: `dpStartDockTask` (`203`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `` + +### `roomMerge` + +- **Target DP**: `dpRoomMerge` (`72`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `roomSplit` + +- **Target DP**: `dpRoomSplit` (`73`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `resetRoomName` + +- **Target DP**: `dpResetRoomName` (`74`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + - References room ids. + +### `resetOneRoomName` + +- **Target DP**: `dpResetRoomName` (`74`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `getRemoveZonedList` + +- **Target DP**: `dpRemoveZoned` (`70`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `saveRemoveZoned` + +- **Target DP**: `dpRemoveZoned` (`70`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setCarpetCleanType` + +- **Target DP**: `dpCarpetCleanType` (`76`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Requires carpet recognition / carpet config support. + +### `setButtonLightSwitch` + +- **Target DP**: `dpButtonLightSwitch` (`77`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setCleanLine` + +- **Target DP**: `dpCleanLine` (`78`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setTimeZoneToRobot` + +- **Target DP**: `dpTimeZone` (`79`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setChildLockSwitch` + +- **Target DP**: `dpChildLock` (`47`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setAreaUnit` + +- **Target DP**: `dpAreaUnit` (`80`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setCleanOrder` + +- **Target DP**: `dpCleanOrder` (`82`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + +### `setRobotLog` + +- **Target DP**: `dpLogSwitch` (`84`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setFloorMaterial` + +- **Target DP**: `dpFloorMaterial` (`85`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + - References room ids. + +### `setAutoBoostSwitch` + +- **Target DP**: `dpAutoBoost` (`45`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setLineLaserObstacleAvoidance` + +- **Target DP**: `dpLineLaserObstacleAvoidance` (`86`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setIgnoreObstacle` + +- **Target DP**: `dpIgnoreObstacle` (`89`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setLastCleanRecordReaded` + +- **Target DP**: `dpRecendCleanRecord` (`53`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setGroundCleanSwitch` + +- **Target DP**: `dpGroundClean` (`88`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `requestMapAndPathData` + +- **Target DP**: `dpRequest` (`16`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setNotDisturbExpand` + +- **Target DP**: `dpNotDisturbExpand` (`92`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setDisturbLight` + +- **Target DP**: `dpNotDisturbExpand` (`92`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setDisturbVoice` + +- **Target DP**: `dpNotDisturbExpand` (`92`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setDisturbResumeClean` + +- **Target DP**: `dpNotDisturbExpand` (`92`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setDisturbDustEnable` + +- **Target DP**: `dpNotDisturbExpand` (`92`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Likely requires an auto-empty dock / dust collection hardware. + +### `setAddCleanArea` + +- **Target DP**: `dpAddCleanArea` (`95`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Notes**: + - Payload is a base64-encoded binary blob (built from a `Uint8Array`, then `fromByteArray(...)`). + - Uses `.points` with `{x, y}` entries; coordinates are multiplied by 10 before packing into 2 bytes. + - Uses `.oriModel` (seen fields: `.type`, `.name`). + +### `setRestrictedArea` + +- **Target DP**: `dpRestrictedArea` (`97`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `setSuspectedThreshold` + +- **Target DP**: `dpSuspectedThreshold` (`99`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `jumpClean` + +- **Target DP**: `dpJumpScan` (`101`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `sendUserPlan` + +- **Target DP**: `dpUserPlan` (`207`) +- **Transport**: `sendCmdWithDp` (direct DP write) +- **Value / params**: + - `` + +### `setCliffRestrictedArea` + +- **Target DP**: `dpCliffRestrictedArea` (`102`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` + +### `getValleyPointChargingData` + +- **Target DP**: `dpValleyPointChargingData` (`107`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` +- **Device support**: + - Only available if the device firmware supports valley/off-peak charging. + +### `heartbeat` + +- **Target DP**: `dpHeartbeat` (`110`) +- **Transport**: `sendPublicCmd` (wrapped through `dpCommon` / DP 101) +- **Value / params**: + - `` diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index b3cd30d6..f9425b9d 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -1 +1,89 @@ -"""Q10""" +"""Traits for Q10 B01 devices.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.devices.b01_q10_channel import get_b01_q10_router +from roborock.devices.mqtt_channel import MqttChannel +from roborock.devices.traits import Trait +from roborock.roborock_message import RoborockMessage + +from .child_lock import Q10ChildLockTrait +from .command import Q10CommandTrait +from .consumable import Q10ConsumableTrait +from .dnd import Q10DNDTrait +from .status import Q10StatusTrait +from .volume import Q10VolumeTrait + +__all__ = [ + "Q10PropertiesApi", +] + + +@dataclass +class Q10PropertiesApi(Trait): + """API for interacting with Q10 (B01) devices.""" + + status: Q10StatusTrait + consumables: Q10ConsumableTrait + command: Q10CommandTrait + volume: Q10VolumeTrait + child_lock: Q10ChildLockTrait + dnd: Q10DNDTrait + + def __init__(self, channel: MqttChannel) -> None: + """Initialize the Q10 properties API.""" + self._channel = channel + self._router = get_b01_q10_router(channel) + self._remove_prop_cb: Callable[[], None] | None = self._router.add_prop_update_callback(self._on_prop_update) + self.status = Q10StatusTrait() + self.consumables = Q10ConsumableTrait() + self.command = Q10CommandTrait() + self.volume = Q10VolumeTrait() + self.child_lock = Q10ChildLockTrait() + self.dnd = Q10DNDTrait() + + # Set the channel on all traits + for trait in [ + self.status, + self.consumables, + self.command, + self.volume, + self.child_lock, + self.dnd, + ]: + trait.set_channel(channel) + + def on_message(self, message: RoborockMessage) -> None: + """Receive inbound MQTT messages and route them asynchronously.""" + self._router.feed(message) + + def close(self) -> None: + """Clean up background routing tasks and callbacks.""" + if self._remove_prop_cb: + self._remove_prop_cb() + self._remove_prop_cb = None + self._router.close() + + async def refresh(self) -> None: + """Request all DPs from the device (fire-and-forget). + + Q10 devices push DP updates asynchronously and often in multiple different payloads + `B01Q10MessageRouter` will apply those updates via `_on_prop_update` as they arrive. + """ + await self.command.send_dp(B01_Q10_DP.REQUETDPS, 1) + + def _on_prop_update(self, dps: dict[int, Any]) -> None: + """Apply a prop update DP payload to all known traits.""" + self.status.update_from_dps(dps) + self.consumables.update_from_dps(dps) + self.volume.update_from_dps(dps) + self.child_lock.update_from_dps(dps) + self.dnd.update_from_dps(dps) + + +def create(channel: MqttChannel) -> Q10PropertiesApi: + """Create traits for Q10 devices.""" + return Q10PropertiesApi(channel) diff --git a/roborock/devices/traits/b01/q10/child_lock.py b/roborock/devices/traits/b01/q10/child_lock.py new file mode 100644 index 00000000..7eb84726 --- /dev/null +++ b/roborock/devices/traits/b01/q10/child_lock.py @@ -0,0 +1,21 @@ +"""Child lock trait for Q10 devices.""" + +from dataclasses import dataclass + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import Q10ChildLock +from roborock.devices.traits.b01.q10.common import Q10TraitMixin + + +@dataclass +class Q10ChildLockTrait(Q10ChildLock, Q10TraitMixin): + """Trait for managing the child lock of Q10 devices.""" + + dps_field_map = { + B01_Q10_DP.CHILD_LOCK: ("enabled", bool), + } + + async def set_child_lock(self, enabled: bool) -> None: + """Set the child lock of the device.""" + await self.send_dp(B01_Q10_DP.CHILD_LOCK, int(enabled)) + self.enabled = enabled diff --git a/roborock/devices/traits/b01/q10/clean_control.py b/roborock/devices/traits/b01/q10/clean_control.py new file mode 100644 index 00000000..2d1c9b2a --- /dev/null +++ b/roborock/devices/traits/b01/q10/clean_control.py @@ -0,0 +1,53 @@ +"""Clean control trait for Q10 devices.""" + +from dataclasses import dataclass + +from roborock.data.b01_q10.b01_q10_code_mappings import ( + B01_Q10_DP, + YXBackType, + YXDeviceWorkMode, + YXFanLevel, + YXWaterLevel, +) +from roborock.devices.traits.b01.q10.common import Q10TraitMixin + + +@dataclass +class Q10CleanControlTrait(Q10TraitMixin): + """Trait for controlling the cleaning process of Q10 devices.""" + + async def start_clean(self) -> None: + """Start cleaning.""" + await self.send_dp(B01_Q10_DP.START_CLEAN, {"cmd": 1}) + + async def stop_clean(self) -> None: + """Stop cleaning.""" + await self.send_dp(B01_Q10_DP.STOP, 0) + + async def pause_clean(self) -> None: + """Pause cleaning.""" + await self.send_dp(B01_Q10_DP.PAUSE, 0) + + async def resume_clean(self) -> None: + """Resume cleaning.""" + await self.send_dp(B01_Q10_DP.RESUME, 0) + + async def return_to_dock(self) -> None: + """Return to dock.""" + await self.send_dp(B01_Q10_DP.START_BACK, YXBackType.BACK_CHARGING.code) + + async def find_me(self) -> None: + """Locate the robot.""" + await self.send_public(B01_Q10_DP.SEEK, {"seek": 1}) + + async def set_fan_speed(self, fan_speed: YXFanLevel) -> None: + """Set the fan speed.""" + await self.send_dp(B01_Q10_DP.FUN_LEVEL, fan_speed.code) + + async def set_water_level(self, water_level: YXWaterLevel) -> None: + """Set the water level.""" + await self.send_dp(B01_Q10_DP.WATER_LEVEL, water_level.code) + + async def set_work_mode(self, work_mode: YXDeviceWorkMode) -> None: + """Set the work mode.""" + await self.send_dp(B01_Q10_DP.CLEAN_TASK_TYPE, work_mode.code) diff --git a/roborock/devices/traits/b01/q10/common.py b/roborock/devices/traits/b01/q10/common.py new file mode 100644 index 00000000..4b412db7 --- /dev/null +++ b/roborock/devices/traits/b01/q10/common.py @@ -0,0 +1,80 @@ +"""Common functionality for Q10 traits.""" + +from abc import ABC +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, ClassVar, TypeAlias + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.devices.b01_q10_channel import send_b01_dp_command +from roborock.devices.mqtt_channel import MqttChannel + +Q10DpsValueConverter: TypeAlias = Callable[[Any], Any] +Q10DpsFieldSpec: TypeAlias = str | tuple[str, Q10DpsValueConverter] +Q10DpsFieldMap: TypeAlias = Mapping[B01_Q10_DP, Q10DpsFieldSpec] + + +@dataclass +class Q10TraitMixin(ABC): + """Base mixin for Q10 traits.""" + + # Note: We can potentially experiment with extracting this map upward and knowing what DP go to what + # trait to avoid having to call all of them. + dps_field_map: ClassVar[Q10DpsFieldMap | None] = None + """Optional mapping of DP enum -> attribute (and optional converter). + + If set on a trait class, `update_from_dps()` will automatically apply + updates from incoming DP payloads. + """ + + def __post_init__(self) -> None: + """Initialize the Q10 trait.""" + self._channel: MqttChannel | None = None + + def set_channel(self, channel: MqttChannel) -> None: + """Bind this trait to a MQTT channel. + + Q10 traits are also used as state containers; we keep construction + decoupled from transport and inject the channel at the API composition + layer. + """ + self._channel = channel + + @property + def channel(self) -> MqttChannel: + """Get the MQTT channel.""" + if self._channel is None: + raise ValueError("Channel not set on Q10 trait") + return self._channel + + def update_from_dps(self, dps: dict[int, Any]) -> None: + """Update this trait's state from a DP payload.""" + mapping = self.dps_field_map + if not mapping: + return + + for dp, spec in mapping.items(): + if dp.code not in dps: + continue + + value = dps[dp.code] + if isinstance(spec, tuple): + attr, converter = spec + value = converter(value) + else: + attr = spec + setattr(self, attr, value) + + async def send_dp(self, dp: B01_Q10_DP, value: Any) -> None: + """Send a direct DP write (no response expected).""" + await send_b01_dp_command( + self.channel, + {dp.code: value}, + ) + + async def send_public(self, dp: B01_Q10_DP, value: Any) -> None: + """Send a public command wrapped in DP 101 (dpCommon) (no response expected).""" + await send_b01_dp_command( + self.channel, + {B01_Q10_DP.COMMON.code: {str(dp.code): value}}, + ) diff --git a/roborock/devices/traits/b01/q10/consumable.py b/roborock/devices/traits/b01/q10/consumable.py new file mode 100644 index 00000000..b35eb0d0 --- /dev/null +++ b/roborock/devices/traits/b01/q10/consumable.py @@ -0,0 +1,40 @@ +"""Consumable trait for Q10 devices.""" + +from dataclasses import dataclass + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import Q10Consumable +from roborock.devices.traits.b01.q10.common import Q10TraitMixin + + +@dataclass +class Q10ConsumableTrait(Q10Consumable, Q10TraitMixin): + """Trait for managing the consumables of Q10 devices.""" + + dps_field_map = { + B01_Q10_DP.MAIN_BRUSH_LIFE: "main_brush_life", + B01_Q10_DP.SIDE_BRUSH_LIFE: "side_brush_life", + B01_Q10_DP.FILTER_LIFE: "filter_life", + B01_Q10_DP.RAG_LIFE: "rag_life", + B01_Q10_DP.SENSOR_LIFE: "sensor_life", + } + + async def reset_main_brush(self) -> None: + """Reset the main brush life.""" + await self.send_dp(B01_Q10_DP.RESET_MAIN_BRUSH, 1) + + async def reset_side_brush(self) -> None: + """Reset the side brush life.""" + await self.send_dp(B01_Q10_DP.RESET_SIDE_BRUSH, 1) + + async def reset_filter(self) -> None: + """Reset the filter life.""" + await self.send_dp(B01_Q10_DP.RESET_FILTER, 1) + + async def reset_rag_life(self) -> None: + """Reset the rag life.""" + await self.send_dp(B01_Q10_DP.RESET_RAG_LIFE, 1) + + async def reset_sensor(self) -> None: + """Reset the sensor life.""" + await self.send_dp(B01_Q10_DP.RESET_SENSOR, 1) diff --git a/roborock/devices/traits/b01/q10/status.py b/roborock/devices/traits/b01/q10/status.py new file mode 100644 index 00000000..668dbdb2 --- /dev/null +++ b/roborock/devices/traits/b01/q10/status.py @@ -0,0 +1,26 @@ +"""Status trait for Q10 devices.""" + +from dataclasses import dataclass + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import Q10Status +from roborock.devices.traits.b01.q10.common import Q10TraitMixin + + +@dataclass +class Q10StatusTrait(Q10Status, Q10TraitMixin): + """Trait for managing the status of Q10 devices.""" + + dps_field_map = { + B01_Q10_DP.CLEAN_TIME: "clean_time", + B01_Q10_DP.CLEAN_AREA: "clean_area", + B01_Q10_DP.BATTERY: "battery", + B01_Q10_DP.STATUS: "status", + B01_Q10_DP.FUN_LEVEL: "fun_level", + B01_Q10_DP.WATER_LEVEL: "water_level", + B01_Q10_DP.CLEAN_COUNT: "clean_count", + B01_Q10_DP.CLEAN_MODE: "clean_mode", + B01_Q10_DP.CLEAN_TASK_TYPE: "clean_task_type", + B01_Q10_DP.BACK_TYPE: "back_type", + B01_Q10_DP.CLEANING_PROGRESS: "cleaning_progress", + } diff --git a/roborock/devices/traits/traits_mixin.py b/roborock/devices/traits/traits_mixin.py index 92b9597e..60c15640 100644 --- a/roborock/devices/traits/traits_mixin.py +++ b/roborock/devices/traits/traits_mixin.py @@ -34,6 +34,9 @@ class TraitsMixin: b01_q7_properties: b01.Q7PropertiesApi | None = None """B01 Q7 properties trait, if supported.""" + b01_q10_properties: b01.Q10PropertiesApi | None = None + """B01 Q10 properties trait, if supported.""" + def __init__(self, trait: Trait) -> None: """Initialize the TraitsMixin with the given trait. diff --git a/roborock/protocols/b01_protocol.py b/roborock/protocols/b01_protocol.py index eeaad03a..dd638a81 100644 --- a/roborock/protocols/b01_protocol.py +++ b/roborock/protocols/b01_protocol.py @@ -21,10 +21,21 @@ ParamsType = list | dict | int | None -def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType, msg_id: str) -> RoborockMessage: - """Encode payload for B01 commands over MQTT.""" - dps_data = { - "dps": { +def encode_b01_mqtt_payload(dps: dict[int, Any]) -> RoborockMessage: + """Encode payload for B01 commands over MQTT with arbitrary DPs.""" + dps_data = {"dps": dps} + payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size) + return RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=payload, + ) + + +def encode_q7_payload(dps: int, command: CommandType, params: ParamsType, msg_id: str) -> RoborockMessage: + """Encode payload for Q7 commands over MQTT.""" + return encode_b01_mqtt_payload( + { dps: { "method": str(command), "msgId": msg_id, @@ -34,12 +45,6 @@ def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType, msg_ "params": params if params is not None else [], } } - } - payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size) - return RoborockMessage( - protocol=RoborockMessageProtocol.RPC_REQUEST, - version=B01_VERSION, - payload=payload, )