diff --git a/device_info.yaml b/device_info.yaml index df22af85..3034065a 100644 --- a/device_info.yaml +++ b/device_info.yaml @@ -1348,3 +1348,164 @@ roborock.vacuum.a288: code: map_diff mode: ro type: RAW + +roborock.vacuum.ss07: + protocol_version: B01 + product_nickname: PEARLPLUS + product: + id: 3gKDz7BnDrjXOVBAEXj4uw + name: Roborock Q10 Series + model: roborock.vacuum.ss07 + category: robot.vacuum.cleaner + capability: 0 + schema: + - id: 101 + name: RPC Request + code: rpc_request + mode: rw + type: RAW + property: 'null' + - id: 102 + name: RPC Response + code: rpc_response + mode: rw + type: RAW + property: 'null' + - id: 120 + name: "\u9519\u8BEF\u4EE3\u7801" + code: error_code + mode: ro + type: ENUM + property: '{"range": []}' + - id: 121 + name: "\u8BBE\u5907\u72B6\u6001" + code: state + mode: ro + type: VALUE + property: 'null' + - id: 122 + name: "\u8BBE\u5907\u7535\u91CF" + code: battery + mode: ro + type: ENUM + property: '{"range": []}' + - id: 123 + name: "\u5438\u529B\u6863\u4F4D" + code: fan_power + mode: rw + type: ENUM + property: '{"range": []}' + - id: 124 + name: "\u62D6\u5730\u6863\u4F4D" + code: water_box_mode + mode: rw + type: RAW + property: 'null' + - id: 125 + name: "\u4E3B\u5237\u5BFF\u547D" + code: main_brush_life + mode: ro + type: ENUM + property: '{"range": []}' + - id: 126 + name: "\u8FB9\u5237\u5BFF\u547D" + code: side_brush_life + mode: ro + type: ENUM + property: '{"range": []}' + - id: 127 + name: "\u6EE4\u7F51\u5BFF\u547D" + code: filter_life + mode: ro + type: ENUM + property: '{"range": []}' + - id: 135 + name: "\u79BB\u7EBF\u539F\u56E0" + code: offline_status + mode: ro + type: ENUM + property: '{"range": []}' + - id: 136 + name: "\u6E05\u6D01\u6B21\u6570" + code: clean_times + mode: rw + type: ENUM + property: '{"range": []}' + - id: 137 + name: "\u626B\u62D6\u6A21\u5F0F" + code: cleaning_preference + mode: rw + type: ENUM + property: '{"range": []}' + - id: 138 + name: "\u6E05\u6D01\u4EFB\u52A1\u7C7B\u578B" + code: clean_task_type + mode: ro + type: ENUM + property: '{"range": []}' + - id: 139 + name: "\u8FD4\u56DE\u57FA\u7AD9\u7C7B\u578B" + code: back_type + mode: ro + type: ENUM + property: '{"range": []}' + - id: 140 + name: "\u57FA\u7AD9\u4EFB\u52A1\u7C7B\u578B" + code: dock_task_type + mode: ro + type: ENUM + property: '{"range": []}' + - id: 141 + name: "\u6E05\u6D01\u8FDB\u5EA6" + code: cleaning_progress + mode: ro + type: ENUM + property: '{"range": []}' + - id: 142 + name: "\u7A9C\u8D27\u4FE1\u606F" + code: fc_state + mode: ro + type: RAW + property: 'null' + - id: 201 + name: "\u542F\u52A8\u6E05\u6D01\u4EFB\u52A1" + code: start_clean_task + mode: wo + type: ENUM + property: '{"range": []}' + - id: 202 + name: "\u8FD4\u56DE\u57FA\u7AD9\u4EFB\u52A1" + code: start_back_dock_task + mode: wo + type: ENUM + property: '{"range": []}' + - id: 203 + name: "\u542F\u52A8\u57FA\u7AD9\u4EFB\u52A1" + code: start_dock_task + mode: wo + type: ENUM + property: '{"range": []}' + - id: 204 + name: "\u6682\u505C\u4EFB\u52A1" + code: pause + mode: wo + type: RAW + property: 'null' + - id: 205 + name: "\u7EE7\u7EED\u4EFB\u52A1" + code: resume + mode: wo + type: RAW + property: 'null' + - id: 206 + name: "\u7ED3\u675F\u4EFB\u52A1" + code: stop + mode: wo + type: RAW + property: 'null' + - id: 207 + name: "\u7528\u6237\u6539\u5584\u8BA1\u5212" + code: ceip + mode: rw + type: ENUM + property: '{"range": ["0,1"]}' diff --git a/roborock/cli.py b/roborock/cli.py index b36b11ce..bb06e2f7 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -43,13 +43,19 @@ from roborock import RoborockCommand from roborock.data import RoborockBase, UserData -from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType, YXFanLevel +from roborock.data.b01_q10.b01_q10_code_mappings import ( + B01_Q10_DP, + YXCleanType, + YXDeviceDustCollectionFrequency, + YXFanLevel, +) from roborock.data.code_mappings import SHORT_MODEL_TO_ENUM from roborock.device_features import DeviceFeatures from roborock.devices.cache import Cache, CacheData from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager from roborock.devices.traits import Trait +from roborock.devices.traits.b01.q10 import Q10PropertiesApi from roborock.devices.traits.b01.q10.vacuum import VacuumTrait from roborock.devices.traits.v1 import V1TraitMixin from roborock.devices.traits.v1.consumeable import ConsumableAttribute @@ -439,13 +445,67 @@ async def _display_v1_trait(context: RoborockContext, device_id: str, display_fu click.echo(dump_json(trait.as_dict())) -async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait: - """Get VacuumTrait from Q10 device.""" +async def _q10_properties(context: RoborockContext, device_id: str) -> Q10PropertiesApi: + """Get the B01 Q10 properties API for a device.""" device_manager = await context.get_device_manager() device = await device_manager.get_device(device_id) if device.b01_q10_properties is None: raise RoborockUnsupportedFeature("Device does not support B01 Q10 protocol. Is it a Q10?") - return device.b01_q10_properties.vacuum + return device.b01_q10_properties + + +async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumTrait: + """Get VacuumTrait from Q10 device.""" + return (await _q10_properties(context, device_id)).vacuum + + +async def _display_q10_status(context: RoborockContext, device_id: str) -> None: + """Refresh and display the full status of a B01 Q10 device. + + Unlike V1 devices, the Q10 reports its state asynchronously: ``refresh()`` + sends a request and the device streams the values back over the persistent + subscribe loop. That loop also delivers unsolicited pushes, so the read-model + traits may already hold (possibly stale) values from before this command ran + -- checking that a field is merely populated isn't enough. To display data + the device sent *in response to this refresh*, we register update listeners, + fire the refresh, and wait for a fresh update before reading the traits. + + All read-model traits refreshed by :meth:`Q10PropertiesApi.refresh` are shown, + not just ``status`` (volume, child lock, do-not-disturb, dust collection, + network info and consumables are part of the device's reported state too). + """ + properties = await _q10_properties(context, device_id) + + # Read-model traits populated from the device's DPS push stream. + traits = { + "status": properties.status, + "volume": properties.volume, + "child_lock": properties.child_lock, + "do_not_disturb": properties.do_not_disturb, + "dust_collection": properties.dust_collection, + "network_info": properties.network_info, + "consumable": properties.consumable, + } + + updated = asyncio.Event() + unsubscribes = [trait.add_update_listener(updated.set) for trait in traits.values()] + try: + await properties.refresh() + try: + await asyncio.wait_for(updated.wait(), timeout=5) + except TimeoutError: + click.echo("Timed out waiting for status from device") + return + # The device streams its DPS across several pushes; give the remaining + # ones a brief window to arrive after the first fresh update. + await asyncio.sleep(0.5) + finally: + for unsubscribe in unsubscribes: + unsubscribe() + + # Each concrete trait also subclasses a RoborockBase read-model, so it has + # ``as_dict``; the cast satisfies the typed UpdatableTrait view above. + click.echo(dump_json({name: cast(RoborockBase, trait).as_dict() for name, trait in traits.items()})) @session.command() @@ -455,7 +515,14 @@ async def _q10_vacuum_trait(context: RoborockContext, device_id: str) -> VacuumT async def status(ctx, device_id: str): """Get device status.""" context: RoborockContext = ctx.obj - await _display_v1_trait(context, device_id, lambda v1: v1.status) + device_manager = await context.get_device_manager() + device = await device_manager.get_device(device_id) + if device.v1_properties is not None: + await _display_v1_trait(context, device_id, lambda v1: v1.status) + elif device.b01_q10_properties is not None: + await _display_q10_status(context, device_id) + else: + click.echo("Feature not supported by device") @session.command() @@ -1294,6 +1361,123 @@ async def q10_vacuum_dock(ctx: click.Context, device_id: str) -> None: click.echo(f"Error: {e}") +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.pass_context +@async_command +async def q10_vacuum_spot(ctx: click.Context, device_id: str) -> None: + """Start a spot / part clean on a Q10 device.""" + context: RoborockContext = ctx.obj + try: + trait = await _q10_vacuum_trait(context, device_id) + await trait.spot_clean() + click.echo("Starting spot clean...") + 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 + try: + properties = await _q10_properties(context, device_id) + await apply(properties) + click.echo(message) + except RoborockUnsupportedFeature: + click.echo("Device does not support B01 Q10 protocol. Is it a Q10?") + except (RoborockException, ValueError) as e: + click.echo(f"Error: {e}") + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--volume", required=True, type=int, help="Volume 0-100") +@click.pass_context +@async_command +async def q10_set_volume(ctx: click.Context, device_id: str, volume: int) -> None: + """Set the speaker volume on a Q10 device.""" + await _q10_set(ctx, device_id, lambda p: p.volume.set_volume(volume), f"Volume set to {volume}") + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_child_lock(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable the child lock on a Q10 device.""" + await _q10_set( + ctx, + device_id, + lambda p: p.child_lock.enable() if enabled else p.child_lock.disable(), + f"Child lock set to {enabled}", + ) + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_dnd(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable Do Not Disturb on a Q10 device.""" + await _q10_set( + ctx, + device_id, + lambda p: p.do_not_disturb.enable() if enabled else p.do_not_disturb.disable(), + f"Do Not Disturb set to {enabled}", + ) + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_led(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable the indicator light (LED) on a Q10 device.""" + await _q10_set( + ctx, + device_id, + lambda p: p.button_light.enable() if enabled else p.button_light.disable(), + f"LED set to {enabled}", + ) + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False)") +@click.pass_context +@async_command +async def q10_set_dust_collection(ctx: click.Context, device_id: str, enabled: bool) -> None: + """Enable or disable automatic dust collection on a Q10 device.""" + await _q10_set( + ctx, + device_id, + lambda p: p.dust_collection.enable() if enabled else p.dust_collection.disable(), + f"Dust collection set to {enabled}", + ) + + +@session.command() +@click.option("--device_id", required=True, help="Device ID") +@click.option( + "--frequency", + required=True, + type=click.Choice([str(m.code) for m in YXDeviceDustCollectionFrequency]), + help="Empty after every N cleans (0 = daily).", +) +@click.pass_context +@async_command +async def q10_set_dust_frequency(ctx: click.Context, device_id: str, frequency: str) -> None: + """Set how often the dock empties the bin (0 = daily, else every N cleans).""" + freq = YXDeviceDustCollectionFrequency.from_code(int(frequency)) + label = "daily" if freq.code == 0 else f"every {freq.code} cleans" + await _q10_set(ctx, device_id, lambda p: p.dust_collection.set_frequency(freq), f"Dust frequency set to {label}") + + @session.command() @click.option("--device_id", required=True, help="Device ID") @click.pass_context diff --git a/roborock/data/b01_q10/b01_q10_code_mappings.py b/roborock/data/b01_q10/b01_q10_code_mappings.py index 8e236596..c3e1771c 100644 --- a/roborock/data/b01_q10/b01_q10_code_mappings.py +++ b/roborock/data/b01_q10/b01_q10_code_mappings.py @@ -85,9 +85,14 @@ class B01_Q10_DP(RoborockModeEnum): SUSPECTED_THRESHOLD = ("dpSuspectedThreshold", 99) SUSPECTED_THRESHOLD_UP = ("dpSuspectedThresholdUp", 100) COMMON = ("dpCommon", 101) - JUMP_SCAN = ("dpJumpScan", 101) REQUEST_DPS = ("dpRequestDps", 102) # NOTE: typo "dpRequetdps" in source code - CLIFF_RESTRICTED_AREA = ("dpCliffRestrictedArea", 102) + # NOTE: the legacy B01 source also listed dpJumpScan (101) and + # dpCliffRestrictedArea (102), which collided with the confirmed dpCommon / + # dpRequestDps codes above (verified against ss07 hardware and the official app + # plugin) and shadowed them in ``from_code``. Both are unused and their real + # codes could not be verified, so they were removed rather than left as wrong + # duplicates. dpCliffRestrictedAreaUp (103) is kept: ss07 hardware does push + # data point 103 (an empty list when no cliff-restricted areas are set). CLIFF_RESTRICTED_AREA_UP = ("dpCliffRestrictedAreaUp", 103) BREAKPOINT_CLEAN = ("dpBreakpointClean", 104) VALLEY_POINT_CHARGING = ("dpValleyPointCharging", 105) @@ -96,6 +101,13 @@ class B01_Q10_DP(RoborockModeEnum): VOICE_VERSION = ("dpVoiceVersion", 108) ROBOT_COUNTRY_CODE = ("dpRobotCountryCode", 109) HEARTBEAT = ("dpHeartbeat", 110) + # NOTE: ss07 hardware also pushes data points 112 and 113 in its full status + # dump. They are absent from the official app's vacuum plugin and stayed 0 + # across every observed state (docked/charging, segment cleaning, lifted-off- + # ground fault, returning to dock, dustbin removed), so their meaning is not + # yet known. They are intentionally left unmapped; ``decode_rpc_response`` + # silently ignores unknown codes via ``from_code_optional``, so they do not + # produce "not a valid code" warnings. Map them here once identified. STATUS = ("dpStatus", 121) BATTERY = ("dpBattery", 122) FAN_LEVEL = ("dpFanLevel", 123) # NOTE: typo "dpfunLevel" in source code @@ -215,13 +227,31 @@ class YXDeviceCleanTask(RoborockModeEnum): class YXDeviceDustCollectionFrequency(RoborockModeEnum): - DAILY = "daily", 0 + # The app exposes "regular" (code 0) vs "frequent", where "frequent" selects + # one of the every-N-cleans intervals below. + REGULAR = "regular", 0 INTERVAL_15 = "interval_15", 15 INTERVAL_30 = "interval_30", 30 INTERVAL_45 = "interval_45", 45 INTERVAL_60 = "interval_60", 60 +class YXAreaUnit(RoborockModeEnum): + """Unit used to report cleaned area (dpAreaUnit).""" + + SQUARE_METER = "square_meter", 0 + SQUARE_FEET = "square_feet", 1 + + +class YXCarpetCleanType(RoborockModeEnum): + """Carpet handling behavior (dpCarpetCleanType).""" + + RISE = "rise", 0 # lift the mop and boost over carpet + AVOID = "avoid", 1 + IGNORE = "ignore", 2 + CROSS = "cross", 3 + + 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 393eb231..db57a313 100644 --- a/roborock/data/b01_q10/b01_q10_containers.py +++ b/roborock/data/b01_q10/b01_q10_containers.py @@ -2,7 +2,7 @@ Many of these classes use the `field(metadata={"dps": ...})` convention to map dataclass fields to device Data Points (DPS). This metadata is utilized by the -`update_from_dps` helper in `roborock.devices.traits.b01.q10.common` to +`UpdatableTrait` helper in `roborock.devices.traits.b01.q10.common` to automatically update objects from raw device responses. """ @@ -11,9 +11,13 @@ from ..containers import RoborockBase from .b01_q10_code_mappings import ( B01_Q10_DP, + YXAreaUnit, YXBackType, + YXCarpetCleanType, + YXCleanLine, YXCleanType, YXDeviceCleanTask, + YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, YXWaterLevel, @@ -51,18 +55,19 @@ class dpSelfIdentifyingCarpet(RoborockBase): @dataclass class dpNetInfo(RoborockBase): - wifiName: str - ipAdress: str - mac: str - signal: int + wifi_name: str | None = None + # "ip_adress" intentionally mirrors the device's "ipAdress" key (sic). + ip_adress: str | None = None + mac: str | None = None + signal: int | None = None @dataclass class dpNotDisturbExpand(RoborockBase): - disturb_dust_enable: int - disturb_light: int - disturb_resume_clean: int - disturb_voice: int + disturb_dust_enable: int | None = None + disturb_light: int | None = None + disturb_resume_clean: int | None = None + disturb_voice: int | None = None @dataclass @@ -77,16 +82,18 @@ class dpVoiceVersion(RoborockBase): @dataclass class dpTimeZone(RoborockBase): - timeZoneCity: str - timeZoneSec: int + time_zone_city: str | None = None + time_zone_sec: int | None = None @dataclass class Q10Status(RoborockBase): - """Status for Q10 devices. + """Core vacuum status for Q10 devices. Fields are mapped to DPS values using metadata. Objects of this class can be - automatically updated using the `update_from_dps` helper. + automatically updated using the `UpdatableTrait` helper. Settings that have + their own trait (volume, child lock, do-not-disturb, dust collection, + network info, consumables) live on those traits instead of here. """ clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TIME}) @@ -99,12 +106,93 @@ class Q10Status(RoborockBase): total_clean_area: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_AREA}) total_clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_COUNT}) total_clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TOTAL_CLEAN_TIME}) - main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE}) - side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE}) - 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}) clean_mode: YXCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_MODE}) 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}) + + # Additional state reported in the device's full status dump. + clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE}) + carpet_clean_type: YXCarpetCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE}) + area_unit: YXAreaUnit | None = field(default=None, metadata={"dps": B01_Q10_DP.AREA_UNIT}) + auto_boost: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.AUTO_BOOST}) + multi_map_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP_SWITCH}) + map_save_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MAP_SAVE_SWITCH}) + recent_clean_record: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.RECENT_CLEAN_RECORD}) + valley_point_charging: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.VALLEY_POINT_CHARGING}) + line_laser_obstacle_avoidance: bool | None = field( + default=None, metadata={"dps": B01_Q10_DP.LINE_LASER_OBSTACLE_AVOIDANCE} + ) + # Whether a mop module is attached, and whether "clean along floor direction" is on. + mop_state: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.MOP_STATE}) + ground_clean: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.GROUND_CLEAN}) + # True while an "add area" / re-clean (the app's draw-a-rectangle "re cleaning") + # request is in progress; pulses back to False once the robot has the area. + add_clean_state: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.ADD_CLEAN_STATE}) + robot_country_code: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_COUNTRY_CODE}) + time_zone: dpTimeZone | None = field(default=None, metadata={"dps": B01_Q10_DP.TIME_ZONE}) + + # TODO(#846): value mappings for these ints are not yet decoded (no app + # control found / internal / constant); keep as int until reverse-engineered. + breakpoint_clean: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BREAKPOINT_CLEAN}) + timer_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.TIMER_TYPE}) + user_plan: int | None = field(default=None, metadata={"dps": B01_Q10_DP.USER_PLAN}) + robot_type: int | None = field(default=None, metadata={"dps": B01_Q10_DP.ROBOT_TYPE}) + + +@dataclass +class SoundVolume(RoborockBase): + """Speaker volume read-model (0-100).""" + + volume: int | None = field(default=None, metadata={"dps": B01_Q10_DP.VOLUME}) + + +@dataclass +class ChildLock(RoborockBase): + """Child-lock read-model.""" + + child_lock: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.CHILD_LOCK}) + + +@dataclass +class DoNotDisturb(RoborockBase): + """Do Not Disturb read-model.""" + + not_disturb: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB}) + not_disturb_expand: dpNotDisturbExpand | None = field(default=None, metadata={"dps": B01_Q10_DP.NOT_DISTURB_EXPAND}) + + +@dataclass +class DustCollection(RoborockBase): + """Dock auto-empty (dust collection) read-model.""" + + dust_switch: bool | None = field(default=None, metadata={"dps": B01_Q10_DP.DUST_SWITCH}) + dust_setting: YXDeviceDustCollectionFrequency | None = field( + default=None, metadata={"dps": B01_Q10_DP.DUST_SETTING} + ) + + +@dataclass +class Q10Consumable(RoborockBase): + """Consumable / accessory remaining-life read-model. + + Named with a ``Q10`` prefix to avoid shadowing the v1 ``Consumable`` when both + are star-imported into the ``roborock.data`` namespace. + """ + + main_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.MAIN_BRUSH_LIFE}) + side_brush_life: int | None = field(default=None, metadata={"dps": B01_Q10_DP.SIDE_BRUSH_LIFE}) + 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}) + + +@dataclass +class Q10NetworkInfo(RoborockBase): + """Network information read-model. + + Named with a ``Q10`` prefix to avoid shadowing the v1 ``NetworkInfo`` when both + are star-imported into the ``roborock.data`` namespace. + """ + + net_info: dpNetInfo | None = field(default=None, metadata={"dps": B01_Q10_DP.NET_INFO}) diff --git a/roborock/data/code_mappings.py b/roborock/data/code_mappings.py index d1f36b83..a0864408 100644 --- a/roborock/data/code_mappings.py +++ b/roborock/data/code_mappings.py @@ -76,11 +76,17 @@ def from_code(cls, code: int) -> Self: @classmethod def from_code_optional(cls, code: int) -> Self | None: - """Gracefully return None if the code does not exist.""" - try: - return cls.from_code(code) - except ValueError: - return None + """Gracefully return None if the code does not exist. + + This is the silent counterpart to :meth:`from_code`: callers use it when + an unknown code is expected and tolerable (e.g. decoding a device push + that may include data points this library does not model yet), so it must + not emit the "not a valid code" warning that ``from_code`` logs. + """ + for member in cls: + if member.code == code: + return member + return None @classmethod def from_value(cls, value: str) -> Self: @@ -115,12 +121,14 @@ def from_any_optional(cls, value: str | int) -> Self | None: return cls.from_value(str(value)) except ValueError: pass - # Try integer code lookup (e.g. "11") + # Try integer code lookup (e.g. "11"). Use the silent optional variant so + # a value that is neither a name, a DP string, nor a known code resolves + # to None without logging a spurious "not a valid code" warning. try: - return cls.from_code(int(value)) + int_code = int(value) except (ValueError, TypeError): - pass - return None + return None + return cls.from_code_optional(int_code) @classmethod def keys(cls) -> list[str]: diff --git a/roborock/devices/traits/b01/q10/__init__.py b/roborock/devices/traits/b01/q10/__init__.py index 184de2d2..f803e6f4 100644 --- a/roborock/devices/traits/b01/q10/__init__.py +++ b/roborock/devices/traits/b01/q10/__init__.py @@ -8,13 +8,28 @@ from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel +from .button_light import ButtonLightTrait +from .child_lock import ChildLockTrait from .command import CommandTrait +from .consumable import ConsumableTrait +from .do_not_disturb import DoNotDisturbTrait +from .dust_collection import DustCollectionTrait +from .network_info import NetworkInfoTrait from .remote import RemoteTrait from .status import StatusTrait from .vacuum import VacuumTrait +from .volume import SoundVolumeTrait __all__ = [ "Q10PropertiesApi", + "ButtonLightTrait", + "ChildLockTrait", + "ConsumableTrait", + "DoNotDisturbTrait", + "DustCollectionTrait", + "NetworkInfoTrait", + "SoundVolumeTrait", + "StatusTrait", ] _LOGGER = logging.getLogger(__name__) @@ -27,7 +42,7 @@ class Q10PropertiesApi(Trait): """Trait for sending commands to Q10 devices.""" status: StatusTrait - """Trait for managing the status of Q10 devices.""" + """Trait for managing the core status of Q10 devices.""" vacuum: VacuumTrait """Trait for sending vacuum related commands to Q10 devices.""" @@ -35,6 +50,27 @@ class Q10PropertiesApi(Trait): remote: RemoteTrait """Trait for sending remote control related commands to Q10 devices.""" + volume: SoundVolumeTrait + """Trait for reading / setting the speaker volume.""" + + child_lock: ChildLockTrait + """Trait for reading / controlling the child lock.""" + + do_not_disturb: DoNotDisturbTrait + """Trait for reading / controlling Do Not Disturb.""" + + dust_collection: DustCollectionTrait + """Trait for reading / controlling dock auto-empty (dust collection).""" + + button_light: ButtonLightTrait + """Trait for controlling the indicator / button light (LED).""" + + network_info: NetworkInfoTrait + """Trait exposing the device's network information.""" + + consumable: ConsumableTrait + """Trait exposing remaining life of consumables.""" + def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self._channel = channel @@ -42,6 +78,23 @@ def __init__(self, channel: MqttChannel) -> None: self.vacuum = VacuumTrait(self.command) self.remote = RemoteTrait(self.command) self.status = StatusTrait() + self.volume = SoundVolumeTrait(self.command) + self.child_lock = ChildLockTrait(self.command) + self.do_not_disturb = DoNotDisturbTrait(self.command) + self.dust_collection = DustCollectionTrait(self.command) + self.button_light = ButtonLightTrait(self.command) + self.network_info = NetworkInfoTrait() + self.consumable = ConsumableTrait() + # Read-model traits updated from the device's DPS push stream. + self._updatable_traits = [ + self.status, + self.volume, + self.child_lock, + self.do_not_disturb, + self.dust_collection, + self.network_info, + self.consumable, + ] self._subscribe_task: asyncio.Task[None] | None = None async def start(self) -> None: @@ -69,10 +122,10 @@ async def _subscribe_loop(self) -> None: async for decoded_dps in stream_decoded_responses(self._channel): _LOGGER.debug("Received Q10 status update: %s", decoded_dps) - # Notify all traits about a new message and each trait will - # only update what fields that it is responsible for. - # More traits can be added here below. - self.status.update_from_dps(decoded_dps) + # Notify all read-model traits about the new message; each trait + # only updates the fields that it is responsible for. + for trait in self._updatable_traits: + trait.update_from_dps(decoded_dps) def create(channel: MqttChannel) -> Q10PropertiesApi: diff --git a/roborock/devices/traits/b01/q10/button_light.py b/roborock/devices/traits/b01/q10/button_light.py new file mode 100644 index 00000000..7ff075c0 --- /dev/null +++ b/roborock/devices/traits/b01/q10/button_light.py @@ -0,0 +1,29 @@ +"""Indicator / button light (LED) trait for Q10 B01 devices.""" + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP + +from .command import CommandTrait + + +class ButtonLightTrait: + """Trait for controlling the indicator / button light (LED) of a Q10 device. + + The device does not report the button-light state in its status dump, so + this trait is write-only (no read-back). + """ + + def __init__(self, command: CommandTrait) -> None: + """Initialize the button light trait.""" + self._command = command + + async def _write(self, value: int) -> None: + """Write the button-light data point via the dpCommon (101) wrapper.""" + await self._command.send(B01_Q10_DP.COMMON, {str(B01_Q10_DP.BUTTON_LIGHT_SWITCH.code): value}) + + async def enable(self) -> None: + """Turn the indicator light on.""" + await self._write(1) + + async def disable(self) -> None: + """Turn the indicator light off.""" + await self._write(0) 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..968bfe8d --- /dev/null +++ b/roborock/devices/traits/b01/q10/child_lock.py @@ -0,0 +1,36 @@ +"""Child lock trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import ChildLock +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class ChildLockTrait(ChildLock, UpdatableTrait): + """Trait for reading and controlling the child lock of a Q10 device.""" + + _CONVERTER = DpsDataConverter.from_dataclass(ChildLock) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the child lock trait.""" + ChildLock.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + @property + def is_on(self) -> bool: + """Return whether the child lock is enabled.""" + return bool(self.child_lock) + + async def enable(self) -> None: + """Enable the child lock.""" + await self._write(B01_Q10_DP.CHILD_LOCK, 1) + + async def disable(self) -> None: + """Disable the child lock.""" + await self._write(B01_Q10_DP.CHILD_LOCK, 0) diff --git a/roborock/devices/traits/b01/q10/common.py b/roborock/devices/traits/b01/q10/common.py new file mode 100644 index 00000000..6e9fff03 --- /dev/null +++ b/roborock/devices/traits/b01/q10/common.py @@ -0,0 +1,70 @@ +"""Common helpers for Q10 B01 traits. + +Q10 devices push their full state as a single decoded DPS dictionary (see +``Q10PropertiesApi._subscribe_loop``). Each trait owns a small ``RoborockBase`` +read-model whose fields are annotated with ``field(metadata={"dps": ...})`` and +only updates the fields it is responsible for, ignoring the rest. + +The :class:`UpdatableTrait` base wires that read-model to the update lifecycle +and (for traits that also write) exposes the ``dpCommon`` (101) wrapper used by +Q10 setting writes. +""" + +import logging +from typing import Any, ClassVar, cast + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.containers import RoborockBase +from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener + +from .command import CommandTrait + + +class UpdatableTrait(TraitUpdateListener): + """Base for Q10 traits backed by a read-model updated from the DPS stream. + + Concrete traits subclass both their ``RoborockBase`` read-model and this + class, set the ``_CONVERTER`` class attribute, and initialize the read-model + explicitly in their constructor, e.g.:: + + class SoundVolumeTrait(SoundVolume, UpdatableTrait): + _CONVERTER = DpsDataConverter.from_dataclass(SoundVolume) + + def __init__(self, command: CommandTrait) -> None: + SoundVolume.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + The read-model init is called explicitly (rather than via ``super()``) + because the read-model dataclass precedes this class in the MRO. + + Traits that also send commands receive a :class:`CommandTrait` and use the + :meth:`_write` helper, which wraps the write in the ``dpCommon`` (101) data + point as the device requires. + """ + + _CONVERTER: ClassVar[DpsDataConverter] + + def __init__(self, command: CommandTrait | None, logger: logging.Logger) -> None: + """Initialize the update listener. The read-model is initialized by the subclass.""" + TraitUpdateListener.__init__(self, logger=logger) + self._command = command + + def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: + """Update the trait's read-model from raw DPS data and notify listeners. + + Concrete traits also subclass a ``RoborockBase`` read-model, so the cast + is always valid at runtime. + """ + if self._CONVERTER.update_from_dps(cast(RoborockBase, self), decoded_dps): + self._notify_update() + + async def _write(self, dp: B01_Q10_DP, value: int) -> None: + """Write a single data point value via the dpCommon (101) wrapper. + + Q10 setting writes must be wrapped in ``dpCommon`` (101), e.g. setting + the volume sends ``{"dps": {"101": {"26": }}}``. Writing the bare + data point (without the wrapper) is silently ignored by the device. + """ + if self._command is None: + raise ValueError("Trait is read-only; no command channel was provided") + await self._command.send(B01_Q10_DP.COMMON, {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..914acd9f --- /dev/null +++ b/roborock/devices/traits/b01/q10/consumable.py @@ -0,0 +1,21 @@ +"""Consumable / accessory life trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_containers import Q10Consumable +from roborock.devices.traits.common import DpsDataConverter + +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class ConsumableTrait(Q10Consumable, UpdatableTrait): + """Trait exposing remaining life of consumables (brushes, filter, sensors).""" + + _CONVERTER = DpsDataConverter.from_dataclass(Q10Consumable) + + def __init__(self) -> None: + """Initialize the consumable trait.""" + Q10Consumable.__init__(self) + UpdatableTrait.__init__(self, command=None, logger=_LOGGER) diff --git a/roborock/devices/traits/b01/q10/do_not_disturb.py b/roborock/devices/traits/b01/q10/do_not_disturb.py new file mode 100644 index 00000000..1c65bb5f --- /dev/null +++ b/roborock/devices/traits/b01/q10/do_not_disturb.py @@ -0,0 +1,36 @@ +"""Do Not Disturb trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import DoNotDisturb +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class DoNotDisturbTrait(DoNotDisturb, UpdatableTrait): + """Trait for reading and controlling Do Not Disturb on a Q10 device.""" + + _CONVERTER = DpsDataConverter.from_dataclass(DoNotDisturb) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the Do Not Disturb trait.""" + DoNotDisturb.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + @property + def is_on(self) -> bool: + """Return whether Do Not Disturb is enabled.""" + return bool(self.not_disturb) + + async def enable(self) -> None: + """Enable Do Not Disturb.""" + await self._write(B01_Q10_DP.NOT_DISTURB, 1) + + async def disable(self) -> None: + """Disable Do Not Disturb.""" + await self._write(B01_Q10_DP.NOT_DISTURB, 0) diff --git a/roborock/devices/traits/b01/q10/dust_collection.py b/roborock/devices/traits/b01/q10/dust_collection.py new file mode 100644 index 00000000..78c6fb24 --- /dev/null +++ b/roborock/devices/traits/b01/q10/dust_collection.py @@ -0,0 +1,43 @@ +"""Dust collection (dock auto-empty) trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceDustCollectionFrequency +from roborock.data.b01_q10.b01_q10_containers import DustCollection +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class DustCollectionTrait(DustCollection, UpdatableTrait): + """Trait for reading and controlling automatic dust collection at the dock.""" + + _CONVERTER = DpsDataConverter.from_dataclass(DustCollection) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the dust collection trait.""" + DustCollection.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + @property + def is_on(self) -> bool: + """Return whether automatic dust collection is enabled.""" + return bool(self.dust_switch) + + async def enable(self) -> None: + """Enable automatic dust collection at the dock.""" + await self._write(B01_Q10_DP.DUST_SWITCH, 1) + + async def disable(self) -> None: + """Disable automatic dust collection at the dock.""" + await self._write(B01_Q10_DP.DUST_SWITCH, 0) + + async def set_frequency(self, frequency: YXDeviceDustCollectionFrequency) -> None: + """Set how often the dock empties the bin. + + The value is the interval in cleans, with ``DAILY`` (0) meaning daily. + """ + await self._write(B01_Q10_DP.DUST_SETTING, frequency.code) diff --git a/roborock/devices/traits/b01/q10/network_info.py b/roborock/devices/traits/b01/q10/network_info.py new file mode 100644 index 00000000..2fd4d504 --- /dev/null +++ b/roborock/devices/traits/b01/q10/network_info.py @@ -0,0 +1,21 @@ +"""Network information trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_containers import Q10NetworkInfo +from roborock.devices.traits.common import DpsDataConverter + +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class NetworkInfoTrait(Q10NetworkInfo, UpdatableTrait): + """Trait exposing the device's network information (read-only).""" + + _CONVERTER = DpsDataConverter.from_dataclass(Q10NetworkInfo) + + def __init__(self) -> None: + """Initialize the network info trait.""" + Q10NetworkInfo.__init__(self) + UpdatableTrait.__init__(self, command=None, logger=_LOGGER) diff --git a/roborock/devices/traits/b01/q10/status.py b/roborock/devices/traits/b01/q10/status.py index e5769b09..f4b38a67 100644 --- a/roborock/devices/traits/b01/q10/status.py +++ b/roborock/devices/traits/b01/q10/status.py @@ -1,31 +1,26 @@ """Status trait for Q10 B01 devices.""" import logging -from typing import Any -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.common import DpsDataConverter, TraitUpdateListener +from roborock.devices.traits.common import DpsDataConverter -_LOGGER = logging.getLogger(__name__) +from .common import UpdatableTrait -_CONVERTER = DpsDataConverter.from_dataclass(Q10Status) +_LOGGER = logging.getLogger(__name__) -class StatusTrait(Q10Status, TraitUpdateListener): - """Trait for managing the status of Q10 Roborock devices. +class StatusTrait(Q10Status, UpdatableTrait): + """Trait for managing the core status of Q10 Roborock devices. This is a thin wrapper around Q10Status that provides the Trait interface. The current values reflect the most recently received data from the device. New values can be requested through the `Q10PropertiesApi`'s `refresh` method. """ + _CONVERTER = DpsDataConverter.from_dataclass(Q10Status) + def __init__(self) -> None: """Initialize the status trait.""" - super().__init__() - TraitUpdateListener.__init__(self, logger=_LOGGER) - - def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None: - """Update the trait from raw DPS data.""" - if _CONVERTER.update_from_dps(self, decoded_dps): - self._notify_update() + Q10Status.__init__(self) + UpdatableTrait.__init__(self, command=None, logger=_LOGGER) diff --git a/roborock/devices/traits/b01/q10/vacuum.py b/roborock/devices/traits/b01/q10/vacuum.py index 1ed9febb..fbe447de 100644 --- a/roborock/devices/traits/b01/q10/vacuum.py +++ b/roborock/devices/traits/b01/q10/vacuum.py @@ -21,50 +21,56 @@ def __init__(self, command: CommandTrait) -> None: self._command = command async def start_clean(self) -> None: - """Start cleaning.""" - await self._command.send( - command=B01_Q10_DP.START_CLEAN, - # TODO: figure out other commands - # 1 = start cleaning - # 2 = "electoral" clean, also has "clean_parameters" - # 4 = fast create map - params={"cmd": 1}, - ) + """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. + + 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 spot_clean(self) -> None: + """Start a spot / part clean around the robot's current position. + + Verified live: ``{"dps": {"201": 5}}`` (clean_task_type -> 5). + """ + await self._command.send(command=B01_Q10_DP.START_CLEAN, params=5) async def pause_clean(self) -> None: - """Pause cleaning.""" - await self._command.send( - command=B01_Q10_DP.PAUSE, - params={}, - ) + """Pause the current task. Verified live: ``{"dps": {"204": 0}}``.""" + await self._command.send(command=B01_Q10_DP.PAUSE, params=0) async def resume_clean(self) -> None: - """Resume cleaning.""" - await self._command.send( - command=B01_Q10_DP.RESUME, - params={}, - ) + """Resume a paused task. Verified live: ``{"dps": {"205": 0}}``.""" + await self._command.send(command=B01_Q10_DP.RESUME, params=0) async def stop_clean(self) -> None: - """Stop cleaning.""" - await self._command.send( - command=B01_Q10_DP.STOP, - params={}, - ) + """Stop / cancel the current task. Verified live: ``{"dps": {"206": 0}}``.""" + await self._command.send(command=B01_Q10_DP.STOP, params=0) async def return_to_dock(self) -> None: - """Return to dock.""" - await self._command.send( - command=B01_Q10_DP.START_DOCK_TASK, - params={}, - ) + """Send the robot back to the dock to charge. + + Uses ``dpStartBack`` (202) with the back-dock task code ``5`` (charge), + matching the official app. Verified live: ``{"dps": {"202": 5}}`` puts the + robot into the returning state. (The other back-dock codes are ``1`` = + wash mop en route and ``4`` = collect dust en route.) + """ + await self._command.send(command=B01_Q10_DP.START_BACK, params=5) async def empty_dustbin(self) -> None: - """Empty the dustbin at the dock.""" - await self._command.send( - command=B01_Q10_DP.START_DOCK_TASK, - params=2, # 2 = dock task type for "empty dustbin" - ) + """Empty the dustbin at the dock. + + Verified live: ``{"dps": {"203": 2}}`` triggers dust collection + (status -> emptying_the_bin). This is a dock task (``dpStartDockTask``), + distinct from the en-route collect-dust back-dock code. + """ + await self._command.send(command=B01_Q10_DP.START_DOCK_TASK, params=2) async def set_clean_mode(self, mode: YXCleanType) -> None: """Set the cleaning mode (vacuum, mop, or both).""" diff --git a/roborock/devices/traits/b01/q10/volume.py b/roborock/devices/traits/b01/q10/volume.py new file mode 100644 index 00000000..43d0e9a5 --- /dev/null +++ b/roborock/devices/traits/b01/q10/volume.py @@ -0,0 +1,29 @@ +"""Sound volume trait for Q10 B01 devices.""" + +import logging + +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP +from roborock.data.b01_q10.b01_q10_containers import SoundVolume +from roborock.devices.traits.common import DpsDataConverter + +from .command import CommandTrait +from .common import UpdatableTrait + +_LOGGER = logging.getLogger(__name__) + + +class SoundVolumeTrait(SoundVolume, UpdatableTrait): + """Trait for reading and setting the speaker volume of a Q10 device.""" + + _CONVERTER = DpsDataConverter.from_dataclass(SoundVolume) + + def __init__(self, command: CommandTrait) -> None: + """Initialize the volume trait.""" + SoundVolume.__init__(self) + UpdatableTrait.__init__(self, command, _LOGGER) + + async def set_volume(self, volume: int) -> None: + """Set the speaker volume (0-100).""" + if not 0 <= volume <= 100: + raise ValueError("volume must be between 0 and 100") + await self._write(B01_Q10_DP.VOLUME, volume) diff --git a/tests/data/test_code_mappings.py b/tests/data/test_code_mappings.py index f7ba6321..48ef6f8e 100644 --- a/tests/data/test_code_mappings.py +++ b/tests/data/test_code_mappings.py @@ -2,10 +2,13 @@ These tests exercise the custom enum methods using arbitrary enum values. """ +import logging + import pytest from roborock import HomeDataProduct, RoborockCategory from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType +from roborock.data.code_mappings import completed_warnings def test_from_code() -> None: @@ -26,6 +29,29 @@ def test_invalid_from_code_optional() -> None: assert B01_Q10_DP.from_code_optional(999999) is None +def test_from_code_optional_does_not_warn(caplog: pytest.LogCaptureFixture) -> None: + """from_code_optional must silently return None for unknown codes. + + Regression test: ss07 hardware pushes data points this library does not model + (e.g. DPs 112 and 113); resolving them via the optional lookup must not emit + the "not a valid code" warning that the strict ``from_code`` logs. + """ + completed_warnings.discard("112 is not a valid code for B01_Q10_DP") + completed_warnings.discard("113 is not a valid code for B01_Q10_DP") + with caplog.at_level(logging.WARNING): + assert B01_Q10_DP.from_code_optional(112) is None + assert B01_Q10_DP.from_code_optional(113) is None + assert "not a valid code" not in caplog.text + + +def test_from_code_still_warns(caplog: pytest.LogCaptureFixture) -> None: + """The strict from_code keeps logging and raising on unknown codes.""" + completed_warnings.discard("87654 is not a valid code for B01_Q10_DP") + with caplog.at_level(logging.WARNING), pytest.raises(ValueError, match="not a valid code"): + B01_Q10_DP.from_code(87654) + assert "87654 is not a valid code for B01_Q10_DP" in caplog.text + + def test_from_name() -> None: """Test from_name method.""" assert B01_Q10_DP.START_CLEAN == B01_Q10_DP.from_name("START_CLEAN") diff --git a/tests/devices/traits/b01/q10/test_settings.py b/tests/devices/traits/b01/q10/test_settings.py new file mode 100644 index 00000000..7b3474c5 --- /dev/null +++ b/tests/devices/traits/b01/q10/test_settings.py @@ -0,0 +1,87 @@ +"""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 +from roborock.devices.traits.b01.q10.button_light import ButtonLightTrait +from roborock.devices.traits.b01.q10.child_lock import ChildLockTrait +from roborock.devices.traits.b01.q10.command import CommandTrait +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 + + +@pytest.fixture +def fake_channel() -> FakeChannel: + return FakeChannel() + + +@pytest.fixture +def command(fake_channel: FakeChannel) -> CommandTrait: + return CommandTrait(cast(MqttChannel, fake_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"] + + +async def test_set_volume_uses_common_wrapper(fake_channel: FakeChannel, 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}} + + +@pytest.mark.parametrize("volume", [-1, 101, 1000]) +async def test_set_volume_rejects_out_of_range(command: CommandTrait, volume: int) -> None: + with pytest.raises(ValueError, match="between 0 and 100"): + await SoundVolumeTrait(command).set_volume(volume) + + +@pytest.mark.parametrize( + ("trait_cls", "method", "code"), + [ + (ChildLockTrait, "enable", "47"), + (DoNotDisturbTrait, "enable", "25"), + (ButtonLightTrait, "enable", "77"), + (DustCollectionTrait, "enable", "37"), + ], +) +async def test_switch_enable_writes_common_wrapped_dp( + fake_channel: FakeChannel, 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}} + + +async def test_switch_disable_sends_zero(fake_channel: FakeChannel, command: CommandTrait) -> None: + await ChildLockTrait(command).disable() + assert _sent_dps(fake_channel) == {"101": {"47": 0}} + + +@pytest.mark.parametrize( + ("frequency", "code"), + [ + (YXDeviceDustCollectionFrequency.REGULAR, 0), + (YXDeviceDustCollectionFrequency.INTERVAL_30, 30), + (YXDeviceDustCollectionFrequency.INTERVAL_60, 60), + (YXDeviceDustCollectionFrequency.INTERVAL_15, 15), + ], +) +async def test_set_dust_frequency_writes_interval_code( + fake_channel: FakeChannel, + 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}} diff --git a/tests/devices/traits/b01/q10/test_status.py b/tests/devices/traits/b01/q10/test_status.py index 8e2588ad..98732ce6 100644 --- a/tests/devices/traits/b01/q10/test_status.py +++ b/tests/devices/traits/b01/q10/test_status.py @@ -11,11 +11,17 @@ from roborock.data.b01_q10.b01_q10_code_mappings import ( B01_Q10_DP, + YXAreaUnit, + YXCarpetCleanType, + YXCleanLine, YXCleanType, YXDeviceCleanTask, + YXDeviceDustCollectionFrequency, YXDeviceState, YXFanLevel, + YXWaterLevel, ) +from roborock.data.b01_q10.b01_q10_containers import dpNetInfo, dpNotDisturbExpand, dpTimeZone from roborock.devices.traits.b01.q10 import Q10PropertiesApi, create from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol @@ -114,7 +120,7 @@ async def test_status_trait_refresh( assert q10_api.status.status is None assert q10_api.status.fan_level is None assert q10_api.status.total_clean_count is None - assert q10_api.status.main_brush_life is None + assert q10_api.consumable.main_brush_life is None assert q10_api.status.cleaning_progress is None assert q10_api.status.fault is None @@ -148,13 +154,64 @@ async def test_status_trait_refresh( assert q10_api.status.total_clean_area == 0 assert q10_api.status.total_clean_count == 0 assert q10_api.status.total_clean_time == 0 - assert q10_api.status.main_brush_life == 0 - assert q10_api.status.side_brush_life == 0 - assert q10_api.status.filter_life == 0 - assert q10_api.status.sensor_life == 0 + assert q10_api.consumable.main_brush_life == 0 + assert q10_api.consumable.side_brush_life == 0 + 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.clean_mode == YXCleanType.VAC_AND_MOP + assert q10_api.status.water_level == YXWaterLevel.LOW + + # Settings with dedicated traits are read from those traits. + assert q10_api.volume.volume == 74 + assert q10_api.do_not_disturb.not_disturb is True + assert q10_api.do_not_disturb.is_on is True + assert q10_api.child_lock.child_lock is False + assert q10_api.child_lock.is_on is False + assert q10_api.dust_collection.dust_switch is True + assert q10_api.dust_collection.is_on is True + assert q10_api.dust_collection.dust_setting == YXDeviceDustCollectionFrequency.REGULAR + + # Additional state captured on the Status trait. + assert q10_api.status.mop_state is True + assert q10_api.status.ground_clean is False + assert q10_api.status.carpet_clean_type == YXCarpetCleanType.RISE + assert q10_api.status.area_unit == YXAreaUnit.SQUARE_METER + assert q10_api.status.auto_boost is False + assert q10_api.status.map_save_switch is True + assert q10_api.status.recent_clean_record is False + assert q10_api.status.valley_point_charging is False + assert q10_api.status.clean_line == YXCleanLine.FAST + assert q10_api.status.line_laser_obstacle_avoidance is True + assert q10_api.status.robot_country_code == "us" + assert q10_api.status.robot_type == 1 + assert q10_api.status.time_zone == dpTimeZone(time_zone_city="America/Los_Angeles", time_zone_sec=-28800) + + # Nested containers are parsed into their dataclasses on their own traits. + assert q10_api.do_not_disturb.not_disturb_expand == dpNotDisturbExpand( + disturb_dust_enable=1, disturb_light=1, disturb_resume_clean=1, disturb_voice=1 + ) + assert q10_api.network_info.net_info == dpNetInfo( + wifi_name="wifi-network-name", ip_adress="1.1.1.2", mac="99:AA:88:BB:77:CC", signal=-50 + ) + + +async def test_status_trait_vacuum_only_refresh( + q10_api: Q10PropertiesApi, + message_queue: asyncio.Queue[RoborockMessage], +) -> 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)) + + await wait_for_attribute_value(q10_api.status, "battery", 75) + + assert q10_api.status.fan_level == YXFanLevel.MAX_PLUS + assert q10_api.status.water_level == YXWaterLevel.MEDIUM + assert q10_api.status.clean_mode == YXCleanType.VACUUM + assert q10_api.status.recent_clean_record is True + assert q10_api.status.total_clean_count == 7 def test_status_trait_update_listener(q10_api: Q10PropertiesApi) -> None: diff --git a/tests/devices/traits/b01/q10/test_vacuum.py b/tests/devices/traits/b01/q10/test_vacuum.py index 499b9bbc..e736d28c 100644 --- a/tests/devices/traits/b01/q10/test_vacuum.py +++ b/tests/devices/traits/b01/q10/test_vacuum.py @@ -28,11 +28,13 @@ def vacuumm_fixture(q10_api: Q10PropertiesApi) -> VacuumTrait: @pytest.mark.parametrize( ("command_fn", "expected_payload"), [ - (lambda x: x.start_clean(), {"201": {"cmd": 1}}), - (lambda x: x.pause_clean(), {"204": {}}), - (lambda x: x.resume_clean(), {"205": {}}), - (lambda x: x.stop_clean(), {"206": {}}), - (lambda x: x.return_to_dock(), {"203": {}}), + # Payloads verified live against ss07 hardware. + (lambda x: x.start_clean(), {"201": 1}), + (lambda x: x.spot_clean(), {"201": 5}), + (lambda x: x.pause_clean(), {"204": 0}), + (lambda x: x.resume_clean(), {"205": 0}), + (lambda x: x.stop_clean(), {"206": 0}), + (lambda x: x.return_to_dock(), {"202": 5}), (lambda x: x.empty_dustbin(), {"203": 2}), (lambda x: x.set_clean_mode(YXCleanType.VAC_AND_MOP), {"137": 1}), (lambda x: x.set_fan_level(YXFanLevel.BALANCED), {"123": 2}), diff --git a/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr b/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr index 726d9203..a1c15e8c 100644 --- a/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr +++ b/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr @@ -6,6 +6,69 @@ } ''' # --- +# name: test_decode_rpc_payload[dpRequestDps_vacuum_only] + ''' + { + "dpStatus": 8, + "dpBattery": 75, + "dpFanLevel": 8, + "dpWaterLevel": 2, + "dpMainBrushLife": 2, + "dpSideBrushLife": 2, + "dpFilterLife": 2, + "dpCleanCount": 1, + "dpCleanMode": 2, + "dpCleanTaskType": 0, + "dpBackType": 5, + "dpBreakpointClean": 0, + "dpValleyPointCharging": false, + "dpRobotCountryCode": "us", + "dpUserPlan": 0, + "dpNotDisturb": 1, + "dpVolume": 70, + "dpTotalCleanArea": 72, + "dpTotalCleanCount": 7, + "dpTotalCleanTime": 111, + "dpDustSwitch": 1, + "dpMopState": 1, + "dpAutoBoost": 1, + "dpChildLock": 0, + "dpDustSetting": 0, + "dpMapSaveSwitch": true, + "dpRecentCleanRecord": true, + "dpCleanTime": 0, + "dpMultiMapSwitch": 1, + "dpSensorLife": 2, + "dpCleanArea": 0, + "dpCarpetCleanType": 0, + "dpCleanLine": 0, + "dpTimeZone": { + "timeZoneCity": "America/Los_Angeles", + "timeZoneSec": -28800 + }, + "dpAreaUnit": 0, + "dpNetInfo": { + "ipAdress": "1.1.1.2", + "mac": "99:AA:88:BB:77:CC", + "signal": -50, + "wifiName": "wifi-network-name" + }, + "dpRobotType": 1, + "dpLineLaserObstacleAvoidance": 1, + "dpCleanProgress": 100, + "dpGroundClean": 0, + "dpFault": 0, + "dpNotDisturbExpand": { + "disturb_dust_enable": 1, + "disturb_light": 1, + "disturb_resume_clean": 1, + "disturb_voice": 1 + }, + "dpTimerType": 0, + "dpAddCleanState": 0 + } + ''' +# --- # name: test_decode_rpc_payload[dpRequetdps] ''' { diff --git a/tests/protocols/test_b01_q10_protocol.py b/tests/protocols/test_b01_q10_protocol.py index 62ee2c27..b43e348e 100644 --- a/tests/protocols/test_b01_q10_protocol.py +++ b/tests/protocols/test_b01_q10_protocol.py @@ -1,6 +1,7 @@ """Tests for the B01 protocol message encoding and decoding.""" import json +import logging import pathlib from collections.abc import Generator from typing import Any @@ -10,6 +11,7 @@ from syrupy import SnapshotAssertion from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXWaterLevel +from roborock.data.code_mappings import completed_warnings from roborock.exceptions import RoborockException from roborock.protocols.b01_q10_protocol import ( decode_rpc_response, @@ -74,21 +76,31 @@ def test_decode_invalid_rpc_payload(payload: bytes, expected_error_message: str) decode_rpc_response(message) -def test_decode_unknown_dps_code() -> None: - """Test decoding a B01 RPC response protocol message.""" +def test_decode_unknown_dps_code(caplog: pytest.LogCaptureFixture) -> None: + """Unknown data points are dropped silently, without logging warnings. + + ss07 hardware pushes DPs 112 and 113 (and occasionally others) that this + library does not model. They must be ignored without emitting "not a valid + code" warnings, which previously spammed the log on every status push. + """ + completed_warnings.discard("112 is not a valid code for B01_Q10_DP") + completed_warnings.discard("113 is not a valid code for B01_Q10_DP") + completed_warnings.discard("909090 is not a valid code for B01_Q10_DP") message = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, - payload=b'{"dps": {"909090": 123, "122":100}}', + payload=b'{"dps": {"909090": 123, "112": 0, "113": 0, "122": 100}}', seq=12750, version=b"B01", random=97431, timestamp=1652547161, ) - decoded_message = decode_rpc_response(message) + with caplog.at_level(logging.WARNING): + decoded_message = decode_rpc_response(message) assert decoded_message == { B01_Q10_DP.BATTERY: 100, } + assert "not a valid code" not in caplog.text @pytest.mark.parametrize( diff --git a/tests/protocols/testdata/b01_q10_protocol/dpRequestDps_vacuum_only.json b/tests/protocols/testdata/b01_q10_protocol/dpRequestDps_vacuum_only.json new file mode 100644 index 00000000..8d45eebc --- /dev/null +++ b/tests/protocols/testdata/b01_q10_protocol/dpRequestDps_vacuum_only.json @@ -0,0 +1 @@ +{"dps":{"101":{"104":0,"105":false,"109":"us","207":0,"25":1,"26":70,"29":72,"30":7,"31":111,"37":1,"40":1,"45":1,"47":0,"50":0,"51":true,"53":true,"6":0,"60":1,"67":2,"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":0,"96":0},"121":8,"122":75,"123":8,"124":2,"125":2,"126":2,"127":2,"136":1,"137":2,"138":0,"139":5},"t":1766802312}