Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions device_info.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}'
194 changes: 189 additions & 5 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading