Skip to content

Commit b99394d

Browse files
authored
Merge branch 'main' into main
2 parents c51ddb0 + 6fa73d7 commit b99394d

27 files changed

Lines changed: 750 additions & 52 deletions

CHANGELOG.md

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

33
<!-- version list -->
44

5+
## v5.10.1 (2026-05-12)
6+
7+
### Bug Fixes
8+
9+
- Handle Web API unauthorized errors
10+
([#825](https://github.com/Python-roborock/python-roborock/pull/825),
11+
[`ad8d8f0`](https://github.com/Python-roborock/python-roborock/commit/ad8d8f095a260ee720c3e0193bcba5bd691c9f96))
12+
13+
- Mark non vacuum v1 devices as not supported
14+
([#828](https://github.com/Python-roborock/python-roborock/pull/828),
15+
[`2e9a848`](https://github.com/Python-roborock/python-roborock/commit/2e9a84807f5d0c2cffb5b0b6592b9baa6ee14c64))
16+
17+
18+
## v5.10.0 (2026-05-03)
19+
20+
### Features
21+
22+
- Implement direct device trait updates from data protocol messages using `dps` metadata and add
23+
corresponding update listeners
24+
([#799](https://github.com/Python-roborock/python-roborock/pull/799),
25+
[`ba57677`](https://github.com/Python-roborock/python-roborock/commit/ba576778bb51f7e381e16ec93ff218d4a898e009))
26+
27+
28+
## v5.9.1 (2026-05-02)
29+
30+
### Bug Fixes
31+
32+
- Fix operator precedence bug with walrus operator in cli.py command execution
33+
([#822](https://github.com/Python-roborock/python-roborock/pull/822),
34+
[`c3ae98b`](https://github.com/Python-roborock/python-roborock/commit/c3ae98b9ff7acadb97d4d5be8e3409d9fa8ed2a5))
35+
36+
37+
## v5.9.0 (2026-04-29)
38+
39+
### Chores
40+
41+
- Address review feedback for dock_state
42+
([#821](https://github.com/Python-roborock/python-roborock/pull/821),
43+
[`3fdb963`](https://github.com/Python-roborock/python-roborock/commit/3fdb963401c8e81a39faf9ce5c11e9ecec303d91))
44+
45+
### Features
46+
47+
- Implement RoborockDockState synthesis and RoborockChargeStatus enum
48+
([#821](https://github.com/Python-roborock/python-roborock/pull/821),
49+
[`3fdb963`](https://github.com/Python-roborock/python-roborock/commit/3fdb963401c8e81a39faf9ce5c11e9ecec303d91))
50+
51+
- Implement RoborockDockState synthesis and RoborockChargeStatus enum for improved device status
52+
reporting ([#821](https://github.com/Python-roborock/python-roborock/pull/821),
53+
[`3fdb963`](https://github.com/Python-roborock/python-roborock/commit/3fdb963401c8e81a39faf9ce5c11e9ecec303d91))
54+
55+
### Refactoring
56+
57+
- Centralize trait update listener and dps converter
58+
([#820](https://github.com/Python-roborock/python-roborock/pull/820),
59+
[`d125afb`](https://github.com/Python-roborock/python-roborock/commit/d125afbd53b03e883e539bddc28faef836be8cb9))
60+
61+
562
## v5.8.0 (2026-04-26)
663

764
### Features

docs/DEVICES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ graph LR
357357
1. **Temporary Subscriptions**: Each RPC creates a temporary subscription that matches the request ID
358358
2. **Subscription Reuse**: `MqttSession` keeps subscriptions alive for 60 seconds (or idle timeout) to enable reuse during command bursts
359359
3. **Timeout Handling**: Commands timeout after 10 seconds if no response is received
360-
4. **Multiple Strategies**: `V1Channel` tries local first, then falls back to MQTT if local fails
360+
4. **Multiple Strategies**: `V1Channel` tries connect to both Local faster local commands and MQTT for streaming updates.
361361

362362
## Class Design & Components
363363

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.8.0"
3+
version = "5.10.1"
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/callbacks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def wrapper(value: V) -> None:
2626
try:
2727
callback(value)
2828
except Exception as ex: # noqa: BLE001
29-
logger.error("Uncaught error in callback '%s': %s", callback.__name__, ex)
29+
logger.error("Uncaught error in callback '%s': %s", getattr(callback, "__name__", "Unknown"), ex)
3030

3131
return wrapper
3232

roborock/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ async def _v1_trait(context: RoborockContext, device_id: str, display_func: Call
419419
device = await device_manager.get_device(device_id)
420420
if device.v1_properties is None:
421421
raise RoborockUnsupportedFeature(f"Device {device.name} does not support V1 protocol")
422-
await device.v1_properties.discover_features()
422+
await device.v1_properties.start()
423423
trait = display_func(device.v1_properties)
424424
if trait is None:
425425
raise RoborockUnsupportedFeature("Trait not supported by device")
@@ -780,7 +780,7 @@ async def command(ctx, cmd, device_id, params):
780780
if result:
781781
click.echo(dump_json(result))
782782
elif device.b01_q10_properties is not None:
783-
if cmd_value := B01_Q10_DP.from_any_optional(cmd) is None:
783+
if (cmd_value := B01_Q10_DP.from_any_optional(cmd)) is None:
784784
raise RoborockException(f"Invalid command {cmd} for B01_Q10 device")
785785
command_trait: Trait = device.b01_q10_properties.command
786786
await command_trait.send(cmd_value, json.loads(params) if params is not None else None)

roborock/data/v1/v1_code_mappings.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import StrEnum
12
from typing import Self
23

34
from ..code_mappings import RoborockEnum
@@ -63,6 +64,60 @@ class RoborockCleanType(RoborockEnum):
6364
pet_patrol = 6
6465

6566

67+
class RoborockChargeStatus(RoborockEnum):
68+
"""Describes the charging status of the device."""
69+
70+
unknown = -1
71+
charge_waiting = 0
72+
charging = 1
73+
74+
75+
class RoborockDockState(StrEnum):
76+
"""Synthesized high-level dock and power state of the device.
77+
78+
This enum represents a unified "UI-level" state that combines multiple raw
79+
device data points (`state`, `charge_status`, `battery`) into a single,
80+
human-readable status that accurately reflects what the vacuum is doing
81+
relative to the dock.
82+
83+
It is highly recommended for consumers of this API
84+
to use this synthesized state to determine if the vacuum is charging or
85+
docked, rather than attempting to parse the raw integer data points, as
86+
this safely handles backward compatibility for older models that lack
87+
explicit off-peak schedule reporting.
88+
"""
89+
90+
unknown = "unknown"
91+
"""The dock state could not be determined or is unmapped."""
92+
93+
idle = "idle"
94+
"""The vacuum is away from the dock (e.g., cleaning, paused, or errored).
95+
In the official app, this state presents the 'Return to Dock' or 'Recharge' action."""
96+
97+
returning = "returning"
98+
"""The vacuum is actively navigating its way back to the dock.
99+
In the official app, this state presents the 'Stop' or 'Pause' action."""
100+
101+
charging = "charging"
102+
"""The vacuum is on the dock and actively receiving electricity.
103+
In the official app, this state is displayed as 'Charging'."""
104+
105+
off_peak_waiting = "off_peak_waiting"
106+
"""The vacuum is on the dock but charging is paused. It is waiting for the
107+
user's scheduled 'Valley Electricity' off-peak hours to begin before
108+
drawing power.
109+
In the official app, this state is displayed as 'Charging paused during peak hours'."""
110+
111+
full = "full"
112+
"""The vacuum is on the dock and the battery is at 100% capacity.
113+
In the official app, this state is displayed as 'Fully charged'."""
114+
115+
dusting = "dusting"
116+
"""The vacuum is on the dock and is currently being evacuated by the
117+
auto-empty base.
118+
In the official app, this state is displayed as 'Emptying dustbin'."""
119+
120+
66121
class RoborockStartType(RoborockEnum):
67122
button = 1
68123
app = 2

roborock/data/v1/v1_containers.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
ClearWaterBoxStatus,
4747
DirtyWaterBoxStatus,
4848
DustBagStatus,
49+
RoborockChargeStatus,
4950
RoborockCleanType,
5051
RoborockDockDustCollectionModeCode,
5152
RoborockDockErrorCode,
53+
RoborockDockState,
5254
RoborockDockTypeCode,
5355
RoborockErrorCode,
5456
RoborockFanPowerCode,
@@ -98,13 +100,14 @@ class FieldNameBase(StrEnum):
98100

99101

100102
class StatusField(FieldNameBase):
101-
"""An enum that represents a field in the `Status` class.
103+
"""An enum that represents a field in the `StatusV2` class.
102104
103105
This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait`
104106
to understand if a feature is supported by the device using `is_field_supported`.
105107
106-
The enum values are names of fields in the `Status` class. Each field is annotated
107-
with a metadata value to determine if the field is supported by the device.
108+
The enum values are names of fields in the `StatusV2` class. Each field is
109+
annotated with `dps` metadata to map the field to a `RoborockDataProtocol`
110+
value used to check support against the product schema.
108111
"""
109112

110113
STATE = "state"
@@ -161,7 +164,9 @@ class Status(RoborockBase):
161164
collision_avoid_status: int | None = None
162165
switch_map_mode: int | None = None
163166
dock_error_status: RoborockDockErrorCode | None = None
164-
charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS})
167+
charge_status: RoborockChargeStatus | None = field(
168+
default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}
169+
)
165170
unsave_map_reason: int | None = None
166171
unsave_map_flag: int | None = None
167172
wash_status: int | None = None
@@ -329,7 +334,9 @@ class StatusV2(RoborockBase):
329334
collision_avoid_status: int | None = None
330335
switch_map_mode: int | None = None
331336
dock_error_status: RoborockDockErrorCode | None = None
332-
charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS})
337+
charge_status: RoborockChargeStatus | None = field(
338+
default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}
339+
)
333340
unsave_map_reason: int | None = None
334341
unsave_map_flag: int | None = None
335342
wash_status: int | None = None
@@ -414,6 +421,41 @@ def dock_cool_fan_status(self) -> int | None:
414421
return (self.dss >> 15) & 3
415422
return None
416423

424+
@property
425+
def dock_state(self) -> RoborockDockState:
426+
"""A synthesized, high-level dock state reflecting the UI's display.
427+
428+
This property simplifies integration by handling the complex logic
429+
of checking state, charge_status, and battery level simultaneously. It handles
430+
newer off-peak charging logic seamlessly while maintaining backwards compatibility
431+
with older devices.
432+
"""
433+
if self.state is None or self.state == RoborockStateCode.unknown:
434+
return RoborockDockState.unknown
435+
436+
# 6. DUSTING
437+
if self.state == RoborockStateCode.emptying_the_bin:
438+
return RoborockDockState.dusting
439+
440+
# 5. FULL
441+
if self.state == RoborockStateCode.charging_complete or (
442+
self.state == RoborockStateCode.charging and self.battery == 100
443+
):
444+
return RoborockDockState.full
445+
446+
# 3 & 4. CHARGING and CHARGE_WAITING
447+
if self.state == RoborockStateCode.charging:
448+
if self.charge_status == RoborockChargeStatus.charge_waiting:
449+
return RoborockDockState.off_peak_waiting
450+
return RoborockDockState.charging
451+
452+
# 2. RECHARGING
453+
if self.state in (RoborockStateCode.returning_home, RoborockStateCode.docking):
454+
return RoborockDockState.returning
455+
456+
# 1. IDLE (Not on dock, or doing something else)
457+
return RoborockDockState.idle
458+
417459
def __repr__(self) -> str:
418460
return _attr_repr(self)
419461

@@ -629,8 +671,9 @@ class ConsumableField(FieldNameBase):
629671
This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait`
630672
to understand if a feature is supported by the device using `is_field_supported`.
631673
632-
The enum values are names of fields in the `Consumable` class. Each field is annotated
633-
with a metadata value to determine if the field is supported by the device.
674+
The enum values are names of fields in the `Consumable` class. Each field is
675+
annotated with `dps` metadata to map the field to a `RoborockDataProtocol`
676+
value used to check support against the product schema.
634677
"""
635678

636679
MAIN_BRUSH_WORK_TIME = "main_brush_work_time"

roborock/devices/device.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ async def connect(self) -> None:
199199
unsub = await self._channel.subscribe(self._on_message)
200200
try:
201201
if self.v1_properties is not None:
202-
await self.v1_properties.discover_features()
202+
await self.v1_properties.start()
203203
elif self.b01_q10_properties is not None:
204204
await self.b01_q10_properties.start()
205205
except RoborockException:
@@ -216,6 +216,8 @@ async def close(self) -> None:
216216
await self._connect_task
217217
except asyncio.CancelledError:
218218
pass
219+
if self.v1_properties is not None:
220+
self.v1_properties.close()
219221
if self.b01_q10_properties is not None:
220222
await self.b01_q10_properties.close()
221223
if self._unsub:

roborock/devices/device_manager.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
HomeData,
1414
HomeDataDevice,
1515
HomeDataProduct,
16+
RoborockCategory,
1617
UserData,
1718
)
1819
from roborock.devices.device import DeviceReadyCallback, RoborockDevice
@@ -173,14 +174,15 @@ def create_web_api_wrapper(
173174
*,
174175
cache: Cache | None = None,
175176
session: aiohttp.ClientSession | None = None,
177+
unauthorized_hook: SessionUnauthorizedHook | None = None,
176178
) -> UserWebApiClient:
177179
"""Create a home data API wrapper from an existing API client."""
178180

179181
# Note: This will auto discover the API base URL. This can be improved
180182
# by caching this next to `UserData` if needed to avoid unnecessary API calls.
181183
client = RoborockApiClient(username=user_params.username, base_url=user_params.base_url, session=session)
182184

183-
return UserWebApiClient(client, user_params.user_data)
185+
return UserWebApiClient(client, user_params.user_data, unauthorized_hook=unauthorized_hook)
184186

185187

186188
async def create_device_manager(
@@ -212,7 +214,9 @@ async def create_device_manager(
212214
if cache is None:
213215
cache = NoCache()
214216

215-
web_api = create_web_api_wrapper(user_params, session=session, cache=cache)
217+
web_api = create_web_api_wrapper(
218+
user_params, session=session, cache=cache, unauthorized_hook=mqtt_session_unauthorized_hook
219+
)
216220
user_data = user_params.user_data
217221

218222
diagnostics = Diagnostics()
@@ -228,6 +232,10 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
228232
device_cache: DeviceCache = DeviceCache(device.duid, cache)
229233
match device.pv:
230234
case DeviceVersion.V1:
235+
if product.category != RoborockCategory.VACUUM:
236+
raise UnsupportedDeviceError(
237+
f"Device {device.name} has unsupported V1 category {product.category}: {product.model}"
238+
)
231239
channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
232240
trait = v1.create(
233241
device.duid,
@@ -236,6 +244,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
236244
channel.rpc_channel,
237245
channel.mqtt_rpc_channel,
238246
channel.map_rpc_channel,
247+
channel.add_dps_listener,
239248
web_api,
240249
device_cache=device_cache,
241250
map_parser_config=map_parser_config,
@@ -264,6 +273,12 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
264273
dev.add_ready_callback(ready_callback)
265274
return dev
266275

267-
manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache, diagnostics=diagnostics)
276+
manager = DeviceManager(
277+
web_api,
278+
device_creator,
279+
mqtt_session=mqtt_session,
280+
cache=cache,
281+
diagnostics=diagnostics,
282+
)
268283
await manager.discover_devices(prefer_cache)
269284
return manager

0 commit comments

Comments
 (0)