Skip to content

Commit 1fb2853

Browse files
authored
Merge branch 'main' into home-data-cache
2 parents f8115ce + ecde2a3 commit 1fb2853

File tree

22 files changed

+655
-103
lines changed

22 files changed

+655
-103
lines changed

CHANGELOG.md

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

33
<!-- version list -->
44

5+
## v3.16.0 (2025-12-14)
6+
7+
### Bug Fixes
8+
9+
- Fix bugs in the subscription idle timeout
10+
([#665](https://github.com/Python-roborock/python-roborock/pull/665),
11+
[`85b7bee`](https://github.com/Python-roborock/python-roborock/commit/85b7beeb810cfb3d501658cd44f06b2c0052ca33))
12+
13+
- Harden the device connection logic used in startup
14+
([#666](https://github.com/Python-roborock/python-roborock/pull/666),
15+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
16+
17+
- Harden the initial startup logic
18+
([#666](https://github.com/Python-roborock/python-roborock/pull/666),
19+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
20+
21+
### Chores
22+
23+
- Apply suggestions from code review
24+
([#675](https://github.com/Python-roborock/python-roborock/pull/675),
25+
[`ab2de5b`](https://github.com/Python-roborock/python-roborock/commit/ab2de5bda7b8e1ff1ad46c7f2bf3b39dc9af4ace))
26+
27+
- Clarify comments and docstrings
28+
([#666](https://github.com/Python-roborock/python-roborock/pull/666),
29+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
30+
31+
- Fix logging ([#666](https://github.com/Python-roborock/python-roborock/pull/666),
32+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
33+
34+
- Reduce whitespace changes ([#666](https://github.com/Python-roborock/python-roborock/pull/666),
35+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
36+
37+
- Revert whitespace change ([#666](https://github.com/Python-roborock/python-roborock/pull/666),
38+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
39+
40+
### Features
41+
42+
- Add basic schedule getting ([#675](https://github.com/Python-roborock/python-roborock/pull/675),
43+
[`ab2de5b`](https://github.com/Python-roborock/python-roborock/commit/ab2de5bda7b8e1ff1ad46c7f2bf3b39dc9af4ace))
44+
45+
46+
## v3.15.0 (2025-12-14)
47+
48+
### Chores
49+
50+
- Address some comments ([#662](https://github.com/Python-roborock/python-roborock/pull/662),
51+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
52+
53+
- Apply suggestions from code review
54+
([#662](https://github.com/Python-roborock/python-roborock/pull/662),
55+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
56+
57+
- Fix test naming ([#662](https://github.com/Python-roborock/python-roborock/pull/662),
58+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
59+
60+
- Small tweaks ([#662](https://github.com/Python-roborock/python-roborock/pull/662),
61+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
62+
63+
- Update roborock/devices/b01_channel.py
64+
([#662](https://github.com/Python-roborock/python-roborock/pull/662),
65+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
66+
67+
- Update snapshot ([#662](https://github.com/Python-roborock/python-roborock/pull/662),
68+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
69+
70+
### Features
71+
72+
- Add b01 Q7 basic getter support
73+
([#662](https://github.com/Python-roborock/python-roborock/pull/662),
74+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
75+
76+
- Add b01 Q7 support ([#662](https://github.com/Python-roborock/python-roborock/pull/662),
77+
[`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317))
78+
79+
580
## v3.14.3 (2025-12-14)
681

782
### Bug Fixes

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 = "3.14.3"
3+
version = "3.16.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/data/b01_q7/b01_q7_containers.py

Lines changed: 59 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -69,62 +69,62 @@ class B01Props(RoborockBase):
6969
This dataclass is generated based on the device's status JSON object.
7070
"""
7171

72-
status: WorkStatusMapping
73-
fault: B01Fault
74-
wind: SCWindMapping
75-
water: int
76-
mode: int
77-
quantity: int
78-
alarm: int
79-
volume: int
80-
hypa: int
81-
main_brush: int
82-
side_brush: int
83-
mop_life: int
84-
main_sensor: int
85-
net_status: NetStatus
86-
repeat_state: int
87-
tank_state: int
88-
sweep_type: int
89-
clean_path_preference: int
90-
cloth_state: int
91-
time_zone: int
92-
time_zone_info: str
93-
language: int
94-
cleaning_time: int
95-
real_clean_time: int
96-
cleaning_area: int
97-
custom_type: int
98-
sound: int
99-
work_mode: WorkModeMapping
100-
station_act: int
101-
charge_state: int
102-
current_map_id: int
103-
map_num: int
104-
dust_action: int
105-
quiet_is_open: int
106-
quiet_begin_time: int
107-
quiet_end_time: int
108-
clean_finish: int
109-
voice_type: int
110-
voice_type_version: int
111-
order_total: OrderTotal
112-
build_map: int
113-
privacy: Privacy
114-
dust_auto_state: int
115-
dust_frequency: int
116-
child_lock: int
117-
multi_floor: int
118-
map_save: int
119-
light_mode: int
120-
green_laser: int
121-
dust_bag_used: int
122-
order_save_mode: int
123-
manufacturer: str
124-
back_to_wash: int
125-
charge_station_type: int
126-
pv_cut_charge: int
127-
pv_charging: PvCharging
128-
serial_number: str
129-
recommend: Recommend
130-
add_sweep_status: int
72+
status: WorkStatusMapping | None = None
73+
fault: B01Fault | None = None
74+
wind: SCWindMapping | None = None
75+
water: int | None = None
76+
mode: int | None = None
77+
quantity: int | None = None
78+
alarm: int | None = None
79+
volume: int | None = None
80+
hypa: int | None = None
81+
main_brush: int | None = None
82+
side_brush: int | None = None
83+
mop_life: int | None = None
84+
main_sensor: int | None = None
85+
net_status: NetStatus | None = None
86+
repeat_state: int | None = None
87+
tank_state: int | None = None
88+
sweep_type: int | None = None
89+
clean_path_preference: int | None = None
90+
cloth_state: int | None = None
91+
time_zone: int | None = None
92+
time_zone_info: str | None = None
93+
language: int | None = None
94+
cleaning_time: int | None = None
95+
real_clean_time: int | None = None
96+
cleaning_area: int | None = None
97+
custom_type: int | None = None
98+
sound: int | None = None
99+
work_mode: WorkModeMapping | None = None
100+
station_act: int | None = None
101+
charge_state: int | None = None
102+
current_map_id: int | None = None
103+
map_num: int | None = None
104+
dust_action: int | None = None
105+
quiet_is_open: int | None = None
106+
quiet_begin_time: int | None = None
107+
quiet_end_time: int | None = None
108+
clean_finish: int | None = None
109+
voice_type: int | None = None
110+
voice_type_version: int | None = None
111+
order_total: OrderTotal | None = None
112+
build_map: int | None = None
113+
privacy: Privacy | None = None
114+
dust_auto_state: int | None = None
115+
dust_frequency: int | None = None
116+
child_lock: int | None = None
117+
multi_floor: int | None = None
118+
map_save: int | None = None
119+
light_mode: int | None = None
120+
green_laser: int | None = None
121+
dust_bag_used: int | None = None
122+
order_save_mode: int | None = None
123+
manufacturer: str | None = None
124+
back_to_wash: int | None = None
125+
charge_station_type: int | None = None
126+
pv_cut_charge: int | None = None
127+
pv_charging: PvCharging | None = None
128+
serial_number: str | None = None
129+
recommend: Recommend | None = None
130+
add_sweep_status: int | None = None

roborock/data/containers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,15 @@ class HomeDataScene(RoborockBase):
284284
name: str
285285

286286

287+
@dataclass
288+
class HomeDataSchedule(RoborockBase):
289+
id: int
290+
cron: str
291+
repeated: bool
292+
enabled: bool
293+
param: dict | None = None
294+
295+
287296
@dataclass
288297
class HomeData(RoborockBase):
289298
id: int

roborock/devices/b01_channel.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,76 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
import json
57
import logging
8+
from typing import Any
69

10+
from roborock.exceptions import RoborockException
711
from roborock.protocols.b01_protocol import (
812
CommandType,
913
ParamsType,
14+
decode_rpc_response,
1015
encode_mqtt_payload,
1116
)
17+
from roborock.roborock_message import RoborockMessage
18+
from roborock.util import get_next_int
1219

1320
from .mqtt_channel import MqttChannel
1421

1522
_LOGGER = logging.getLogger(__name__)
23+
_TIMEOUT = 10.0
1624

1725

1826
async def send_decoded_command(
1927
mqtt_channel: MqttChannel,
2028
dps: int,
2129
command: CommandType,
2230
params: ParamsType,
23-
) -> None:
31+
) -> dict[str, Any]:
2432
"""Send a command on the MQTT channel and get a decoded response."""
2533
_LOGGER.debug("Sending MQTT command: %s", params)
26-
roborock_message = encode_mqtt_payload(dps, command, params)
27-
await mqtt_channel.publish(roborock_message)
34+
msg_id = str(get_next_int(100000000000, 999999999999))
35+
roborock_message = encode_mqtt_payload(dps, command, params, msg_id)
36+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
37+
38+
def find_response(response_message: RoborockMessage) -> None:
39+
"""Handle incoming messages and resolve the future."""
40+
try:
41+
decoded_dps = decode_rpc_response(response_message)
42+
except RoborockException as ex:
43+
_LOGGER.info("Failed to decode b01 message: %s: %s", response_message, ex)
44+
return
45+
46+
for dps_value in decoded_dps.values():
47+
# valid responses are JSON strings wrapped in the dps value
48+
if not isinstance(dps_value, str):
49+
_LOGGER.debug("Received unexpected response: %s", dps_value)
50+
continue
51+
52+
try:
53+
inner = json.loads(dps_value)
54+
except (json.JSONDecodeError, TypeError):
55+
_LOGGER.debug("Received unexpected response: %s", dps_value)
56+
continue
57+
58+
if isinstance(inner, dict) and inner.get("msgId") == msg_id:
59+
_LOGGER.debug("Received query response: %s", inner)
60+
data = inner.get("data")
61+
if not future.done():
62+
if isinstance(data, dict):
63+
future.set_result(data)
64+
else:
65+
future.set_exception(RoborockException(f"Unexpected data type for response: {data}"))
66+
67+
unsub = await mqtt_channel.subscribe(find_response)
68+
69+
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
70+
try:
71+
await mqtt_channel.publish(roborock_message)
72+
try:
73+
return await asyncio.wait_for(future, timeout=_TIMEOUT)
74+
except TimeoutError as ex:
75+
raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
76+
finally:
77+
unsub()

roborock/devices/device.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,34 +147,45 @@ async def start_connect(self) -> None:
147147
called. The device will automatically attempt to reconnect if the connection
148148
is lost.
149149
"""
150-
start_attempt: asyncio.Event = asyncio.Event()
150+
# The future will be set to True if the first attempt succeeds, False if
151+
# it fails, or an exception if an unexpected error occurs.
152+
# We use this to wait a short time for the first attempt to complete. We
153+
# don't actually care about the result, just that we waited long enough.
154+
start_attempt: asyncio.Future[bool] = asyncio.Future()
151155

152156
async def connect_loop() -> None:
153-
backoff = MIN_BACKOFF_INTERVAL
154157
try:
158+
backoff = MIN_BACKOFF_INTERVAL
155159
while True:
156160
try:
157161
await self.connect()
158-
start_attempt.set()
162+
if not start_attempt.done():
163+
start_attempt.set_result(True)
159164
self._has_connected = True
160165
self._ready_callbacks(self)
161166
return
162167
except RoborockException as e:
163-
start_attempt.set()
168+
if not start_attempt.done():
169+
start_attempt.set_result(False)
164170
self._logger.info("Failed to connect (retry %s): %s", backoff.total_seconds(), e)
165171
await asyncio.sleep(backoff.total_seconds())
166172
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
173+
except Exception as e: # pylint: disable=broad-except
174+
if not start_attempt.done():
175+
start_attempt.set_exception(e)
176+
self._logger.exception("Uncaught error during connect: %s", e)
177+
return
167178
except asyncio.CancelledError:
168179
self._logger.debug("connect_loop was cancelled for device %s", self.duid)
169-
# Clean exit on cancellation
170-
return
171180
finally:
172-
start_attempt.set()
181+
if not start_attempt.done():
182+
start_attempt.set_result(False)
173183

174184
self._connect_task = asyncio.create_task(connect_loop())
175185

176186
try:
177-
await asyncio.wait_for(start_attempt.wait(), timeout=START_ATTEMPT_TIMEOUT.total_seconds())
187+
async with asyncio.timeout(START_ATTEMPT_TIMEOUT.total_seconds()):
188+
await start_attempt
178189
except TimeoutError:
179190
self._logger.debug("Initial connection attempt took longer than expected, will keep trying in background")
180191

@@ -189,6 +200,7 @@ async def connect(self) -> None:
189200
except RoborockException:
190201
unsub()
191202
raise
203+
self._logger.info("Connected to device")
192204
self._unsub = unsub
193205

194206
async def close(self) -> None:

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Traits for Q7 B01 devices.
22
Potentially other devices may fall into this category in the future."""
33

4+
from roborock import B01Props
45
from roborock.devices.b01_channel import send_decoded_command
56
from roborock.devices.mqtt_channel import MqttChannel
67
from roborock.devices.traits import Trait
@@ -13,17 +14,18 @@
1314

1415

1516
class Q7PropertiesApi(Trait):
16-
"""API for interacting with Q7 B01 devices."""
17+
"""API for interacting with B01 devices."""
1718

1819
def __init__(self, channel: MqttChannel) -> None:
1920
"""Initialize the B01Props API."""
2021
self._channel = channel
2122

22-
async def query_values(self, props: list[RoborockB01Props]) -> None:
23+
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
2324
"""Query the device for the values of the given Q7 properties."""
24-
await send_decoded_command(
25+
result = await send_decoded_command(
2526
self._channel, dps=10000, command=RoborockB01Q7Methods.GET_PROP, params={"property": props}
2627
)
28+
return B01Props.from_dict(result)
2729

2830

2931
def create(channel: MqttChannel) -> Q7PropertiesApi:

0 commit comments

Comments
 (0)