Skip to content

Commit 5499780

Browse files
committed
docs: clarify Q10 archive callbacks and simplify tests
1 parent f1f5e24 commit 5499780

2 files changed

Lines changed: 45 additions & 94 deletions

File tree

‎roborock/devices/README.md‎

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -43,47 +43,38 @@ This speeds up startup time and reduces load on the Roborock cloud APIs.
4343

4444
## Q10 archive updates
4545

46-
Q10 traits are push driven. `refresh_detail()` publishes a selection and returns
47-
when the command has been sent. It does not wait for the map response. Register
48-
an update listener before requesting data, and read the trait when it notifies
49-
you. A history listener can also fire for record-list updates; compare the
50-
received detail with the previous detail to distinguish archive updates.
46+
Archive updates use the same push-driven pattern as all other Q10 traits.
47+
Register an update listener and update your view's state from the trait when the
48+
callback runs. `refresh_detail()` publishes a selection; the device sends the
49+
archive independently through its push stream. A history listener can also fire
50+
for record-list updates, so read whichever properties your view displays on each
51+
callback.
5152

5253
```python
53-
import asyncio
54-
5554
from roborock.devices.traits.b01.q10 import Q10PropertiesApi
5655

5756

58-
async def request_archive_preview(properties: Q10PropertiesApi) -> bytes | None:
59-
history = properties.clean_history
60-
previous_detail = history.detail
61-
received = asyncio.Event()
62-
63-
def archive_updated() -> None:
64-
if history.detail is not previous_detail:
65-
received.set()
66-
67-
record = history.last_record # Populated by an earlier history list push.
68-
if record is None or not record.map_len:
69-
return None
70-
unsubscribe = history.add_update_listener(archive_updated)
71-
try:
72-
# This caller chooses to wait; the trait itself only publishes.
73-
async with asyncio.timeout(30):
74-
await history.refresh_detail(record)
75-
await received.wait()
76-
return history.detail_image_content
77-
finally:
78-
unsubscribe()
57+
class ArchivePreview:
58+
def __init__(self, properties: Q10PropertiesApi) -> None:
59+
self.history = properties.clean_history
60+
self.image_content = self.history.detail_image_content
61+
self._unsubscribe = self.history.add_update_listener(self._archive_updated)
62+
63+
def _archive_updated(self) -> None:
64+
self.image_content = self.history.detail_image_content
65+
# Notify your UI to redraw using the latest received image.
66+
67+
async def select_latest_record(self) -> None:
68+
record = self.history.last_record
69+
if record is not None and record.map_len:
70+
await self.history.refresh_detail(record)
71+
72+
def close(self) -> None:
73+
self._unsubscribe()
7974
```
8075

81-
For a persistent archive view, keep its listener until the view closes. The
82-
example is a one-shot caller that waits for its own event with a deadline; that
83-
waiting policy belongs to the caller. A missing response does not block later
84-
requests. Publication failures and cancellation propagate to the caller, and
85-
the publication lock is released.
86-
The traits do not create response-waiting tasks or futures.
76+
Keep the listener registered while the view is open and unsubscribe when it
77+
closes. The callback receives updates regardless of which client requested them.
8778

8879
Clean-record detail packets contain no record identifier. `detail` represents
8980
the latest received archive, including delayed or unsolicited pushes; it cannot
@@ -104,5 +95,4 @@ map or trace in `properties.map`.
10495
`properties.as_dict()` includes clean-history records and path data and
10596
saved-map metadata. Binary map grids and PNG bytes are excluded; access images
10697
through `detail_image_content`. Live map and trace updates use the usual trait
107-
listener API without revision counters. The CLI subscribes before requesting a
108-
push, then waits for an update satisfying its predicate, with a 30-second limit.
98+
listener API.

‎tests/devices/traits/b01/q10/test_clean_history.py‎

Lines changed: 19 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -188,24 +188,14 @@ async def test_refresh_sends_op_list(q10_api: Q10PropertiesApi, fake_channel: Fa
188188
)
189189

190190

191-
async def test_refresh_detail_publishes_without_waiting_for_push(
191+
async def test_refresh_detail_sends_op_select(
192192
clean_history: CleanHistoryTrait,
193193
fake_channel: FakeB01Q10Channel,
194194
) -> None:
195195
record = CleanRecordConverter.parse_record(RECORD_A)
196196
assert record is not None
197-
updates = []
198-
clean_history.add_update_listener(lambda: updates.append(clean_history.detail))
199197
await clean_history.refresh_detail(record)
200198
assert fake_channel.published_commands == [(B01_Q10_DP.COMMON, {"52": {"op": "select", "id": RECORD_A}})]
201-
assert clean_history.detail is None
202-
assert updates == []
203-
detail = _detail()
204-
clean_history.update_from_detail(detail)
205-
assert clean_history.detail is detail
206-
assert clean_history.detail_packet is detail.map
207-
assert clean_history.detail_image_content is not None
208-
assert updates == [detail]
209199

210200

211201
def _detail() -> Q10CleanRecordDetail:
@@ -220,68 +210,39 @@ async def test_refresh_detail_rejects_record_without_map(clean_history: CleanHis
220210
await clean_history.refresh_detail(record)
221211

222212

223-
async def test_missing_push_does_not_block_further_selections(
224-
clean_history: CleanHistoryTrait,
225-
fake_channel: FakeB01Q10Channel,
226-
) -> None:
227-
first = CleanRecordConverter.parse_record(RECORD_A)
228-
second = CleanRecordConverter.parse_record(RECORD_B)
229-
assert first is not None and second is not None
230-
await clean_history.refresh_detail(first)
231-
await clean_history.refresh_detail(second)
232-
assert [params["52"]["id"] for _, params in fake_channel.published_commands] == [RECORD_A, RECORD_B]
233-
detail = _detail()
234-
clean_history.update_from_detail(detail)
235-
assert clean_history.detail is detail
236-
# The received payload has no record identifier; no association is invented.
237-
assert "detailRecord" not in clean_history.as_dict()
238-
239-
240-
@pytest.mark.parametrize("failure", ["cancel", "send_error"])
241-
async def test_failed_publication_releases_lock(
213+
async def test_refresh_detail_after_cancelled_publication(
242214
clean_history: CleanHistoryTrait,
243215
fake_channel: FakeB01Q10Channel,
244-
failure: str,
245216
) -> None:
246217
record = CleanRecordConverter.parse_record(RECORD_A)
247218
assert record is not None
248-
if failure == "cancel":
249-
fake_channel.send_gate = asyncio.Event()
250-
else:
251-
fake_channel.send_error = RoborockException("publish failed")
219+
fake_channel.send_gate = asyncio.Event()
252220
request = asyncio.create_task(clean_history.refresh_detail(record))
253221
await fake_channel.send_started.wait()
254-
if failure == "cancel":
255-
request.cancel()
256-
with pytest.raises(asyncio.CancelledError if failure == "cancel" else RoborockException):
222+
request.cancel()
223+
with pytest.raises(asyncio.CancelledError):
257224
await request
225+
assert fake_channel.published_commands == []
226+
258227
fake_channel.send_gate = None
259-
fake_channel.send_error = None
260228
await clean_history.refresh_detail(record)
261-
assert len(fake_channel.published_commands) == 1
262-
clean_history.update_from_detail(_detail())
263-
assert clean_history.detail_image_content is not None
229+
assert fake_channel.published_commands == [(B01_Q10_DP.COMMON, {"52": {"op": "select", "id": RECORD_A}})]
264230

265231

266-
async def test_concurrent_selections_serialize_publication(
232+
async def test_refresh_detail_after_publication_error(
267233
clean_history: CleanHistoryTrait,
268234
fake_channel: FakeB01Q10Channel,
269235
) -> None:
270-
first = CleanRecordConverter.parse_record(RECORD_A)
271-
second = CleanRecordConverter.parse_record(RECORD_B)
272-
assert first is not None and second is not None
273-
gate = asyncio.Event()
274-
fake_channel.send_gate = gate
275-
first_request = asyncio.create_task(clean_history.refresh_detail(first))
276-
await fake_channel.send_started.wait()
277-
fake_channel.send_started.clear()
278-
second_request = asyncio.create_task(clean_history.refresh_detail(second))
279-
await asyncio.sleep(0)
280-
assert not fake_channel.send_started.is_set()
281-
assert not second_request.done()
282-
gate.set()
283-
await asyncio.gather(first_request, second_request)
284-
assert [params["52"]["id"] for _, params in fake_channel.published_commands] == [RECORD_A, RECORD_B]
236+
record = CleanRecordConverter.parse_record(RECORD_A)
237+
assert record is not None
238+
fake_channel.send_error = RoborockException("publish failed")
239+
with pytest.raises(RoborockException, match="publish failed"):
240+
await clean_history.refresh_detail(record)
241+
assert fake_channel.published_commands == []
242+
243+
fake_channel.send_error = None
244+
await clean_history.refresh_detail(record)
245+
assert fake_channel.published_commands == [(B01_Q10_DP.COMMON, {"52": {"op": "select", "id": RECORD_A}})]
285246

286247

287248
def test_received_detail_is_serializable_and_path_list_is_defensive(q10_api: Q10PropertiesApi) -> None:

0 commit comments

Comments
 (0)