Skip to content

feat: decode and render Q10 saved-map obstacles - #1

Draft
tubededentifrice wants to merge 37 commits into
mainfrom
q10-map-obstacles
Draft

feat: decode and render Q10 saved-map obstacles#1
tubededentifrice wants to merge 37 commits into
mainfrom
q10-map-obstacles

Conversation

@tubededentifrice

@tubededentifrice tubededentifrice commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Adds focused support for obstacle markers carried by Q10 saved-map (03 01) packets. The draft is now based on current main; all obsolete Python-roborock#848 stack code has been removed.

Source lifecycle and API

  • Q10MapPacket owns raw obstacle coordinates decoded from the saved-map RPC stream.
  • The parser accepts current-map (01 01) and saved-map (03 01) markers, but only saved-map packets decode obstacle data.
  • Obstacle parsing is anchored after one fully validated carpet block, preventing malformed or current-map tails from being misread.
  • The pure renderer derives obstacle pixels from the packet header and fixed obstacle scale, then draws them into its single PNG output.
  • No calibration, MapData, render model, or alternate image path is exposed to the trait.
  • Protocol dispatch routes saved-map packets through the existing Q10MapPacket path; no trait changes are needed here.

Provenance

Two real ss07 captures shared by @andrewlyeats are included as fixtures:

  • b01_q10_saved_map_2obstacles.bin — two markers
  • b01_q10_saved_map_53obstacles.bin — 53 markers

The captured coordinates reproduce the expected header-anchored placement: raw (-4633, -1946) maps to grid pixel (72.4, 82.3), and (-5231, -3852) maps to (60.5, 120.4).

Validation

  • focused parser, renderer, and protocol suite — 77 passed, 10 snapshots passed
  • pytest -q — 799 passed, 86 snapshots passed
  • pre-commit run --show-diff-on-failure --color=always --all-files — all hooks passed, including Ruff and mypy

Stack

This remains a draft until Python-roborock#887 merges. Python-roborock#887 connects the pure renderer to the Q10 trait; after that, this branch can merge latest main and be promoted upstream as the next small PR.

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.
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.
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).
Implements @andrewlyeats' suggestion (PR Python-roborock#848 review): the ss07 01 01
grid-frame header carries the calibration, so a GridCalibration origin
can be read straight from the packet instead of being recovered by
solve_calibration's dense-path slide.

- Decode the header calibration fields into Q10MapPacket:
  Q10HeaderCalibration{origin_x, origin_y (5 mm units), resolution,
  charger x/y/phi}. origin_pixels() returns the grid-pixel origin
  (header value / 10, since the grid is 50 mm/px); keepalive frames
  (x_min == y_min == 0) report is_keepalive and yield no origin.
- Add solve_calibration_with_origin(): fits only resolution + Y sign
  around a fixed pixel origin, validated against on-floor points. With
  the 2-D offset slide gone, a short path confirms the fit instead of a
  dense clean.
- MapContentTrait.solve_calibration() now prefers the header origin
  (>= 4 path points) and falls back to the full fit (>= 20) for
  keepalive frames or when the header origin doesn't validate.

The header origin (5 mm units -> /10 px) is the unambiguous, verifiable
part of the report. The GridCalibration resolution still lives in the
path's native units (the branch fits ~13-16/px), so it is confirmed
against a short path here rather than read from the header's 50 mm/px
field; a fully path-free resolution awaits the annotated ss07 captures
@andrewlyeats offered.

Tests: header field decode + keepalive, solve_calibration_with_origin
(short path / off-floor reject / no points), and trait-level header vs
fallback paths. Full suite green (584 passed).
Virtual walls (dpVirtualWallUp 57) use a different on-wire frame from the
restricted-zone DPs: a bare [count] byte (no version, no per-record
type/pad) then 8-byte (y, x) int16-BE records. Feeding such a blob to
parse_zone_blob mis-frames it (leading 0x01 read as a version, the next
coordinate byte as a record count), so virtual_walls silently came back
empty and the wall overlay never rendered.

Add parse_virtual_wall_blob (axes un-swapped to (x, y) so walls share the
restricted-zone coordinate order), point load_overlays at it for DP 57,
and correct the overlay module's docs that wrongly claimed parse_zone_blob
handled DP 57. Tested against a real ss07 read-back from the PR Python-roborock#850 thread.
Read back from our RDC robot after drawing two Invisible Walls in the
official app. Verified end-to-end through the map trait's load_overlays;
the previous parse_zone_blob path returned [] for this exact blob.
Read back from RDC with three No-Go Zones drawn; exercises the 38-byte
slot walk at count=3 against real device bytes.
The 0201 path frame uses a 14-byte header, not 10: bytes 10-11 are the
robot's SLAM heading (s16 degrees) and bytes 12-13 a constant, with the
path points starting at byte 14. The previous 10-byte header folded the
heading word into a phantom leading point (heading, 0) -- the "stray
point" the heuristic was papering over, and why the point count read one
high.

Verified byte-for-byte against the live ss07 captures and our own
fixtures: the docked capture carries count 0 + heading 169 (no points),
and the corridor capture carries count 14 + heading -34 with exactly 14
points from byte 14. The byte-8/9 count is now the exact number of
points (matches the 1417 / 2462 captures).

Parse the heading onto Q10TracePacket, plumb it through to the map trait
as robot_heading, and draw a facing-direction tick on the rendered robot
marker. The near-origin sentinel drop still runs, now correctly, since
the heading no longer masquerades as point 0.
… zones

DP 57 records are (x, y) int16-BE -- the same coordinate order as the
restricted-zone DP 55 -- not (y, x). parse_virtual_wall_blob was swapping
the axes, which placed every wall transposed 90 degrees from where it was
drawn. The swap came from a misreading of PR Python-roborock#850's notes and had never
been checked against a wall's actual position.

Ground-truthed against the app on two devices:
- RDC: the wide no-go zone reads back wide (x-range >> y-range), matching
  the horizontal band drawn across both living rooms -- so DP 55 keeps the
  first wire word on x, and there is no world<->display transpose.
- R1: a wall drawn horizontally below the Kids bedroom reads back with x
  varying and y constant only when the axes are NOT swapped; the old swap
  rendered it vertical.

Drop the swap so walls share the zone order, add the R1 capture as a
regression fixture, and correct the docs/synthetic fixtures that described
the records as (y, x). Also render virtual walls in render_path_on_map
(2-point line segments were silently skipped, so walls never drew).
Real DP-57 read-back from the R1 with one horizontal and one (near-)vertical
wall drawn in the app. Confirms the un-swapped axis order holds for both
orientations in a single blob -- the second wall's 4-unit x drift even matches
it not being drawn perfectly vertical. Locks in the mixed-orientation case the
inferred two-walls fixture only approximated.
…th-units/px

A dense ss07 path fits resolution 20.0 around the header origin, but the
[10.0..18.0] candidate range couldn't reach it (the fit railed at 18, or at 10
for sparse paths), biasing every header-anchored calibration. Ground-truthed on
the R1: a corridor drive registered at 20 (matching the format author's
independent value), and the dock->robot path span (3619 units) lined up with
the ruler-measured 8.81 m corridor at that scale -- ~2.4 mm/path-unit and a
~49 mm/px grid, confirming the header resolution=5. Widen to [12.0..26.0] so the
fit can land on 20.
…nvention

Scale: with the header resolution=5 (50 mm/px grid) and the confirmed 20
path-units/px, one path-unit is exactly 2.5 mm -- so a path-unit is not a
millimetre (the reviewer's open scale question). Refine the resolution-range
comment accordingly.

Heading: a live R1 clean confirmed the convention including the y-sign -- on
straight segments the reported heading equalled the direction of travel
atan2(dy,dx): +x read 0, -x read +/-180, a slight -y drift read negative. So
the on-map heading arrow points the right way.
# Conflicts:
#	roborock/data/b01_q10/b01_q10_containers.py
#	roborock/devices/traits/b01/q10/__init__.py
…-message model

PR Python-roborock#847 (the PR this branch is stacked on) landed a typed-message decode/
dispatch model on main: stream_decoded_messages() decodes each MAP_RESPONSE
push into a typed Q10Message (Q10DpsUpdate | Q10MapPacket | Q10TracePacket)
via decode_message(), and Q10PropertiesApi._handle_message routes by
isinstance. This branch predated that and carried the older push-driven model
(channel yielding DPS-only dicts, _handle_message taking the raw RoborockMessage
and calling map.update_from_map_response()), so the merges reconciled the
parsing details but left the dispatch architecture reverted -- decode_message
ended up orphaned (defined + unit-tested but with no production caller), which
is the revert @allenporter flagged.

Re-reconcile onto main's typed model instead of replacing it:

- b01_q10_channel.py: restore stream_decoded_messages() + decode_message()
  (now byte-identical to main); drop the DPS-only stream_decoded_responses().
- traits/b01/q10/__init__.py: restore main's typed isinstance dispatch. The
  branch's one net-new behavior -- feeding the no-go / virtual-wall overlay DPs
  to the map trait -- now rides on the Q10DpsUpdate branch.
- traits/b01/q10/map.py: replace update_from_map_response(message) with main's
  typed update_from_map_packet(packet) / update_from_trace_packet(packet). The
  trait caches the parsed Q10MapPacket (self._packet) so erase zones / overlays
  re-render without re-parsing wire bytes; parse_map_content() re-renders the
  cached packet. Drop raw_api_response (no raw bytes reach the trait in the
  typed model, matching main). Calibration / layers / erase / overlay features
  are unchanged, just layered on the typed entry points.
- tests: drive the typed methods; the trait-level "ignores non-map" case is
  now covered by the decode_message protocol tests and the dispatch integration
  tests.

decode_message is back on the production path. Full suite green (611 passed);
mypy + ruff clean.
The packet tail past the erase section is not opaque: it continues with a
carpet mask, then (currently undecoded) obstacle and skip-clean sections.
Decode the carpet mask here.

Framing (reported by @andrewlyeats on PR Python-roborock#848, then confirmed byte-exact on
our own ss07 hardware -- live captures from both the R1 and RDC):

- The carpet mask follows the erase section and uses the same framing as the
  main grid block: [u32 uncompressed_len][u16 compressed_len][LZ4 block].
- The decompressed mask is a full width*height grid in the same top-down pixel
  space as the main grid; a non-zero cell is carpet (the value is the carpet
  kind). The uncompressed length equals width*height exactly on every capture,
  which is used as the guard: if it doesn't hold we mis-located the section and
  leave carpet undecoded rather than emit garbage.

Verified on hardware (the synthetic fixture has an empty tail, so this could
not be exercised before): walking erase -> carpet -> obstacles -> skip consumes
both frames to the last byte, and the carpet masks are non-empty -- R1 = 1047
carpet cells (kind 4), RDC = 2856 cells (kinds 3 and 4).

- parse_map_packet now returns Q10MapPacket.carpet_mask (the decompressed grid).
- B01Q10MapParser populates MapData.carpet_map (flat grid indices y*width+x),
  which lines up with the rendered top-down raster and composes with the layers.
- Tests build a synthetic carpet section with the shared LZ4 helper and cover
  the decode, the MapData.carpet_map population, the no-carpet case, and the
  dimension-mismatch guard.

The obstacle and skip-clean sections share this tail; their framing is also
byte-exact (count byte + N int16 pairs) but both read count 0 on our current
maps, so decoding their values is left for a follow-up once we have a non-empty
capture.
The orchestrator special-cased the no-go / virtual-wall data points,
reaching into the map trait's load_overlays after the generic DPS fan-out.
Per review feedback on Python-roborock#848, the map trait now implements update_from_dps
and joins the _updatable_traits fan-out like every other read-model trait,
picking out the vector-overlay DPs it owns and ignoring the rest. The
orchestrator no longer needs to know which DPs the map cares about.

A DpsUpdatable Protocol in the Q10 common module types the (now
heterogeneous) fan-out list, since the map trait extends TraitUpdateListener
directly rather than UpdatableTrait.
# Conflicts:
#	roborock/devices/traits/b01/q10/__init__.py
Addresses review feedback that `_render_packet` mutated ~8 trait fields
through 3-4 layers of side-effecting methods, making the trait hard to
review and blurring the state-management / pixel-work boundary.

- Add `roborock/map/b01_q10_render.py`: `render_q10_map()` composes a map
  packet + path + overlays + calibration into one `Q10MapRender` result
  (image + MapData + layers), and owns the erase blanking, world->pixel
  overlay placement, path drawing and calibration policy that previously
  lived on the trait. The whole data flow reads top-to-bottom in one
  function instead of nested mutations.
- `MapContentTrait` is now just state management: it accumulates the pushed
  inputs and rebuilds the single `Q10MapRender` wholesale on each change,
  exposing read-only properties. No more per-field clearing. The derived
  rendering scaffolding (erase zones, header calibration) is off the public
  surface; layers/calibration stay (used by the CLI + frontend compositing).
- Move the geometry/pixel tests to tests/map/test_b01_q10_render.py; the
  trait tests now cover state management.
- `UpdatableTrait` explicitly declares the `DpsUpdatable` protocol so the
  heterogeneous fan-out list's shared shape is documented (the map trait
  satisfies it structurally without being a converter-backed read-model).
Saved-map frames (marker `03 01`) share the current-map (`01 01`)
header/grid/room layout but append an obstacle-marker section to the tail,
after the carpet block: `[count:u8]` then `count` int16-BE (x, y) pairs in
raw obstacle coordinates.

- Parse `03 01` frames (`is_saved_map_packet`; `parse_map_packet` now
  accepts both markers) and decode the obstacle section into
  `Q10MapPacket.obstacles`. Located after a validated carpet block, so a
  frame without one decodes no obstacles rather than misreading the tail.
- Place obstacles onto `MapData.obstacles` in `render_q10_map` using a fixed
  header-anchored scale (`col = ox/10 + x/50`, `row = oy/10 - y/50`,
  resolution 50 around the header pixel origin) -- independent of the fitted
  path calibration, so they appear as soon as the saved map arrives.
- Route `03 01` through the protocol dispatch; the map trait needs no change.

Fixtures + byte-level decode ground-truthed against two real ss07 captures
shared by @andrewlyeats (2- and 53-obstacle frames, map id zeroed): obstacle
raw (-4633, -1946) with header origin (1651, 434) lands at grid px
(72.4, 82.3), matching his expected decode.

Follow-ups (not in this PR): the trailing 17-byte-header path package and the
skip-clean section that also live in the `03 01` tail.
Resolve conflicts from Python-roborock#859 (decouple B01 protocol/transport layer):
- q7/map_content.py: adopt the new Q7MapRpcChannel.send_map_command(method,
  params) call style; drop the now-unused Q7RequestMessage/B01_Q7_DPS imports
  while keeping the Q10 layers/calibration decomposition.
- q10/test_map.py: route the subscribe-loop integration tests through the new
  parsed-Q10Message stream (FakeB01Q10Channel) instead of raw RoborockMessage;
  drop the obsolete _map_message helper; keep the new layer/calibration/overlay
  test coverage.
# Conflicts:
#	roborock/map/b01_grid_layers.py
#	roborock/map/b01_q10_map_parser.py
#	roborock/map/b01_q10_overlays.py
#	roborock/map/b01_q10_render.py
#	tests/devices/traits/b01/q10/test_map.py
#	tests/map/test_b01_grid_layers.py
#	tests/map/test_b01_q10_map_parser.py
#	tests/map/test_b01_q10_overlays.py
#	tests/map/test_b01_q10_render.py
@tubededentifrice tubededentifrice changed the title feat: decode Q10 (03 01) saved-map obstacle markers feat: decode and render Q10 saved-map obstacles Jul 19, 2026
@tubededentifrice
tubededentifrice changed the base branch from q10-map-layers to main July 19, 2026 19:25
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.

1 participant