Skip to content

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

Open
tubededentifrice wants to merge 6 commits into
Python-roborock:mainfrom
tubededentifrice:q10-maps
Open

feat: Q10 (B01/ss07) map support — rooms + rendered map image#847
tubededentifrice wants to merge 6 commits into
Python-roborock:mainfrom
tubededentifrice:q10-maps

Conversation

@tubededentifrice

@tubededentifrice tubededentifrice commented Jun 14, 2026

Copy link
Copy Markdown

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

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.

2 participants