Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
93b921e
feat: expand Q10 (B01/ss07) status support and add device info
tubededentifrice Jun 14, 2026
bc2413b
feat: add Q10 (B01/ss07) settings writers
tubededentifrice Jun 14, 2026
ce11580
feat: add Q10 dust-collection frequency writer
tubededentifrice Jun 14, 2026
a7612cc
refactor: split Q10 status/settings into per-concern traits
tubededentifrice Jun 15, 2026
2ece942
feat: decode Q10 carpet/area/mop/floor-direction status into enums+bools
tubededentifrice Jun 15, 2026
86bd888
feat: decode Q10 add_clean_state as a bool
tubededentifrice Jun 15, 2026
c3f8cab
fix: stop unmapped Q10 data points from logging "not a valid code" wa…
tubededentifrice Jun 15, 2026
d129659
fix: avoid Q10 Consumable/NetworkInfo shadowing v1 in roborock.data
tubededentifrice Jun 15, 2026
5ce284f
fix: keep Q10 CLIFF_RESTRICTED_AREA_UP (103); ss07 pushes it
tubededentifrice Jun 15, 2026
34f94ca
fix: correct Q10 vacuum command payloads, verified against ss07 hardware
tubededentifrice Jun 15, 2026
e029022
fix: show all Q10 read-model traits in status, wait for fresh push
tubededentifrice Jun 15, 2026
472cbe7
Merge branch 'main' into support-q10-devices
tubededentifrice Jun 21, 2026
39661db
refactor: keep Q10 consumable fields as deprecated aliases; add ip_ad…
tubededentifrice Jun 21, 2026
499b45d
Merge branch 'main' into support-q10-devices
tubededentifrice Jun 21, 2026
5beeec6
Merge remote-tracking branch 'origin/main' into support-q10-devices
tubededentifrice Jun 22, 2026
90860e5
Merge branch 'main' into support-q10-devices
tubededentifrice Jun 23, 2026
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"]}'
193 changes: 188 additions & 5 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@

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
Expand Down Expand Up @@ -446,13 +451,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 @@ -462,7 +521,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 @@ -1399,6 +1465,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
Loading