Skip to content
Open
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Evaluate every change against the repository's three core layers in strict prior
## Detailed Engineering Conventions

### 1. Typing & Data Models
- **Subclass `RoborockBase`**: Define structured domain and wire data models as `@dataclass` subclassing `RoborockBase` (`from_dict`, `as_dict`). Avoid `TypedDict` or loose dicts. (Binary protocol packets, transport message envelopes, and map layers are exempt).
- **Subclass `RoborockBase`**: Define structured domain and wire data models as `@dataclass` subclassing `RoborockBase` (`from_dict`, `as_dict`). Avoid `TypedDict` or loose dicts. (Binary protocol packets, transport message envelopes, and map layers are exempt). The existing frozen `Q10RoborockPoint` coordinate value is also exempt: it must retain immutability and hashability, and Python disallows frozen dataclass inheritance from the non-frozen `RoborockBase`. This exception does not extend to other domain models.
- **Enum Fallback Resilience**: All enums representing device status, firmware modes, error codes, and wire protocol integer codes MUST inherit from `RoborockEnum` (defining a lowercase `unknown = -1` or `0` member) or `RoborockModeEnum` (using `from_code_optional()`). Internal enums not decoding unknown firmware codes remain standard `Enum`/`StrEnum`.
- **Strongly Type What You Know; Contain `Any` to the Wire**: Public trait APIs, method signatures, properties, and domain models MUST declare concrete types. `Any` is accepted only where the underlying wire protocol is dynamic or polymorphic (Tuya DPS maps, low-level RPC dispatch, serialization helpers, evolving cloud schemas).
- **Avoid Forward References & `TYPE_CHECKING`**: Avoid stringified forward references (`"ClassName"`) and `if typing.TYPE_CHECKING:` guards wherever possible. They typically indicate circular dependencies or coupling that should be refactored by extracting shared models.
Expand Down
13 changes: 9 additions & 4 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ async def maps(ctx, device_id: str):
async def _await_q10_map_push(
properties: Q10PropertiesApi,
predicate: Callable[[], bool],
revision: Callable[[], int],
*,
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
allow_cached_on_timeout: bool = False,
Expand All @@ -617,9 +618,10 @@ async def _await_q10_map_push(
"""
loop = asyncio.get_running_loop()
updated: asyncio.Future[None] = loop.create_future()
initial_revision = revision()

def on_update() -> None:
if predicate() and not updated.done():
if revision() > initial_revision and predicate() and not updated.done():
updated.set_result(None)

unsub = properties.map.add_update_listener(on_update)
Expand Down Expand Up @@ -649,6 +651,7 @@ async def map_image(ctx, device_id: str, output_file: str):
await _await_q10_map_push(
properties,
lambda: properties.map.image_content is not None,
lambda: properties.map.map_revision,
allow_cached_on_timeout=True,
)
image_content = properties.map.image_content
Expand Down Expand Up @@ -697,8 +700,8 @@ async def map_data(ctx, device_id: str, include_path: bool):
async def q10_position(ctx, device_id: str, include_path: bool):
"""Get the current Q10 robot position and live cleaning path.

The Q10 only streams its position/path while it is actively cleaning, so this
will report that no live trace is available for an idle/docked robot.
The Q10 normally streams position/path while it is actively cleaning, so an
idle device may report that no fresh live trace is available.
"""
context: RoborockContext = ctx.obj
device_manager = await context.get_device_manager()
Expand All @@ -710,9 +713,10 @@ async def q10_position(ctx, device_id: str, include_path: bool):
got_trace = await _await_q10_map_push(
properties,
lambda: bool(properties.map.path),
lambda: properties.map.trace_revision,
)
if not got_trace:
click.echo("No live trace available (the robot only reports position while cleaning).")
click.echo("No fresh live trace available.")
return
map_trait = properties.map
position = map_trait.robot_position
Expand Down Expand Up @@ -875,6 +879,7 @@ async def rooms(ctx, device_id: str):
await _await_q10_map_push(
properties,
lambda: properties.map.image_content is not None,
lambda: properties.map.map_revision,
allow_cached_on_timeout=True,
)
click.echo(dump_json({room.id: room.name for room in properties.map.rooms}))
Expand Down
4 changes: 3 additions & 1 deletion roborock/data/b01_q10/b01_q10_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,9 @@ class Q10MapInfo(RoborockBase):
"""A saved map reported by ``dpMultiMap``.

Q10 firmware represents the map identifier as a string on the wire. The
value is sent back unchanged in a subsequent ``{"op": "get"}`` request.
value is sent back unchanged in a subsequent ``{"op": "select"}`` detail
request. On Q10 firmware, ``select`` previews a saved map without applying
it as the active map.
"""

id: str
Expand Down
8 changes: 7 additions & 1 deletion roborock/devices/device_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from roborock.devices.device import DeviceReadyCallback, RoborockDevice
from roborock.diagnostics import Diagnostics, redact_device_data
from roborock.exceptions import RoborockException
from roborock.map.b01_q10_map_parser import B01Q10MapParserConfig
from roborock.map.map_parser import MapParserConfig
from roborock.mqtt.roborock_session import create_lazy_mqtt_session
from roborock.mqtt.session import MqttSession, SessionUnauthorizedHook
Expand Down Expand Up @@ -262,7 +263,12 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
if "ss" in model_part:
b01_q10_channel = create_b01_q10_channel(mqtt_channel)
channel = b01_q10_channel
trait = b01.q10.create(channel)
trait = b01.q10.create(
channel,
map_parser_config=(
B01Q10MapParserConfig(map_scale=map_parser_config.map_scale) if map_parser_config else None
),
)
elif "sc" in model_part:
# Q7 devices start with 'sc' in their model naming.
b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel)
Expand Down
46 changes: 38 additions & 8 deletions roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
from roborock.data.containers import RoborockBase
from roborock.devices.rpc.b01_q10_channel import B01Q10Channel
from roborock.devices.traits import Trait
from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
from roborock.map.b01_q10_map_parser import (
B01Q10MapParserConfig,
Q10CleanRecordDetail,
Q10MapPacket,
Q10MapPacketKind,
Q10TracePacket,
)
from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message

from .button_light import ButtonLightTrait
Expand Down Expand Up @@ -92,7 +98,12 @@ class Q10PropertiesApi(Trait):
clean_history: CleanHistoryTrait
"""Trait for fetching the device clean-record history (``dpCleanRecord``)."""

def __init__(self, channel: B01Q10Channel) -> None:
def __init__(
self,
channel: B01Q10Channel,
*,
map_parser_config: B01Q10MapParserConfig,
) -> None:
"""Initialize the B01Props API."""
self._channel = channel
self.command = CommandTrait(channel)
Expand All @@ -106,10 +117,17 @@ def __init__(self, channel: B01Q10Channel) -> None:
self.network_info = NetworkInfoTrait()
self.consumable = ConsumableTrait()
self._map_dps = MapDpsTrait()
self.maps = MapsTrait(self.command)
self.map = MapContentTrait(self._map_dps, self.maps, self.command)
self.maps = MapsTrait(self.command, map_parser_config=map_parser_config)
self.map = MapContentTrait(
self._map_dps,
self.command,
map_parser_config=map_parser_config,
)
self.clean_history = CleanHistoryTrait(
self.command,
map_parser_config=map_parser_config,
)
self.vacuum = VacuumTrait(self.command, self.status, self.map)
self.clean_history = CleanHistoryTrait(self.command)
# Read-model traits updated from the device's DPS push stream.
self._updatable_traits = [
self.status,
Expand Down Expand Up @@ -158,7 +176,12 @@ def _handle_message(self, message: Q10Message) -> None:
Map-list DPS responses and other DPS updates feed the read-model traits.
"""
if isinstance(message, Q10MapPacket):
self.map.update_from_map_packet(message)
if message.kind is Q10MapPacketKind.CURRENT:
self.map.update_from_map_packet(message)
elif message.kind is Q10MapPacketKind.SAVED_MAP_DETAIL:
self.maps.update_from_map_packet(message)
elif isinstance(message, Q10CleanRecordDetail):
self.clean_history.update_from_detail(message)
elif isinstance(message, Q10TracePacket):
self.map.update_from_trace_packet(message)
elif isinstance(message, Q10DpsUpdate):
Expand All @@ -179,6 +202,13 @@ def as_dict(self) -> dict[str, Any]:
return result


def create(channel: B01Q10Channel) -> Q10PropertiesApi:
def create(
channel: B01Q10Channel,
*,
map_parser_config: B01Q10MapParserConfig | None = None,
) -> Q10PropertiesApi:
"""Create traits for B01 devices."""
return Q10PropertiesApi(channel)
return Q10PropertiesApi(
channel,
map_parser_config=map_parser_config or B01Q10MapParserConfig(),
)
90 changes: 87 additions & 3 deletions roborock/devices/traits/b01/q10/clean_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@
YXStartMethod,
)
from roborock.data.b01_q10.b01_q10_containers import Q10CleanRecord
from roborock.exceptions import RoborockException
from roborock.map.b01_q10_map_parser import (
B01Q10MapParserConfig,
Q10CleanRecordDetail,
Q10HistoricalTracePacket,
Q10MapPacket,
Q10MapPacketKind,
Q10Point,
)
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map

from .command import CommandTrait
from .common import UpdatableTrait
Expand Down Expand Up @@ -115,12 +125,28 @@ class CleanHistoryTrait(UpdatableTrait):
or a single ``op:"notify"`` record) rather than a flat data-point-to-field map.
"""

def __init__(self, command: CommandTrait) -> None:
_command: CommandTrait

def __init__(
self,
command: CommandTrait,
*,
map_parser_config: B01Q10MapParserConfig | None = None,
) -> None:
"""Initialize the clean history trait."""
UpdatableTrait.__init__(self, command, _LOGGER)
self._command = command
self._converter = CleanRecordConverter()
self._map_parser_config = map_parser_config or B01Q10MapParserConfig()
self.records: list[Q10CleanRecord] = []
"""Decoded clean records, most recent first."""
self.detail: Q10CleanRecordDetail | None = None
"""Most recently pushed ``03 01`` clean-record map detail."""
self.detail_record: Q10CleanRecord | None = None
"""Record associated with :attr:`detail_packet`, when requested here."""
self.detail_image_content: bytes | None = None
"""Rendered clean-record detail image, if decoding succeeded."""
self._pending_detail_record: Q10CleanRecord | None = None

@property
def last_record(self) -> Q10CleanRecord | None:
Expand All @@ -134,13 +160,52 @@ async def refresh(self) -> None:
asynchronously on the device stream and populate :attr:`records` once
:meth:`update_from_dps` processes the ``dpCleanRecord`` push.
"""
if self._command is None:
raise ValueError("Trait is read-only; no command channel was provided")
await self._command.send(
B01_Q10_DP.COMMON,
params={str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "list"}},
)

async def refresh_detail(self, record: Q10CleanRecord) -> None:
"""Request the saved map and path for one clean record.

The complete 12-field raw record is the firmware's detail identifier;
the shorter human-facing record ID is not accepted. Only one request
may be outstanding because ``03 01`` responses carry no correlation ID.
"""
if not record.raw or not record.map_len:
raise RoborockException("The Q10 clean record has no saved map detail")
if self._pending_detail_record is not None:
raise RoborockException("A Q10 clean-record detail request is already pending")
self._pending_detail_record = record
try:
await self._command.send(
B01_Q10_DP.COMMON,
params={
str(B01_Q10_DP.CLEAN_RECORD.code): {
"op": "select",
"id": record.raw,
}
},
)
except RoborockException:
self._pending_detail_record = None
raise

@property
def detail_packet(self) -> Q10MapPacket | None:
"""The map from the most recently received clean-record detail."""
return self.detail.map if self.detail else None

@property
def detail_trace(self) -> Q10HistoricalTracePacket | None:
"""Historical path embedded in the selected clean-record detail."""
return self.detail.trace if self.detail else None

@property
def detail_path(self) -> list[Q10Point]:
"""Historical path points for the selected clean record."""
return self.detail_trace.points if self.detail_trace else []

def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
"""Apply a ``dpCleanRecord`` push (a full list reply or a single notify)."""
envelope = decoded_dps.get(B01_Q10_DP.CLEAN_RECORD)
Expand All @@ -151,6 +216,25 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
return
self._apply(push)

def update_from_detail(self, detail: Q10CleanRecordDetail) -> None:
"""Store and render a pushed clean-record detail map."""
if detail.map.kind is not Q10MapPacketKind.CLEAN_RECORD_DETAIL:
raise ValueError(f"Expected a Q10 clean-record detail packet, got {detail.map.kind.value}")
self.detail_record = self._pending_detail_record
self._pending_detail_record = None
self.detail = detail
try:
self.detail_image_content = render_q10_map(
detail.map,
detail.trace,
Q10MapOverlays(),
config=self._map_parser_config,
)
except RoborockException:
_LOGGER.debug("Failed to render Q10 clean-record detail", exc_info=True)
self.detail_image_content = None
self._notify_update()

def _apply(self, push: CleanRecordPush) -> None:
"""Merge or replace the records from ``push``, then sort newest-first and notify."""
if push.replace:
Expand Down
Loading
Loading