Repository navigation
feat: expose Q10 map archives #937
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b8bdf3a
95d268e
eaac141
1a4ffde
cbd6abc
3f8ec38
4eb0b9b
49dfd31
565c7d0
ac6d83b
d4dbd86
3d3a337
9d5d9fe
569e6b3
9b5e77d
d6b7c59
f1f5e24
5499780
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,10 +10,12 @@ | |
| a ``dpCleanRecord`` envelope into a :class:`CleanRecordPush`, and the trait applies it. | ||
| """ | ||
|
|
||
| import asyncio | ||
| import logging | ||
| from dataclasses import dataclass, field | ||
| from typing import Any | ||
|
|
||
| from roborock.data import RoborockBase | ||
| from roborock.data.b01_q10.b01_q10_code_mappings import ( | ||
| B01_Q10_DP, | ||
| YXCleaningResult, | ||
|
|
@@ -22,11 +24,22 @@ | |
| 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 | ||
|
|
||
| __all__ = [ | ||
| "CleanHistory", | ||
| "CleanHistoryTrait", | ||
| "CleanRecordConverter", | ||
| "CleanRecordPush", | ||
|
|
@@ -107,40 +120,95 @@ def parse_record(raw: Any | None) -> Q10CleanRecord | None: | |
| return None | ||
|
|
||
|
|
||
| class CleanHistoryTrait(UpdatableTrait): | ||
| """Access to the Q10 clean-record history (``dpCleanRecord``, DP 52). | ||
|
|
||
| A read-model trait updated from the DPS stream like the others, but it overrides | ||
| :meth:`update_from_dps` because the payload is a structured push (a record list, | ||
| or a single ``op:"notify"`` record) rather than a flat data-point-to-field map. | ||
| """ | ||
| @dataclass | ||
| class CleanHistory(RoborockBase): | ||
| """Received clean-history data, independent of request and transport state.""" | ||
|
|
||
| def __init__(self, command: CommandTrait) -> None: | ||
| """Initialize the clean history trait.""" | ||
| UpdatableTrait.__init__(self, command, _LOGGER) | ||
| self._converter = CleanRecordConverter() | ||
| self.records: list[Q10CleanRecord] = [] | ||
| """Decoded clean records, most recent first.""" | ||
| records: list[Q10CleanRecord] = field(default_factory=list) | ||
| """Decoded clean records, most recent first.""" | ||
| detail: Q10CleanRecordDetail | None = None | ||
| """Latest received archive. Its wire payload contains no record identifier.""" | ||
| detail_image_content: bytes | None = None | ||
| """Rendered archive PNG, available separately from diagnostic serialization.""" | ||
|
|
||
| @property | ||
| def last_record(self) -> Q10CleanRecord | None: | ||
| """The most recent clean record, or ``None`` if there are none.""" | ||
| return self.records[0] if self.records else None | ||
|
|
||
| @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 latest received clean-record detail.""" | ||
| return self.detail.trace if self.detail else None | ||
|
|
||
| @property | ||
| def detail_path(self) -> list[Q10Point]: | ||
| """Historical path points from the latest received archive.""" | ||
| return list(self.detail_trace.points) if self.detail_trace else [] | ||
|
|
||
| def as_dict(self, exclude: set[str] | None = None) -> dict[str, Any]: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than hand-rolling custom |
||
| """Serialize public history data without binary map grids or PNG bytes.""" | ||
| excluded = exclude or set() | ||
| data: dict[str, Any] = {} | ||
| if "records" not in excluded: | ||
| data["records"] = [record.as_dict() for record in self.records] | ||
| if "detail_path" not in excluded: | ||
| data["detailPath"] = [point.as_dict() for point in self.detail_path] | ||
| return data | ||
|
|
||
|
|
||
| class CleanHistoryTrait(CleanHistory, UpdatableTrait): | ||
| """Request history updates and notify listeners when device pushes arrive. | ||
|
|
||
| ``refresh_detail`` returns after publishing; it does not wait for an archive. | ||
| Consumers subscribe with ``add_update_listener`` and read ``detail`` there. | ||
| Clean-record archives cannot be correlated with selections: delayed pushes | ||
| and selections by other clients are indistinguishable on the wire. | ||
| """ | ||
|
|
||
| _command: CommandTrait | ||
|
|
||
| def __init__(self, command: CommandTrait, *, map_parser_config: B01Q10MapParserConfig) -> None: | ||
| CleanHistory.__init__(self) | ||
| UpdatableTrait.__init__(self, command, _LOGGER) | ||
| self._command = command | ||
| self._converter = CleanRecordConverter() | ||
| self._map_parser_config = map_parser_config | ||
| self._detail_lock = asyncio.Lock() | ||
|
|
||
| async def refresh(self) -> None: | ||
| """Request the clean-record list from the device. | ||
|
|
||
| This sends the query and returns immediately; the records arrive | ||
| 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: | ||
| """Publish a selection using the complete firmware record identifier. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is basically saying: Please refresh the latest data for this clean record? but also, it displays it on the map? The comment here is discussing fairly low level implementation detail, but not really the higher level thing we're trying to accomplish here by using this API. |
||
|
|
||
| Returns after sending. The resulting archive is received independently | ||
| through the update listener API, with no guaranteed record attribution. | ||
| The lock serializes publication only; missing pushes do not block retries. | ||
| """ | ||
| if not record.raw or not record.map_len: | ||
| raise RoborockException("The Q10 clean record has no saved map detail") | ||
| record_id = record.raw | ||
| async with self._detail_lock: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that |
||
| await self._command.send( | ||
| B01_Q10_DP.COMMON, | ||
| {str(B01_Q10_DP.CLEAN_RECORD.code): {"op": "select", "id": record_id}}, | ||
| ) | ||
|
|
||
| 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) | ||
|
|
@@ -151,6 +219,23 @@ 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 = 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: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
roborock/devices/README.mdis a brief high-level overview of the device architecture (DeviceManager,RoborockDevice,Traits,Channels,Cache). Since Q10 archive traits now work the same way as all other Q10 push-driven traits, let's remove this Q10-specific section from the top-level README and keep any protocol-specific notes (like03 01lacking a record correlation ID) concise in theCleanHistoryTrait/MapsTraitdocstrings.