feat: Q10 (B01/ss07) map support — rooms + rendered map image#847
Conversation
Brings Q10 maps toward parity with V1 devices. Verified end-to-end against two physical Q10 (roborock.vacuum.ss07) robots. Protocol (reverse-engineered from live captures): - Requesting device state (dpRequestDps) makes the robot push its current map as a protocol-301 MAP_RESPONSE a few seconds later (firmware throttles to ~once per minute). - The "01 01" map packet carries a u32be map id, u16le grid width, and an LZ4-block-compressed occupancy grid followed by 47-byte room records (id + ascii name); room cells use value room_id*4. The payload is unencrypted, unlike the Q7 SCMap protobuf format. Changes: - roborock/map/b01_q10_map_parser.py: clean LZ4 block decoder + packet parser + renderer producing a PNG and MapData with room names. - roborock/devices/rpc/b01_q10_channel.py: request_map() triggers and awaits the MAP_RESPONSE push. - roborock/devices/traits/b01/q10/map.py: MapContentTrait (refresh/parse/image/ rooms), wired into Q10PropertiesApi. - cli: `map-image` and `rooms` now work for Q10 devices. - Tests + a synthetic (no-PII) map fixture. Map packet format documentation credit: the roborock-qseries-map-bridge project (GPL-3.0): https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
Adds parsing for the Q10 "02 01" live position packet (delivered on the same protocol-301 channel as the map, only while the robot is moving). The packet format was reverse-engineered and validated against live ss07 captures (the 18-byte-header layout documented elsewhere did NOT match this firmware): - 10-byte header (sequence counter at byte 3, then a constant type/flag). - big-endian int16 (x, y) point pairs; this firmware sends the current position as a single point per packet rather than an accumulated path. - Confirmed live: as R1 traversed the corridor, the decoded x moved from -163 to +169 with y ~0. The full saved map packet (01 01) was checked too and does NOT carry the live path (identical across captures during a clean), so position comes from 02 01. - b01_q10_map_parser: parse_trace_packet() + Q10TracePacket/Q10Point. - b01_q10_channel: request_trace() (marker-filtered). - MapContentTrait.refresh_trace() exposes path + robot_position. - cli: `q10-position` (reports gracefully when the robot is idle). - Tests use a real captured position packet + a synthetic multi-point packet.
|
Added Q10 trace parsing for The tested firmware uses a 10-byte header followed by int16 Later captures from the same cleaning session showed multi-point packets containing the accumulated current-session path; the follow-up comment below covers that final framing and the added fixture. |
Live capture (R1 corridor run) disproved the earlier 'single current point per packet' assumption: the same session emitted packets of 1, then 3, then 15 points, each a strict superset. The robot accumulates the full session path server-side and returns it whole, so a client connecting mid-session still gets the complete trail (matching the app showing it after a cold launch). The parser already read all points; this corrects the docs and adds a real 15-point fixture + test, and clarifies that byte 3 is a session counter (tracks the device clean count) not a per-packet sequence.
|
Updated the trace handling with a real 15-point current-session path fixture. A live corridor run produced The parser now treats the packet body as int16 |
allenporter
left a comment
There was a problem hiding this comment.
Thank your pushing this forward!
My only high level issue is for Q10 we want to avoid faking a synchronous API where there is not one and keep things entirely asynchronous. WDYT?
|
@allenporter yeah I think that's the right call, and will avoid problems with that logic in the first place, let me refactor that a bit |
The Q10 has no synchronous get-map command. The previous MapContentTrait faked one: refresh()/refresh_trace() sent a dpRequestDps and blocked awaiting the next MAP_RESPONSE push with a timeout. That has no request/response correlation and fights the firmware's ~60-70s push throttle. Mirror the existing Q10 StatusTrait model instead: - MapContentTrait is now a push-only TraitUpdateListener. The Q10PropertiesApi subscribe loop routes protocol-301 MAP_RESPONSE packets to update_from_map_response(), which parses the payload, updates the cached fields and notifies listeners. - Drop request_map()/request_trace() and the trait's refresh()/refresh_trace(). - CLI map-image/rooms/q10-position now nudge the device with refresh() and wait on a map-trait update listener for the pushed data.
|
@allenporter Done, pushed the push-driven refactor. Lines up with how Q10 status already works. |
1ccc910 to
8797ede
Compare
|
Thanks for this — great to see first-class Q10 map support landing. I am independently reverse-engineering the 1. Independent confirmations:
2. Grid width — I read it at a different offset, and My grid is 222 × 261, which straddles a 256 boundary, so it's a live failure case. Header bytes 7–10 are
The 3. Some Thanks again, and glad to open small PRs with tests for either if that's easier than comments. |
The 01 01 map header carries two consecutive u16be fields: grid width at bytes 7-8 and grid height at bytes 9-10. The previous u16le read at offset 8 picked up the height's high byte, so it only matched the true width when width and height fell in the same 256-band -- a 222x261 map decoded its width as 478 and failed to split the layout. Read both dimensions as u16be straight from the header (falling back to brute-force inference when the height field is absent, e.g. older fixtures). Also drop a spurious leading trace point that some cleans prepend far outside the map; it skews the rendered start and path-based calibration. points[0] is dropped only when its step to points[1] is a gross outlier (>20x the median step), so a genuine first point is never lost. Both issues were reported, diagnosed and verified by @andrewlyeats from an independent B01/Q10 decoder; the u16be dimension read is corroborated by the ioBroker roborock adapter.
|
Thanks @andrewlyeats — this is excellent, careful work, and both issues you flagged were real. Fixed in 629887a, with you credited in the commit message and code comments. 1. Grid width (the >256 bug). You were exactly right. We were reading width as I verified this against your The fix reads both width and height as 2. Stray leading point. Also reproduced and fixed, using your rule: drop On the trace-header discrepancy you mentioned: we're keeping the 10-byte header for now because it's what verifies on our ss07 (the count field reads points−1, and 1→3→15-point packets are exact supersets with a 10-byte header). Your hypothesis that a sometimes-present 4-byte leading point reads as "extra header" to a fixed-offset parser is a very plausible explanation for the 10-vs-16/18-byte spread across decoders — and the outlier-drop above should make the parser robust to it either way. Happy to take the capture if you'd like it added as a fixture, but the synthetic 222×261 regression covers the failure deterministically. Thanks again for the precise diagnosis. 🙏 |
test_map.py imports roborock.cli (for _await_q10_map_push), which drags cli.py into mypy via follow_imports. cli.py is intentionally not mypy-clean -- the pre-commit hook excludes it -- but an exclude can't stop transitive imports, so its ~79 latent errors surfaced in CI. Mirror the exclude's intent with [mypy-roborock.cli] ignore_errors, which suppresses cli.py errors however it's reached, and cast the minimal Q10 test doubles to Q10PropertiesApi at the _await_q10_map_push call sites.
allenporter
left a comment
There was a problem hiding this comment.
Thank you very much, this is great. I've got some suggested improvements, but overall looking forward to merging this.
Move RoborockMessage decoding out of the map trait and into the Q10 protocol/rpc layer: decode_message() now returns a typed Q10Message (Q10DpsUpdate | Q10MapPacket | Q10TracePacket) and stream_decoded_messages() yields it. The subscribe loop dispatches by type to the read-model traits. - Trait deals with parsed packets, not raw messages: update_from_map_packet / update_from_trace_packet replace update_from_map_response. - Map packet is parsed once: B01Q10MapParser gains parse_packet(packet) so the trait renders the already-parsed packet instead of re-parsing the bytes. - Drop the broad except in the trait and the raw_api_response state; packet parsing only raises RoborockException, funneled through the stream. - Packet-kind markers live solely in the map parser (is_map_packet / is_trace_packet); the duplicated trait constants are gone.
|
Thanks @allenporter should be gtg now (hopefully 😂 ) |
|
thank you 💯 |
Reconciles this branch's Q10 map-layers / calibration / path / overlay work with the Q10 map support that landed on main via Python-roborock#847. The two diverged after the branch's 2026-06-15 work; Python-roborock#847 (merged 2026-06-21) absorbed several @andrewlyeats-validated protocol corrections this branch predated. Net result is a union, not a one-side win: Took from main (newer, validated protocol corrections): - map parser width/height: two consecutive u16be fields (offsets 7/9) + _split_with_dims, fixing the 222x261 (cross-256-band) mis-split that the older u16le@8 read produced; _infer_layout kept as fallback. - trace _drop_stray_leading_point hygiene. - overlay zone-type constants: 0 no-go, 1 virtual-wall, 2 no-mop, 3 threshold (corrects this branch's earlier 3=no-mop reading). Kept from this branch (net-new features main lacks): - erase-zone decode from the packet tail, decompose_layers / classifier, GridCalibration usage, push-driven trait (update_from_map_response, load_overlays, render_path_on_map), and the q10_map_layers / q10_map_with_path CLI commands. - top-down (no-flip) rendering: the branch's overlay/path/calibration placement is built on un-flipped grid-pixel coords, so the base raster is rendered un-flipped to keep overlays aligned. CLI _await_q10_map_push merges both improvements: the early already-satisfied short-circuit and main's allow_cached_on_timeout. test_map zone-type test updated to the corrected no-mop constant (2). Full suite green (576 passed).
Summary
Adds Q10 (
roborock.vacuum.ss07, B01 protocol) map support: requesting current state makes the robot push a protocol-301 map packet, which this PR can parse into a rendered map image, room list, and current-session path data.This branch is independent of #846 and is based on
main.Protocol Notes
dpRequestDpscauses the robot to publishMAP_RESPONSEpackets shortly afterward. Firmware also throttles these pushes.01 01marker. The parser reads the map id, grid width, compressed layout length, and an LZ4 block containing the occupancy grid plus room records.02 01marker. On the tested firmware these contain a 10-byte header followed by big-endian int16(x, y)point pairs for the current cleaning session path.SCMapprotobuf format.Changes
roborock/map/b01_q10_map_parser.pyfor Q10 map, room, LZ4, and trace parsing.request_map()andrequest_trace()helpers on the Q10 channel.MapContentTraitforrefresh(),parse_map_content(),refresh_trace(),image_content,map_data,rooms,path,robot_position, andraw_api_response.map-image,rooms, and Q10 position flows through the Q10 map trait.Testing
uv run pytest— 510 passed on this branchuv run pre-commit run --all-filesCredit
The initial
01 01/02 01packet layout reference came fromroborock-qseries-map-bridgeand the related Home Assistant community thread. The implementation here is separate and is covered by tests against synthetic or scrubbed fixtures.Follow-ups