Skip to content

Commit 698b11b

Browse files
authored
Merge branch 'main' into pr/zeo-push
2 parents ee78f59 + 3c5f788 commit 698b11b

15 files changed

Lines changed: 389 additions & 113 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@
22

33
<!-- version list -->
44

5+
## v5.36.0 (2026-07-21)
6+
7+
### Features
8+
9+
- Add MQTT QoS support and timestamp to A01 protocol payload
10+
([#891](https://github.com/Python-roborock/python-roborock/pull/891),
11+
[`3deed81`](https://github.com/Python-roborock/python-roborock/commit/3deed815a886ee6844054abfad372886a536d681))
12+
13+
14+
## v5.35.0 (2026-07-21)
15+
16+
### Features
17+
18+
- Compose Q10 map content from grouped traits
19+
([#887](https://github.com/Python-roborock/python-roborock/pull/887),
20+
[`a66e7c3`](https://github.com/Python-roborock/python-roborock/commit/a66e7c37dadf50d472b7d24c4a0c960c0efea771))
21+
22+
### Refactoring
23+
24+
- Simplify Q10 map trait state ([#887](https://github.com/Python-roborock/python-roborock/pull/887),
25+
[`a66e7c3`](https://github.com/Python-roborock/python-roborock/commit/a66e7c37dadf50d472b7d24c4a0c960c0efea771))
26+
27+
- Tighten Q10 map trait lifecycle
28+
([#887](https://github.com/Python-roborock/python-roborock/pull/887),
29+
[`a66e7c3`](https://github.com/Python-roborock/python-roborock/commit/a66e7c37dadf50d472b7d24c4a0c960c0efea771))
30+
31+
532
## v5.34.0 (2026-07-20)
633

734
### Chores

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-roborock"
3-
version = "5.34.0"
3+
version = "5.36.0"
44
description = "A package to control Roborock vacuums."
55
authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
66
requires-python = ">=3.11, <4"

roborock/devices/rpc/a01_channel.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from roborock.devices.transport.mqtt_channel import MqttChannel
99
from roborock.exceptions import RoborockException
10+
from roborock.mqtt.session import MqttQos
1011
from roborock.protocols.a01_protocol import (
1112
decode_rpc_response,
1213
encode_mqtt_payload,
@@ -30,6 +31,7 @@ async def send_decoded_command(
3031
mqtt_channel: MqttChannel,
3132
params: dict[RoborockDyadDataProtocol, Any],
3233
value_encoder: Callable[[Any], Any] | None = None,
34+
qos: MqttQos = MqttQos.AT_MOST_ONCE,
3335
) -> dict[RoborockDyadDataProtocol, Any]: ...
3436

3537

@@ -38,23 +40,32 @@ async def send_decoded_command(
3840
mqtt_channel: MqttChannel,
3941
params: dict[RoborockZeoProtocol, Any],
4042
value_encoder: Callable[[Any], Any] | None = None,
43+
qos: MqttQos = MqttQos.AT_MOST_ONCE,
4144
) -> dict[RoborockZeoProtocol, Any]: ...
4245

4346

4447
async def send_decoded_command(
4548
mqtt_channel: MqttChannel,
4649
params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any],
4750
value_encoder: Callable[[Any], Any] | None = None,
51+
qos: MqttQos = MqttQos.AT_MOST_ONCE,
4852
) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]:
49-
"""Send a command on the MQTT channel and get a decoded response."""
53+
"""Send a command on the MQTT channel and get a decoded response.
54+
55+
Args:
56+
mqtt_channel: The MQTT channel to send the command on.
57+
params: The parameters to send.
58+
value_encoder: A function to encode the values of the dictionary.
59+
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
60+
"""
5061
_LOGGER.debug("Sending MQTT command: %s", params)
5162
roborock_message = encode_mqtt_payload(params, value_encoder)
5263

5364
# For commands that set values: send the command and do not
5465
# block waiting for a response. Queries are handled below.
5566
param_values = {int(k): v for k, v in params.items()}
5667
if not (query_values := param_values.get(_ID_QUERY)):
57-
await mqtt_channel.publish(roborock_message)
68+
await mqtt_channel.publish(roborock_message, qos=qos)
5869
return {}
5970

6071
# Merge any results together than contain the requested data. This

roborock/devices/traits/b01/q10/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from .consumable import ConsumableTrait
1717
from .do_not_disturb import DoNotDisturbTrait
1818
from .dust_collection import DustCollectionTrait
19-
from .map import MapContentTrait
19+
from .map import MapContentTrait, MapDpsTrait
2020
from .network_info import NetworkInfoTrait
2121
from .remote import RemoteTrait
2222
from .status import StatusTrait
@@ -77,7 +77,10 @@ class Q10PropertiesApi(Trait):
7777
"""Trait exposing remaining life of consumables."""
7878

7979
map: MapContentTrait
80-
"""Trait for fetching the current parsed map (image + rooms)."""
80+
"""Composed map image plus caller-facing map and trace data."""
81+
82+
_map_dps: MapDpsTrait
83+
"""Private source of restricted zones and virtual walls received through DPS."""
8184

8285
clean_history: CleanHistoryTrait
8386
"""Trait for fetching the device clean-record history (``dpCleanRecord``)."""
@@ -96,7 +99,8 @@ def __init__(self, channel: B01Q10Channel) -> None:
9699
self.button_light = ButtonLightTrait(self.command)
97100
self.network_info = NetworkInfoTrait()
98101
self.consumable = ConsumableTrait()
99-
self.map = MapContentTrait()
102+
self._map_dps = MapDpsTrait()
103+
self.map = MapContentTrait(self._map_dps)
100104
self.clean_history = CleanHistoryTrait(self.command)
101105
# Read-model traits updated from the device's DPS push stream.
102106
self._updatable_traits = [
@@ -108,6 +112,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
108112
self.network_info,
109113
self.consumable,
110114
self.clean_history,
115+
self._map_dps,
111116
]
112117
self._subscribe_task: asyncio.Task[None] | None = None
113118

Lines changed: 109 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,151 @@
1-
"""Map content trait for B01 Q10 devices.
2-
3-
Unlike the v1 / Q7 maps, the Q10 has no synchronous "get map" command, so this
4-
trait is purely push-driven and mirrors the Q10 ``StatusTrait`` contract:
5-
6-
- The device pushes its current map/path as protocol-301 ``MAP_RESPONSE``
7-
messages (a ``dpRequestDps`` nudges it to do so). The protocol layer decodes
8-
those into :class:`Q10MapPacket` / :class:`Q10TracePacket` objects and the
9-
``Q10PropertiesApi`` subscribe loop routes them to
10-
:meth:`MapContentTrait.update_from_map_packet` /
11-
:meth:`MapContentTrait.update_from_trace_packet`.
12-
- Those methods render/cache the content and notify update listeners (register
13-
via :meth:`add_update_listener`).
14-
- ``image_content``, ``map_data``, ``rooms``, ``path`` and ``robot_position``
15-
are readable and reflect the most recently pushed map.
16-
17-
Unlike the Q7, the Q10 map payload is unencrypted, so no map key is required.
1+
"""Push-driven map traits for B01 Q10 devices.
2+
3+
Map-related state arrives on three independent streams:
4+
5+
* map packets are decoded from map-protocol responses;
6+
* trace packets are decoded from trace-protocol responses;
7+
* restricted zones and virtual walls arrive as ordinary DPS values.
8+
9+
``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` depends
10+
on it and combines that state with the latest map/trace packets through the pure
11+
functions in :mod:`roborock.map.b01_q10_render`. The high-level trait keeps only
12+
the latest value from each source and one replace-whole rendered image;
13+
calibration, path placement and overlay placement remain inside the renderer.
1814
"""
1915

2016
import logging
2117
from dataclasses import dataclass, field
22-
23-
from vacuum_map_parser_base.map_data import MapData
18+
from typing import Any
2419

2520
from roborock.data import RoborockBase
26-
from roborock.devices.traits.common import TraitUpdateListener
21+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
22+
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
23+
from roborock.exceptions import RoborockException
2724
from roborock.map.b01_q10_map_parser import (
28-
B01Q10MapParser,
2925
B01Q10MapParserConfig,
3026
Q10MapPacket,
3127
Q10Point,
3228
Q10Room,
3329
Q10TracePacket,
3430
)
31+
from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob
32+
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
3533

36-
_LOGGER = logging.getLogger(__name__)
34+
from .common import UpdatableTrait
3735

38-
_TRUNCATE_LENGTH = 20
36+
_LOGGER = logging.getLogger(__name__)
3937

4038

4139
@dataclass
42-
class MapContent(RoborockBase):
43-
"""Dataclass representing Q10 map content."""
40+
class MapDps(RoborockBase):
41+
"""Low-level map values delivered in the Q10 DPS stream."""
4442

45-
image_content: bytes | None = None
46-
"""The rendered image of the map in PNG format."""
43+
restricted_zone_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.RESTRICTED_ZONE_UP})
44+
virtual_wall_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.VIRTUAL_WALL_UP})
4745

48-
map_data: MapData | None = None
49-
"""Parsed map data (image metadata + room names)."""
5046

51-
rooms: list[Q10Room] = field(default_factory=list)
52-
"""Rooms (segments) reported by the device, with ids and names."""
47+
class MapDpsTrait(MapDps, UpdatableTrait):
48+
"""Private read model for map-related DPS values and decoded overlays."""
5349

54-
path: list[Q10Point] = field(default_factory=list)
55-
"""Full path of the current cleaning session (oldest point first).
50+
_CONVERTER = DpsDataConverter.from_dataclass(MapDps)
5651

57-
The robot accumulates this server-side and serves the whole trajectory so
58-
far in one packet, so it is complete even if we connect mid-session. Only
59-
populated while a cleaning session is active."""
52+
def __init__(self) -> None:
53+
MapDps.__init__(self)
54+
UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
55+
self._overlays = Q10MapOverlays()
6056

61-
robot_position: Q10Point | None = None
62-
"""Current robot position (the most recent path point), if known."""
57+
@property
58+
def overlays(self) -> Q10MapOverlays:
59+
"""Overlays decoded once from the latest relevant DPS update."""
60+
return self._overlays
6361

64-
def __repr__(self) -> str:
65-
img = self.image_content
66-
if img and len(img) > _TRUNCATE_LENGTH:
67-
img = img[: _TRUNCATE_LENGTH - 3] + b"..."
68-
return f"MapContent(image_content={img!r}, rooms={self.rooms!r})"
62+
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
63+
"""Decode overlay blobs when they arrive, then notify dependents."""
64+
if not self._CONVERTER.update_from_dps(self, decoded_dps):
65+
return
66+
self._overlays = Q10MapOverlays(
67+
zones=tuple(parse_zone_blob(self.restricted_zone_up)),
68+
virtual_walls=tuple(parse_virtual_wall_blob(self.virtual_wall_up)),
69+
)
70+
self._notify_update()
6971

7072

71-
class MapContentTrait(MapContent, TraitUpdateListener):
72-
"""Trait holding the most recently pushed parsed map content for Q10 devices.
73+
class MapContentTrait(TraitUpdateListener):
74+
"""High-level composed Q10 map view.
7375
74-
The Q10 has no synchronous get-map request; the device pushes map and trace
75-
packets, which the protocol layer decodes and the ``Q10PropertiesApi``
76-
subscribe loop feeds into :meth:`update_from_map_packet` /
77-
:meth:`update_from_trace_packet`. Consumers read the cached fields and/or
78-
register a callback with :meth:`add_update_listener` to be notified when new
79-
map content arrives.
76+
The latest map and trace packets are combined with the injected
77+
:class:`MapDpsTrait` whenever any of those three sources changes.
8078
"""
8179

8280
def __init__(
8381
self,
82+
map_dps: MapDpsTrait,
8483
*,
8584
map_parser_config: B01Q10MapParserConfig | None = None,
8685
) -> None:
87-
super().__init__()
8886
TraitUpdateListener.__init__(self, logger=_LOGGER)
89-
self._map_parser = B01Q10MapParser(map_parser_config)
87+
self._config = map_parser_config or B01Q10MapParserConfig()
88+
self._map_dps = map_dps
89+
self._map_packet: Q10MapPacket | None = None
90+
self._trace_packet: Q10TracePacket | None = None
91+
self._image_content: bytes | None = None
92+
self._map_dps.add_update_listener(self._map_dps_updated)
93+
94+
@property
95+
def image_content(self) -> bytes | None:
96+
"""The composed map PNG, if the latest map rendered successfully."""
97+
return self._image_content
98+
99+
@property
100+
def rooms(self) -> list[Q10Room]:
101+
"""Rooms reported by the device."""
102+
return self._map_packet.rooms if self._map_packet else []
103+
104+
@property
105+
def path(self) -> list[Q10Point]:
106+
"""Full path for live status and callers drawing their own map overlay."""
107+
return self._trace_packet.points if self._trace_packet else []
108+
109+
@property
110+
def robot_position(self) -> Q10Point | None:
111+
"""Current position for live status and caller-rendered map overlays."""
112+
return self._trace_packet.robot_position if self._trace_packet else None
113+
114+
@property
115+
def robot_heading(self) -> int | None:
116+
"""Current heading for orienting a robot marker on a caller-rendered map."""
117+
return self._trace_packet.heading if self._trace_packet else None
90118

91119
def update_from_map_packet(self, packet: Q10MapPacket) -> None:
92-
"""Render a pushed full-map packet into the cached image/rooms.
93-
94-
Rendering failures are logged and skipped (listeners are not notified) so
95-
a single bad push cannot tear down the subscribe loop.
96-
"""
97-
parsed = self._map_parser.parse_packet(packet)
98-
if parsed.image_content is None:
99-
_LOGGER.debug("Failed to render Q10 map image")
100-
return
101-
self.image_content = parsed.image_content
102-
self.map_data = parsed.map_data
103-
self.rooms = packet.rooms
120+
"""Store a map-protocol update and render the latest sources."""
121+
self._map_packet = packet
122+
self._render()
104123
self._notify_update()
105124

106125
def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
107-
"""Cache the path/robot position from a pushed trace packet."""
108-
self.path = packet.points
109-
self.robot_position = packet.robot_position
126+
"""Store a trace-protocol update and render the latest sources."""
127+
self._trace_packet = packet
128+
self._render()
110129
self._notify_update()
130+
131+
def _map_dps_updated(self) -> None:
132+
"""Render after the low-level DPS source changes."""
133+
if self._map_packet is None:
134+
return
135+
self._render()
136+
self._notify_update()
137+
138+
def _render(self) -> None:
139+
"""Render the required map with the latest optional trace and overlays."""
140+
if self._map_packet is None:
141+
return
142+
try:
143+
self._image_content = render_q10_map(
144+
self._map_packet,
145+
self._trace_packet,
146+
self._map_dps.overlays,
147+
config=self._config,
148+
)
149+
except RoborockException as ex:
150+
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)
151+
self._image_content = None

roborock/devices/transport/mqtt_channel.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from roborock.data import HomeDataDevice, RRiot, UserData
99
from roborock.exceptions import RoborockException
1010
from roborock.mqtt.health_manager import HealthManager
11-
from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException
11+
from roborock.mqtt.session import MqttParams, MqttQos, MqttSession, MqttSessionException
1212
from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
1313
from roborock.roborock_message import RoborockMessage
1414
from roborock.util import RoborockLoggerAdapter
@@ -89,19 +89,23 @@ async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]:
8989
finally:
9090
unsub()
9191

92-
async def publish(self, message: RoborockMessage) -> None:
92+
async def publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
9393
"""Publish a command message.
9494
9595
The caller is responsible for handling any responses and associating them
9696
with the incoming request.
97+
98+
Args:
99+
message: The message to publish.
100+
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
97101
"""
98102
try:
99103
encoded_msg = self._encoder(message)
100104
except Exception as e:
101105
self._logger.exception("Error encoding MQTT message: %s", e)
102106
raise RoborockException(f"Failed to encode MQTT message: {e}") from e
103107
try:
104-
return await self._mqtt_session.publish(self._publish_topic, encoded_msg)
108+
return await self._mqtt_session.publish(self._publish_topic, encoded_msg, qos=qos)
105109
except MqttSessionException as e:
106110
self._logger.debug("Error publishing MQTT message: %s", e)
107111
raise RoborockException(f"Failed to publish MQTT message: {e}") from e

0 commit comments

Comments
 (0)