Skip to content

Q-series / B01 Q10 map and live trace packets decoded via HA runtime bridge #827

Open

Description

@v1b3c0d3x3r

I have a working sanitized proof-of-concept for Roborock Q-series/B01 Q10 map and live trace extraction in Home Assistant.

Tested with Roborock Q10 S5+ devices.

Key finding:

  • Reuse Home Assistant's official Roborock integration runtime after it has authenticated.
  • Locate the loaded Q10 runtime under HA's Roborock config entry runtime data.
  • Use the existing Q10 API object rather than doing a separate Roborock login.
  • Send COMMON / MULTI_MAP list/get through the Q10 API object.
  • Listen to the Q10 channel stream.
  • Decode binary packets:
    • 01 01 = full map/layout packet.
    • 02 01 = live trace/path packet.
  • Render the trace as a transparent HA camera overlay.

The package includes:

  • sanitized custom component proof-of-concept.
  • offline parser and renderer.
  • packet format notes.
  • step-by-step implementation notes.
  • feature roadmap for cleaned area, zones, virtual walls, etc.

Repo:
https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge

No credentials, tokens, map IDs, device identifiers, private captures, or household calibration are included.

I am sharing this so it can be reviewed, corrected, and hopefully folded into python-roborock / Home Assistant properly instead of depending on HA runtime internals from a custom bridge.

Activity

  1. andrewlyeats commented on Jun 14, 2026

    @andrewlyeats
    Contributor

    Thanks for sharing this — really useful writeup. I ended up decoding the same Q10 (B01, roborock.vacuum.ss07) map stream independently for a standalone CLI (andrewlyeats/roborock-q10-cli), reading the spontaneous protocol-301 frames over MQTT directly (outside Home Assistant). Same 0101/0201 split and LZ4 layout on our end, so I'm just adding what we found in case any of it is useful for an upstream parser.

    Grid width: in our captures we didn't get a reliable width from the header bytes, so we ended up recovering it from the decompressed grid instead — picking the row stride at which walls stay continuous. Grid dimensions vary per home, so a header-derived width may be map-size-dependent; sharing in case it helps.

    Lining the path up with the grid: in our case, path points mapped onto grid pixels with a fixed origin and ~20 mm/px, with the grid's row axis inverted relative to the path's x-axis — roughly col = (y + Cy) // 20, row = (Cx − x) // 20, where Cx/Cy are dock-anchored constants that differ per install. That registration landed ~99.8% of our path points on floor pixels and stayed stable as long as the dock didn't move and the map wasn't reset. Not claiming it's the general solution — just what worked for our single map.

    Grid pixel values we saw: pixel // 4 == room_id; 243 = outside/unmapped, 249 = wall.

    Spatial DPs (walls / zones / carpets): we were also able to decode these from the robot's *_UP output DPs, in case they're useful later:

    • Virtual walls: [count:u8] + per-wall (y1, x1, y2, x2) BE int16 — (y, x) order.
    • No-go / no-mop / clean zones: [0x01][count:u8] + per-zone [type:u8][nverts:u8=4] + 4 × (x, y) BE int16.
    • Carpets: JSON (vertexs).
    • Coordinates are in 0.5 mm units (×2 to reach path-frame mm). (We weren't able to set these — the write seems to ride the MQTT input topic.)

    Availability we observed: the 0101 grid comes through even while docked; the 0201 path/position only during an active clean.

    Full byte-level notes and a working offline decoder are in DESIGN_NOTES.md and decode_map.py if helpful. Glad to help with a python-roborock PR if that'd be useful.

  2. andrewlyeats commented on Jun 23, 2026

    @andrewlyeats
    Contributor

    Following up on my earlier comment — I need to correct a few things I got wrong, and add a more useful update now that we've taken the work further.

    Corrections to my earlier comment (apologies — these were wrong or have since been superseded):

    1. Coordinate units — I said "0.5 mm units"; that was off by 10×. Ground-truthed against the app's default 3.3 ft (~1006 mm) zone, which decodes to ~200 units: the binary zone/wall coords are ~5 mm/unit, the 0201 path is ~2.5 mm/unit, and the grid is ~50 mm/px.
    2. Grid width — I overcomplicated it. It's a clean header read: width and height are consecutive u16be at [7:9] and [9:11] of the 01 01 frame (this is the read that went into feat: Q10 (B01/ss07) map support — rooms + rendered map image #847). My earlier "recover from the decompressed row-stride" was a workaround I no longer need.
    3. Georeference — the divisor is right but I mislabeled the unit. path_unit // 20 = pixel holds, but that 20 is 20 path-units/px (≈50 mm/px), not "20 mm/px".
    4. Setting zones/walls — I said we couldn't; we since cracked it. Restricted zones (DP 54) and virtual walls (DP 56) are settable from our own MQTT client via the string-key COMMON(101) envelope ({"101":{"54": <base64 blob>}}), live round-trip validated (write → read-back → restore, byte-exact). It does not require the app/MITM path.
    5. The dead link. My DESIGN_NOTES.md link is gone — that content moved into PROTOCOL.md and FRAME_ANATOMY.md.

    The actual update — live map and live pose without an active clean, via the DP-110 heartbeat:

    My earlier claim that the 0201 path "only comes through during an active clean" is wrong — but the exact behavior is worth stating precisely. The app gets the live map by polling DP-110 (COMMON(101){"110":1}, what we label HEARTBEAT) roughly every 5 s while the map screen is open. Two different things come back on those polls:

    • The 0101 occupancy grid streams in response to the heartbeat at any time, including while the robot is just docked and idle — it's the app's live-map keepalive.
    • The 0201 path/position (with a live heading field) streams outside an active clean during manual-control sessions — i.e. while the robot is being driven by hand (remote-control mode). It is not emitted while the robot sits docked and idle: we checked that case directly (docked, idle, never entered remote mode, sustained heartbeats) and got only the 0101 grid, no 0201 pose. So it isn't "any time" — but it is definitely not clean-only, since manual teleop (not a clean) is enough to get live pose.

    We replicated this from a plain MQTT client (no app, no HA runtime): sending the same {"101":{"110":1}} heartbeat on an interval streams the live 0101 grid to our connection while docked, and the live 0201 pose+heading while the robot is driven by hand — all outside any clean. So a live map (and, during manual control, live position) looks achievable in python-roborock directly: today the B01 path appears to drop the spontaneous 301 frames and never sends the heartbeat, so the stream is never requested.

    Happy to share captures. With the recent Q10 map work landing (#847 merged, #846/#848 in progress), the one piece this adds is eliciting that live map/pose stream on demand, without a clean: sending the DP-110 heartbeat gets the 0101 grid while docked (and the 0201 pose during manual control), instead of waiting on the spontaneous frames a clean produces. Glad to fold that heartbeat-poll trick into that effort if it's useful, rather than spin up a separate PR. And thanks again for the original writeup — the HA-runtime-bridge framing is what got us comparing notes.

  3. andig commented on Aug 4, 2026

    @andig
    Contributor

    I've got a Roborock Q7 Series B01 now. Please let me know if there's anything I can help with to get this integrated. No HA user, but fluent with coding + Claude.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions