Skip to content

feat: Q10 (B01/ss07) map support — rooms + rendered map image#847

Merged
allenporter merged 12 commits into
Python-roborock:mainfrom
tubededentifrice:q10-maps
Jun 22, 2026
Merged

feat: Q10 (B01/ss07) map support — rooms + rendered map image#847
allenporter merged 12 commits into
Python-roborock:mainfrom
tubededentifrice:q10-maps

Conversation

@tubededentifrice

@tubededentifrice tubededentifrice commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

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

  • Q10 does not appear to have a dedicated get-map command. Sending dpRequestDps causes the robot to publish MAP_RESPONSE packets shortly afterward. Firmware also throttles these pushes.
  • Full map packets use the 01 01 marker. The parser reads the map id, grid width, compressed layout length, and an LZ4 block containing the occupancy grid plus room records.
  • Trace packets use the 02 01 marker. 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.
  • The payloads are unencrypted and do not use the Q7 SCMap protobuf format.

Changes

  • Add roborock/map/b01_q10_map_parser.py for Q10 map, room, LZ4, and trace parsing.
  • Add request_map() and request_trace() helpers on the Q10 channel.
  • Add MapContentTrait for refresh(), parse_map_content(), refresh_trace(), image_content, map_data, rooms, path, robot_position, and raw_api_response.
  • Dispatch CLI map-image, rooms, and Q10 position flows through the Q10 map trait.
  • Add synthetic/scrubbed fixtures and tests, including maps with no room records yet.

Testing

  • uv run pytest — 510 passed on this branch
  • uv run pre-commit run --all-files
  • Live checked map image, rooms, and trace parsing on Q10 hardware

Credit

The initial 01 01 / 02 01 packet layout reference came from roborock-qseries-map-bridge and the related Home Assistant community thread. The implementation here is separate and is covered by tests against synthetic or scrubbed fixtures.

Follow-ups

  • Calibrate trace coordinates to image pixels for floorplan overlays.
  • Add dock/charger placement once calibration is available.

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.
@tubededentifrice

tubededentifrice commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Added Q10 trace parsing for 02 01 MAP_RESPONSE packets and exposed it through MapContentTrait.refresh_trace() plus the q10-position session command.

The tested firmware uses a 10-byte header followed by int16 (x, y) point pairs. The first capture I had was a single-point packet, so the parser handles that case.

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.
@tubededentifrice

tubededentifrice commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Updated the trace handling with a real 15-point current-session path fixture.

A live corridor run produced 02 01 packets with 1, then 3, then 15 points. Each larger packet contained the earlier points, so the robot is serving the accumulated path for the active cleaning session rather than only the latest position.

The parser now treats the packet body as int16 (x, y) pairs after the 10-byte header. Byte 3 is kept as the session counter, and bytes 8-9 match point-count-minus-one on the captured packet. The path is exposed as structured data through MapContentTrait.path and robot_position; drawing it onto the map is intentionally left to the calibration/layers PR.

@allenporter allenporter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@tubededentifrice

Copy link
Copy Markdown
Contributor Author

@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.
@tubededentifrice

Copy link
Copy Markdown
Contributor Author

@allenporter Done, pushed the push-driven refactor. Lines up with how Q10 status already works.
I also rebased the stacked map-layers PR (#848) on top of this so it uses the same push model. Entirely asynchronous now.

@andrewlyeats

Copy link
Copy Markdown
Contributor

Thanks for this — great to see first-class Q10 map support landing. I am independently reverse-engineering the
B01/Q10 protocol in a separate decoder (https://github.com/andrewlyeats/roborock-q10-cli), and I can corroborate most of your approach with what I'm seeing. I came to very slightly
different interpretations in a few minor areas and may have
caught a bug in your implementation that only happens to show up because my map has a dimension >256.

1. Independent confirmations:

  • 01 01 map packet: map id u32be@2, compressed-layout length u16be@27, LZ4 block from 29 — all match.
  • 02 01 trace: big-endian int16 (x, y) pairs, accumulating, emitted only during an active session.
  • Byte 3 of the trace as a session counter (clean count, not a per-packet sequence), and reading all pairs
    rather than trusting the count field — same conclusions here.

2. Grid width — I read it at a different offset, and _WIDTH_OFFSET = 8 (u16le) breaks on my map. In my
captures the 01 01 header carries two consecutive u16be values — width at [7:9], height at [9:11]. A
u16le read at offset 8 sits one byte to the right, so its high byte is byte 9 — the height's high byte; it
only equals the true width when width and height land in the same 256-band.

My grid is 222 × 261, which straddles a 256 boundary, so it's a live failure case. Header bytes 7–10 are
00 DE 01 05:

  • [7:9] BE → width 222, [9:11] BE → height 261, and _infer_layout then finds 7 rooms cleanly.
  • [8:10] LE → width 478 (0xDE + 256·0x01); no room_count makes area % 478 == 0, so _infer_layout
    raises Could not infer Q10 layout dimensions on this map.

The [7:9]/[9:11] BE read is consistent across all 124 of my map frames, and an independent row-stride
heuristic (the stride that maximizes vertical row-similarity — no header assumption) agrees on every one of
them. Reading height straight from [9:11] BE would also drop the brute-force inference. Happy to share the
capture or open a small PR with the BE read + a 222×261 regression test.

3. Some 02 01 packets prepend a stray leading point. It varies by clean — present in several of my
captures, absent in others (including my main one). When present, the first (x, y) sits far outside the map
(≈ (0, -1907), while the real path starts near (-3760, -1920)). robot_position (the last point) is fine,
but it skews the rendered start/bounding box and a path-based calibration fit (relevant to #848). I drop it only
when points[0]'s step to points[1] is a gross outlier versus the median step, so a genuine first point is
never lost. I haven't pinned down the trigger (longer / multi-segment cleans are my current lead) — flagging in
case it's behind the 10-vs-18-byte trace-header discrepancy you noted against roborock-qseries-map-bridge (a
sometimes-present 4-byte leading point reads as "extra header" to a fixed-offset parser).

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.
@tubededentifrice

Copy link
Copy Markdown
Contributor Author

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 u16le at offset 8, whose high byte is actually the height's high byte — so it only matched the true width when width and height shared a 256-band. Your 00 DE 01 05 walkthrough decodes cleanly as two consecutive u16be fields: width [7:9] = 222, height [9:11] = 261.

I verified this against your FRAME_ANATOMY.md (map id BE 2–5, width BE 7–8, height BE 9–10, compressed size BE 27–28) and independently against the ioBroker roborock adapter, which reads the same fields big-endian. It also matches our own data — our captures just happened to have both dimensions <256, where le@8 and be@7 coincide, which is why this slipped through.

The fix reads both width and height as u16be straight from the header (your point that this drops the brute-force inference entirely is well taken), falling back to the old _infer_layout only when the height field is absent. Added a 222×261 regression test reproducing your exact straddle case (asserts the old le@8 read would have given 478).

2. Stray leading point. Also reproduced and fixed, using your rule: drop points[0] only when its step to points[1] exceeds 20× the median step of the rest of the path — so a genuine first point is never lost, and robot_position (the last point) is untouched. Our real 15-point corridor fixture has a 4.8× first step (an initial reposition), well under the cut, so it stays intact; added tests for both the drop and the keep cases.

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 allenporter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much, this is great. I've got some suggested improvements, but overall looking forward to merging this.

Comment thread roborock/devices/traits/b01/q10/__init__.py
Comment thread roborock/devices/traits/b01/q10/map.py Outdated
Comment thread roborock/devices/traits/b01/q10/map.py Outdated
Comment thread roborock/map/b01_q10_map_parser.py
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.
@tubededentifrice

Copy link
Copy Markdown
Contributor Author

Thanks @allenporter should be gtg now (hopefully 😂 )

@allenporter allenporter merged commit 10e51f5 into Python-roborock:main Jun 22, 2026
7 checks passed
@allenporter

Copy link
Copy Markdown
Contributor

thank you 💯

tubededentifrice added a commit to tubededentifrice/python-roborock that referenced this pull request Jun 23, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants