Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
self.network_info = NetworkInfoTrait()
self.consumable = ConsumableTrait()
self._map_dps = MapDpsTrait()
self.map = MapContentTrait(self._map_dps)
self.map = MapContentTrait(self._map_dps, self.status)
self.clean_history = CleanHistoryTrait(self.command)
# Read-model traits updated from the device's DPS push stream.
self._updatable_traits = [
Expand Down Expand Up @@ -157,8 +157,12 @@ def _handle_message(self, message: Q10Message) -> None:
_LOGGER.debug("Received Q10 status update: %s", message.dps)
# Notify all read-model traits about the new message; each trait
# only updates the fields that it is responsible for.
for trait in self._updatable_traits:
trait.update_from_dps(message.dps)
self.map._begin_source_update()
try:
for trait in self._updatable_traits:
trait.update_from_dps(message.dps)
finally:
self.map._end_source_update()


def create(channel: B01Q10Channel) -> Q10PropertiesApi:
Expand Down
68 changes: 57 additions & 11 deletions roborock/devices/traits/b01/q10/map.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"""Push-driven map traits for B01 Q10 devices.

Map-related state arrives on three independent streams:
Map-related state arrives on four independent streams:

* map packets are decoded from map-protocol responses;
* trace packets are decoded from trace-protocol responses;
* restricted zones and virtual walls arrive as ordinary DPS values.
* the device status indicates when an idle robot is charging at the saved dock.

``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` depends
on it and combines that state with the latest map/trace packets through the pure
functions in :mod:`roborock.map.b01_q10_render`. The high-level trait keeps only
the latest value from each source and one replace-whole rendered image;
on it and the status trait, then combines that state with the latest map/trace
packets through the pure functions in :mod:`roborock.map.b01_q10_render`. The
high-level trait keeps only the latest value from each source and one
replace-whole rendered image;
calibration, path placement and overlay placement remain inside the renderer.
"""

Expand All @@ -18,7 +20,7 @@
from typing import Any

from roborock.data import RoborockBase
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
from roborock.exceptions import RoborockException
from roborock.map.b01_q10_map_parser import (
Expand All @@ -32,8 +34,10 @@
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map

from .common import UpdatableTrait
from .status import StatusTrait

_LOGGER = logging.getLogger(__name__)
_DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN}


@dataclass
Expand Down Expand Up @@ -73,23 +77,29 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
class MapContentTrait(TraitUpdateListener):
"""High-level composed Q10 map view.

The latest map and trace packets are combined with the injected
:class:`MapDpsTrait` whenever any of those three sources changes.
The latest map and trace packets are combined with the injected map DPS and
status traits whenever any source changes.
"""

def __init__(
self,
map_dps: MapDpsTrait,
status: StatusTrait,
*,
map_parser_config: B01Q10MapParserConfig | None = None,
) -> None:
TraitUpdateListener.__init__(self, logger=_LOGGER)
self._config = map_parser_config or B01Q10MapParserConfig()
self._map_dps = map_dps
self._status = status
self._robot_at_dock = self._status.status in _DOCKED_STATES
self._map_packet: Q10MapPacket | None = None
self._trace_packet: Q10TracePacket | None = None
self._image_content: bytes | None = None
self._source_update_depth = 0
self._source_update_pending = False
self._map_dps.add_update_listener(self._map_dps_updated)
self._status.add_update_listener(self._status_updated)

@property
def image_content(self) -> bytes | None:
Expand Down Expand Up @@ -124,17 +134,52 @@ def update_from_map_packet(self, packet: Q10MapPacket) -> None:

def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
"""Store a trace-protocol update and render the latest sources."""
self._trace_packet = packet
# A late packet from the completed clean cannot move a robot that the
# status stream already confirmed is docked.
self._trace_packet = None if self._robot_at_dock else packet
self._render()
self._notify_update()

def _map_dps_updated(self) -> None:
"""Render after the low-level DPS source changes."""
if self._map_packet is None:
def _begin_source_update(self) -> None:
"""Defer dependent rendering while one DPS message is applied."""
self._source_update_depth += 1

def _end_source_update(self) -> None:
"""Render once after all traits consumed the same DPS message."""
self._source_update_depth -= 1
if self._source_update_depth == 0 and self._source_update_pending:
self._source_update_pending = False
self._render_and_notify()

def _source_updated(self, *, notify_without_map: bool = False) -> None:
"""Render now, or defer until the current DPS update is complete."""
if self._map_packet is None and not notify_without_map:
return
if self._source_update_depth:
self._source_update_pending = True
return
self._render_and_notify()

def _render_and_notify(self) -> None:
"""Recompose the current map and publish one update."""
self._render()
self._notify_update()

def _map_dps_updated(self) -> None:
"""Render after the low-level map DPS source changes."""
self._source_updated()

def _status_updated(self) -> None:
"""Render only when the status changes whether the robot is docked."""
robot_at_dock = self._status.status in _DOCKED_STATES
if robot_at_dock == self._robot_at_dock:
return
self._robot_at_dock = robot_at_dock
trace_cleared = robot_at_dock and self._trace_packet is not None
if robot_at_dock:
self._trace_packet = None
self._source_updated(notify_without_map=trace_cleared)

def _render(self) -> None:
"""Render the required map with the latest optional trace and overlays."""
if self._map_packet is None:
Expand All @@ -145,6 +190,7 @@ def _render(self) -> None:
self._trace_packet,
self._map_dps.overlays,
config=self._config,
robot_at_dock=self._robot_at_dock,
)
except RoborockException as ex:
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)
Expand Down
88 changes: 63 additions & 25 deletions roborock/map/b01_q10_map_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
"""

import colorsys
import io
import math
import statistics
from dataclasses import dataclass, field, replace

from PIL import Image
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
from vacuum_map_parser_base.config.image_config import ImageConfig
from vacuum_map_parser_base.map_data import ImageData, MapData
from vacuum_map_parser_base.map_data import ImageData, MapData, Point

from roborock.exceptions import RoborockException

Expand All @@ -40,6 +40,7 @@
decompose_grid,
)
from .map_parser import ParsedMapData
from .room_colors import adjacency_aware_room_colors

_MAP_FILE_FORMAT = "PNG"

Expand Down Expand Up @@ -93,15 +94,18 @@ def classify_q10_cell(value: int) -> str:
# 50 mm/px, so dividing by 10 yields the origin in grid pixels -- the (ox, oy)
# that solve_calibration otherwise recovers by sliding the path.
# - 15-16 resolution (u16be): reads 5 (= 0.05 m/px = 50 mm/px) universally.
# - 17-18 charger x, 19-20 charger y (s16be, 5 mm units), 21-22 charger phi.
# - 17-18 charger x, 19-20 charger y (s16be, absolute map decipixels),
# 21-22 charger phi. The official app's device-point transform confirms these
# coordinates are already in the map-array frame and must not receive x_min /
# y_min a second time.
_ORIGIN_X_OFFSET = 11
_ORIGIN_Y_OFFSET = 13
_HEADER_RESOLUTION_OFFSET = 15
_CHARGER_X_OFFSET = 17
_CHARGER_Y_OFFSET = 19
_CHARGER_PHI_OFFSET = 21
# The header origin/charger are in 5 mm units and the grid is 50 mm/px, so a
# header coordinate maps to grid pixels by dividing by this.
# The header origin and charger fields both divide by 10 to reach grid pixels,
# although they use different coordinate frames as documented above.
_HEADER_UNITS_PER_PIXEL = 10

# Grid cell values >= this are walls / borders rather than room segments.
Expand Down Expand Up @@ -129,7 +133,8 @@ class Q10EraseZone:

These are the app's *Erase* tool rectangles -- regions the user marked to be
removed from the map (e.g. phantom floor the lidar mapped through windows).
Coordinates are world units (millimetres), same frame as the path/zones.
Coordinates are 5 mm world units, the same frame as restriction zones.
Trace points use a separate 2.5 mm scale.

Confirmed by a controlled diff: removing the two erase zones on a live device
dropped this section's count from 2 to 0 while the grid and the trailing
Expand All @@ -148,9 +153,11 @@ class Q10HeaderCalibration:
straight from the map packet -- no cleaning path / fit required, so it works
docked or pre-clean. See :meth:`origin_pixels`.

``origin_x`` / ``origin_y`` and the charger coordinates are in 5 mm units;
``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is the
raw dock heading field. Reported and verified by @andrewlyeats (ss07).
``origin_x`` / ``origin_y`` are in 5 mm units and define the world-coordinate
origin. The charger coordinates are absolute decipixels in the map-array
frame, so they are divided by 10 without applying that origin again.
``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is
the raw dock heading field. Reported and verified by @andrewlyeats (ss07).
"""

origin_x: int
Expand All @@ -176,6 +183,15 @@ def origin_pixels(self) -> tuple[float, float] | None:
return None
return (self.origin_x / _HEADER_UNITS_PER_PIXEL, self.origin_y / _HEADER_UNITS_PER_PIXEL)

def charger_pixels(self) -> tuple[float, float] | None:
"""Return the saved dock in absolute map-array pixel coordinates."""
if (self.charger_x == 0 and self.charger_y == 0) or self.charger_x == -1 or self.charger_y == -1:
return None
return (
self.charger_x / _HEADER_UNITS_PER_PIXEL,
self.charger_y / _HEADER_UNITS_PER_PIXEL,
)


@dataclass
class Q10MapPacket:
Expand Down Expand Up @@ -639,7 +655,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
width=packet.width,
image_config=ImageConfig(scale=self._config.map_scale),
data=image,
img_transformation=lambda p: p,
# ImageDimensions uses V1's bottom-up map convention before
# projecting into the top-down PNG. Q10 points are already
# top-down grid pixels, so this adapter cancels that standard flip
# and lets Q10 use the shared V1 ImageGenerator without moving
# overlays vertically.
img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
)
room_names = {room.id: room.name for room in packet.rooms}
if room_names:
Expand All @@ -655,12 +676,12 @@ def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
return ParsedMapData(image_content=image_bytes.getvalue(), map_data=map_data)

def _render(self, packet: Q10MapPacket) -> Image.Image:
"""Render the Q10 grid: rooms get distinct colors, walls white, rest dark."""
palette = _build_palette(packet.grid)
rgb = bytearray()
"""Render the Q10 grid with the V1 map palette."""
palette = _build_palette(packet.grid, packet.width)
rgba = bytearray()
for value in packet.grid:
rgb.extend(palette[value])
img = Image.frombytes("RGB", (packet.width, packet.height), bytes(rgb))
rgba.extend(palette[value])
img = Image.frombytes("RGBA", (packet.width, packet.height), bytes(rgba))
# The ss07 grid is stored top-down (row 0 = top of the home), so it is
# rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
scale = self._config.map_scale
Expand All @@ -669,15 +690,32 @@ def _render(self, packet: Q10MapPacket) -> Image.Image:
return img


def _build_palette(grid: bytes) -> list[tuple[int, int, int]]:
"""Map each grid value to an RGB color (rooms distinct, walls white)."""
palette: list[tuple[int, int, int]] = [(28, 30, 38)] * 256 # default: unknown/outside
room_values = sorted({v for v in set(grid) if 0 < v < _WALL_THRESHOLD})
for index, value in enumerate(room_values):
hue = (index * 0.139) % 1.0
r, g, b = colorsys.hsv_to_rgb(hue, 0.5, 0.95)
palette[value] = (int(r * 255), int(g * 255), int(b * 255))
def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
"""Return a palette color as RGBA."""
return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)


def _build_palette(grid: bytes, width: int) -> list[tuple[int, int, int, int]]:
"""Map Q10 cells onto the same colors used by the V1 map renderer."""

def room_id(value: int) -> int | None:
return max(1, value // 4) if 0 < value < _WALL_THRESHOLD else None

colors = ColorsPalette()
room_colors = adjacency_aware_room_colors(
grid,
width,
colors,
room_id,
)
outside = (0, 0, 0, 0)
palette = [outside] * 256
for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
palette[value] = _opaque(room_colors[max(1, value // 4)])
wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
for value in range(_WALL_THRESHOLD, 256):
palette[value] = (235, 235, 240) # walls / borders
palette[0] = (28, 30, 38)
palette[value] = wall
palette[_UNSEGMENTED_FLOOR_VALUE] = _opaque(colors.get_color(SupportedColor.MAP_INSIDE))
palette[_BACKGROUND_VALUE] = outside
palette[0] = outside
return palette
11 changes: 6 additions & 5 deletions roborock/map/b01_q10_overlays.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@
zones (first wire word = x), confirmed against the app. Provenance and the
byte-level breakdown are in PR #850's review thread.

Coordinates are in the device's world units (the same space as the cleaning
path), so a :class:`~roborock.map.b01_grid_layers.GridCalibration` maps them to
map pixels. ``type`` distinguishes the restriction kind (2 = no-mop, 3 = door
threshold, anything else -- incl. 0 -- a no-go zone); it is preserved verbatim
so callers can route polygons to the right ``MapData`` layer.
Coordinates are in 5 mm device world units, the same space as erase polygons.
Cleaning trace points use a separate 2.5 mm scale, so callers must not project
both through the same resolution. ``type`` distinguishes the restriction kind
(2 = no-mop, 3 = door threshold, anything else -- incl. 0 -- a no-go zone); it
is preserved verbatim so callers can route polygons to the right ``MapData``
layer.
"""

import base64
Expand Down
Loading
Loading