Skip to content

Commit 4866534

Browse files
authored
Merge branch 'main' into e2e-tests
2 parents e4d1630 + df183d1 commit 4866534

File tree

15 files changed

+286
-30
lines changed

15 files changed

+286
-30
lines changed

CHANGELOG.md

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

33
<!-- version list -->
44

5+
## v3.16.1 (2025-12-14)
6+
7+
### Bug Fixes
8+
9+
- Share a HealthManager instance across all mqtt channels
10+
([#672](https://github.com/Python-roborock/python-roborock/pull/672),
11+
[`4ad95dd`](https://github.com/Python-roborock/python-roborock/commit/4ad95ddee4d4d4cd64c7908f150c71d81f45e705))
12+
13+
14+
## v3.16.0 (2025-12-14)
15+
16+
### Bug Fixes
17+
18+
- Fix bugs in the subscription idle timeout
19+
([#665](https://github.com/Python-roborock/python-roborock/pull/665),
20+
[`85b7bee`](https://github.com/Python-roborock/python-roborock/commit/85b7beeb810cfb3d501658cd44f06b2c0052ca33))
21+
22+
- Harden the device connection logic used in startup
23+
([#666](https://github.com/Python-roborock/python-roborock/pull/666),
24+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
25+
26+
- Harden the initial startup logic
27+
([#666](https://github.com/Python-roborock/python-roborock/pull/666),
28+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
29+
30+
### Chores
31+
32+
- Apply suggestions from code review
33+
([#675](https://github.com/Python-roborock/python-roborock/pull/675),
34+
[`ab2de5b`](https://github.com/Python-roborock/python-roborock/commit/ab2de5bda7b8e1ff1ad46c7f2bf3b39dc9af4ace))
35+
36+
- Clarify comments and docstrings
37+
([#666](https://github.com/Python-roborock/python-roborock/pull/666),
38+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
39+
40+
- Fix logging ([#666](https://github.com/Python-roborock/python-roborock/pull/666),
41+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
42+
43+
- Reduce whitespace changes ([#666](https://github.com/Python-roborock/python-roborock/pull/666),
44+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
45+
46+
- Revert whitespace change ([#666](https://github.com/Python-roborock/python-roborock/pull/666),
47+
[`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc))
48+
49+
### Features
50+
51+
- Add basic schedule getting ([#675](https://github.com/Python-roborock/python-roborock/pull/675),
52+
[`ab2de5b`](https://github.com/Python-roborock/python-roborock/commit/ab2de5bda7b8e1ff1ad46c7f2bf3b39dc9af4ace))
53+
54+
555
## v3.15.0 (2025-12-14)
656

757
### 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 = "3.15.0"
3+
version = "3.16.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/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/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/mqtt_channel.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from roborock.callbacks import decoder_callback
77
from roborock.data import HomeDataDevice, RRiot, UserData
88
from roborock.exceptions import RoborockException
9+
from roborock.mqtt.health_manager import HealthManager
910
from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException
1011
from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
1112
from roborock.roborock_message import RoborockMessage
@@ -42,6 +43,11 @@ def is_connected(self) -> bool:
4243
"""
4344
return self._mqtt_session.connected
4445

46+
@property
47+
def health_manager(self) -> HealthManager:
48+
"""Return the health manager for the session."""
49+
return self._mqtt_session.health_manager
50+
4551
@property
4652
def is_local_connected(self) -> bool:
4753
"""Return true if the channel is connected locally."""

roborock/devices/v1_channel.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ def __init__(
181181
self._logger = RoborockLoggerAdapter(device_uid, _LOGGER)
182182
self._security_data = security_data
183183
self._mqtt_channel = mqtt_channel
184-
self._mqtt_health_manager = HealthManager(self._mqtt_channel.restart)
185184
self._local_session = local_session
186185
self._local_channel: LocalChannel | None = None
187186
self._mqtt_unsub: Callable[[], None] | None = None
@@ -272,7 +271,7 @@ def _create_mqtt_rpc_strategy(self, decoder: Callable[[RoborockMessage], Any] =
272271
security_data=self._security_data,
273272
),
274273
decoder=decoder,
275-
health_manager=self._mqtt_health_manager,
274+
health_manager=self._mqtt_channel.health_manager,
276275
)
277276

278277
async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:

roborock/mqtt/health_manager.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
"""
77

88
import datetime
9+
import logging
910
from collections.abc import Awaitable, Callable
1011

12+
_LOGGER = logging.getLogger(__name__)
13+
1114
# Number of consecutive timeouts before considering the connection unhealthy.
1215
TIMEOUT_THRESHOLD = 3
1316

@@ -45,7 +48,13 @@ async def on_timeout(self) -> None:
4548
self._consecutive_timeouts += 1
4649
if self._consecutive_timeouts >= TIMEOUT_THRESHOLD:
4750
now = datetime.datetime.now(datetime.UTC)
48-
if self._last_restart is None or now - self._last_restart >= RESTART_COOLDOWN:
51+
since_last = (now - self._last_restart) if self._last_restart else None
52+
if since_last is None or since_last >= RESTART_COOLDOWN:
53+
_LOGGER.debug(
54+
"Restarting MQTT session after %d consecutive timeouts (duration since last restart %s)",
55+
self._consecutive_timeouts,
56+
since_last,
57+
)
4958
await self._restart()
5059
self._last_restart = now
5160
self._consecutive_timeouts = 0

roborock/mqtt/roborock_session.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from roborock.callbacks import CallbackMap
2121

22+
from .health_manager import HealthManager
2223
from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized
2324

2425
_LOGGER = logging.getLogger(__name__)
@@ -69,17 +70,24 @@ def __init__(
6970
self._stop = False
7071
self._backoff = MIN_BACKOFF_INTERVAL
7172
self._client: aiomqtt.Client | None = None
73+
self._client_subscribed_topics: set[str] = set()
7274
self._client_lock = asyncio.Lock()
7375
self._listeners: CallbackMap[str, bytes] = CallbackMap(_LOGGER)
7476
self._connection_task: asyncio.Task[None] | None = None
7577
self._topic_idle_timeout = topic_idle_timeout
7678
self._idle_timers: dict[str, asyncio.Task[None]] = {}
79+
self._health_manager = HealthManager(self.restart)
7780

7881
@property
7982
def connected(self) -> bool:
8083
"""True if the session is connected to the broker."""
8184
return self._healthy
8285

86+
@property
87+
def health_manager(self) -> HealthManager:
88+
"""Return the health manager for the session."""
89+
return self._health_manager
90+
8391
async def start(self) -> None:
8492
"""Start the MQTT session.
8593
@@ -218,7 +226,7 @@ async def _mqtt_client(self, params: MqttParams) -> aiomqtt.Client:
218226
# Re-establish any existing subscriptions
219227
async with self._client_lock:
220228
self._client = client
221-
for topic in self._listeners.keys():
229+
for topic in self._client_subscribed_topics:
222230
_LOGGER.debug("Re-establishing subscription to topic %s", topic)
223231
# TODO: If this fails it will break the whole connection. Make
224232
# this retry again in the background with backoff.
@@ -249,32 +257,42 @@ async def subscribe(self, topic: str, callback: Callable[[bytes], None]) -> Call
249257
unsub = self._listeners.add_callback(topic, callback)
250258

251259
async with self._client_lock:
252-
if self._client:
253-
_LOGGER.debug("Establishing subscription to topic %s", topic)
254-
try:
255-
await self._client.subscribe(topic)
256-
except MqttError as err:
257-
# Clean up the callback if subscription fails
258-
unsub()
259-
raise MqttSessionException(f"Error subscribing to topic: {err}") from err
260-
else:
261-
_LOGGER.debug("Client not connected, will establish subscription later")
262-
263-
def schedule_unsubscribe():
260+
if topic not in self._client_subscribed_topics:
261+
self._client_subscribed_topics.add(topic)
262+
if self._client:
263+
_LOGGER.debug("Establishing subscription to topic %s", topic)
264+
try:
265+
await self._client.subscribe(topic)
266+
except MqttError as err:
267+
# Clean up the callback if subscription fails
268+
unsub()
269+
self._client_subscribed_topics.discard(topic)
270+
raise MqttSessionException(f"Error subscribing to topic: {err}") from err
271+
else:
272+
_LOGGER.debug("Client not connected, will establish subscription later")
273+
274+
def schedule_unsubscribe() -> None:
264275
async def idle_unsubscribe():
265276
try:
266277
await asyncio.sleep(self._topic_idle_timeout.total_seconds())
267278
# Only unsubscribe if there are no callbacks left for this topic
268279
if not self._listeners.get_callbacks(topic):
269280
async with self._client_lock:
281+
# Check again if we have listeners, in case a subscribe happened
282+
# while we were waiting for the lock or after we popped the timer.
283+
if self._listeners.get_callbacks(topic):
284+
_LOGGER.debug("Skipping unsubscribe for %s, new listeners added", topic)
285+
return
286+
287+
self._idle_timers.pop(topic, None)
288+
self._client_subscribed_topics.discard(topic)
289+
270290
if self._client:
271291
_LOGGER.debug("Idle timeout expired, unsubscribing from topic %s", topic)
272292
try:
273293
await self._client.unsubscribe(topic)
274294
except MqttError as err:
275295
_LOGGER.warning("Error unsubscribing from topic %s: %s", topic, err)
276-
# Clean up timer from dict
277-
self._idle_timers.pop(topic, None)
278296
except asyncio.CancelledError:
279297
_LOGGER.debug("Idle unsubscribe for topic %s cancelled", topic)
280298

@@ -286,7 +304,10 @@ def delayed_unsub():
286304
unsub() # Remove the callback from CallbackMap
287305
# If no more callbacks for this topic, start idle timer
288306
if not self._listeners.get_callbacks(topic):
307+
_LOGGER.debug("Unsubscribing topic %s, starting idle timer", topic)
289308
schedule_unsubscribe()
309+
else:
310+
_LOGGER.debug("Unsubscribing topic %s, still have active callbacks", topic)
290311

291312
return delayed_unsub
292313

@@ -323,6 +344,11 @@ def connected(self) -> bool:
323344
"""True if the session is connected to the broker."""
324345
return self._session.connected
325346

347+
@property
348+
def health_manager(self) -> HealthManager:
349+
"""Return the health manager for the session."""
350+
return self._session.health_manager
351+
326352
async def _maybe_start(self) -> None:
327353
"""Start the MQTT session if not already started."""
328354
async with self._lock:

roborock/mqtt/session.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import dataclass
66

77
from roborock.exceptions import RoborockException
8+
from roborock.mqtt.health_manager import HealthManager
89

910
DEFAULT_TIMEOUT = 30.0
1011

@@ -40,6 +41,11 @@ class MqttSession(ABC):
4041
def connected(self) -> bool:
4142
"""True if the session is connected to the broker."""
4243

44+
@property
45+
@abstractmethod
46+
def health_manager(self) -> HealthManager:
47+
"""Return the health manager for the session."""
48+
4349
@abstractmethod
4450
async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> Callable[[], None]:
4551
"""Invoke the callback when messages are received on the topic.

roborock/web_api.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from aiohttp import ContentTypeError, FormData
1515
from pyrate_limiter import BucketFullException, Duration, Limiter, Rate
1616

17+
from roborock import HomeDataSchedule
1718
from roborock.data import HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
1819
from roborock.exceptions import (
1920
RoborockAccountDoesNotExist,
@@ -607,6 +608,28 @@ async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
607608
if not execute_scene_response.get("success"):
608609
raise RoborockException(execute_scene_response)
609610

611+
async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
612+
rriot = user_data.rriot
613+
if rriot is None:
614+
raise RoborockException("rriot is none")
615+
if rriot.r.a is None:
616+
raise RoborockException("Missing field 'a' in rriot reference")
617+
schedules_request = PreparedRequest(
618+
rriot.r.a,
619+
self.session,
620+
{
621+
"Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"),
622+
},
623+
)
624+
schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs")
625+
if not schedules_response.get("success"):
626+
raise RoborockException(schedules_response)
627+
schedules = schedules_response.get("result")
628+
if isinstance(schedules, list):
629+
return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
630+
else:
631+
raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
632+
610633
async def get_products(self, user_data: UserData) -> ProductResponse:
611634
"""Gets all products and their schemas, good for determining status codes and model numbers."""
612635
base_url = await self.base_url

0 commit comments

Comments
 (0)