feat: Q10 map layers, calibration, path & no-go zones (+ Q7 layer reuse)#848
Draft
tubededentifrice wants to merge 14 commits into
Draft
feat: Q10 map layers, calibration, path & no-go zones (+ Q7 layer reuse)#848tubededentifrice wants to merge 14 commits into
tubededentifrice wants to merge 14 commits into
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.
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.
8af91fe to
0e3af65
Compare
a026949 to
c4beec1
Compare
Contributor
|
If there are low level map parsing parts, you could send them out in parallel without blocking on the other changes if that would be helpful to parallelize review. otherwise, we can mark this in draft until the other blocking parts are reviewed and merged. |
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.
Adds a device-agnostic grid->layers module (b01_grid_layers) that splits a single-byte occupancy grid into background/wall/floor/per-room layers via a caller-supplied classifier, each renderable to a transparent RGBA PNG for frontend compositing. Wires a Q10 classifier (confirmed against real ss07 captures: 243=background, 249=wall, 240=unsegmented floor, value=room_id*4 for room floor) and exposes layers on MapContentTrait, plus a q10-map-layers CLI command that lists layers and can export per-layer PNGs. The shared module is built classifier-first so Q7's 0/127/128 grid can reuse it later.
The Q10 packet carries no calibration (header fields are map-growth metadata; room records hold flags, not coords), so the world<->pixel transform is solved from a cleaning path: GridCalibration + solve_calibration() slide the path's pixel bbox to maximise on-floor overlap. Validated on a live R1 corridor run (184-pt path, 183/184 on floor; path renders along the corridor with the robot at its end). MapContentTrait gains calibration, solve_calibration(), render_path_on_map() and populates MapData.path/vacuum_position in grid-pixel coords (consistent with the identity img_transformation). Adds a q10-map-with-path CLI command. Resolution is fit per-map so nothing is hardcoded; note origin_x landed exactly on header @14, hinting the header may encode origin.
Reverse-engineered the dpRestrictedZoneUp blob from a live ss07 (7 real zones): [version][count] + fixed 38-byte records of [type][nverts] + int16-BE vertex pairs, in world coords. New b01_q10_overlays.parse_zone_blob decodes it (type 0 = no-go, 3 = no-mop). MapContentTrait.load_overlays() stores zones + virtual walls and, with calibration, places them as MapData.no_go_areas / no_mopping_areas / walls in pixel space; the charger is derived from the path origin (the dock). The property API feeds the overlay DPs to the map trait from the status stream, and render_path_on_map() draws zones + dock + position. Validated live on R1: 6 no-go + 1 no-mop zones land squarely inside rooms. Virtual walls / zoned / carpets are empty on the test device, so their decoders are best-effort/scaffolded; obstacles were not located on the device channel.
Demonstrates the device-agnostic b01_grid_layers module serves both devices: the Q7 SCMap parser gains classify_q7_cell / decompose_q7_layers (0=background, 127=wall, 128=floor) and q7_calibration, which reads the world<->pixel transform straight from the SCMap mapHead (minX/minY/resolution) -- no path fitting needed (unlike the Q10). Q7's MapContentTrait now exposes layers + calibration. Q7's raster has no per-room segmentation and its map carries no path or zones, so Q7 reaches background/wall/floor layers + calibration only; per-room masks, path and vector overlays remain Q10-only. Validated against the existing Q7 SCMap fixture (no Q7 hardware available to test live).
The map (01 01) packet has a vector section after the compressed grid that the parser previously ignored: [count][vertices_per] + count polygons of int16-BE (x,y) pairs = carpet areas (user-defined + auto/self-identifying). Confirmed on two ss07 devices (R1: 3 carpets, RDC: 2) and explains why the dpCarpetUp DP is empty -- carpets ride in the map, not a DP. parse_map_packet now returns packet.carpets; MapContentTrait exposes them and, with calibration, rasterises them into MapData.carpet_map and draws them. The remaining tail (a run-length raster + trailing signature) is left for later -- likely the carpet pixel mask and/or obstacles.
load_overlays(restricted_zone_up=None) treated an absent DP as 'clear', so a status push carrying only the virtual-wall DP wiped the loaded no-go zones (caught live: zones loaded as 7, rendered as 0). None now means 'unchanged'; an explicit empty blob still clears. Regression test added.
Two corrections to the Q10 (ss07) map rendering, both verified on live hardware: Orientation: the ss07 grid is stored top-down (row 0 = top of the home), unlike the V1/Q7 bottom-up convention, so the inherited vertical flip rendered every Q10 map upside down. Make the flip a per-device property of GridLayers (Q10 = no flip; Q7 keeps flipping, untouched) and drop it from the Q10 renderer and the path/overlay math so all layers, the combined map and overlays stay consistent. Erase zones: a controlled with/without diff on a live device proved the map-packet tail "carpet" vector section is actually the app's *Erase* zone list -- removing the two zones in-app dropped its count 2->0 while the grid and the trailing raster stayed byte-identical (so the earlier "decode Q10 carpets" commit mislabeled them, and we were drawing them as purple polygons). Rename Q10Carpet -> Q10EraseZone and, once a calibration is available, blank the cells inside each erase rectangle to background and re-render so phantom floor (e.g. lidar seen through floor-to-ceiling windows) drops out of the map and every layer, matching the app. Validated on R1: a 57-point corridor path solved the calibration and the three erase zones removed the three phantom projections.
Follows the q10-maps push-driven refactor: the map trait no longer has refresh()/refresh_trace(), so adapt the layers/path CLI commands to nudge the device (dpRequestDps) and wait on a map-trait update listener, and route the overlay DPs through the refactored _handle_message dispatch.
c4beec1 to
6efe266
Compare
This was referenced Jun 15, 2026
Author
|
@allenporter ok, #850 is the standalone part |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds structured Q10 map layers, calibration, path placement, and vector overlays on top of #847. It also reuses the shared B01 grid-layer code for Q7 maps.
This PR is stacked on #847. Until #847 merges, GitHub will show those commits here too; the incremental work starts with
feat: decompose Q10 map into separable layers.Changes
GridCalibrationandsolve_calibration()to fit Q10 world coordinates to grid pixels from an active cleaning path.MapData.path,vacuum_position, and charger position once calibration is available.MapData.Notes
Testing
uv run pytest— 543 passed on the rebased stackuv run pre-commit run --all-files