python-roborock 3.18.0__tar.gz → 3.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {python_roborock-3.18.0 → python_roborock-3.19.0}/PKG-INFO +1 -1
  2. {python_roborock-3.18.0 → python_roborock-3.19.0}/pyproject.toml +1 -1
  3. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/v1/v1_containers.py +1 -1
  4. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/device_manager.py +15 -2
  5. python_roborock-3.19.0/roborock/diagnostics.py +84 -0
  6. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/mqtt/roborock_session.py +37 -16
  7. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/mqtt/session.py +10 -1
  8. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/protocols/v1_protocol.py +2 -4
  9. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/roborock_message.py +2 -4
  10. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/util.py +10 -0
  11. {python_roborock-3.18.0 → python_roborock-3.19.0}/.gitignore +0 -0
  12. {python_roborock-3.18.0 → python_roborock-3.19.0}/LICENSE +0 -0
  13. {python_roborock-3.18.0 → python_roborock-3.19.0}/README.md +0 -0
  14. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/__init__.py +0 -0
  15. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/api.py +0 -0
  16. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/broadcast_protocol.py +0 -0
  17. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/callbacks.py +0 -0
  18. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/cli.py +0 -0
  19. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/cloud_api.py +0 -0
  20. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/command_cache.py +0 -0
  21. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/const.py +0 -0
  22. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/__init__.py +0 -0
  23. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/b01_q10/__init__.py +0 -0
  24. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  25. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  26. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/b01_q7/__init__.py +0 -0
  27. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  28. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  29. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/code_mappings.py +0 -0
  30. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/containers.py +0 -0
  31. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/dyad/__init__.py +0 -0
  32. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  33. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/dyad/dyad_containers.py +0 -0
  34. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/v1/__init__.py +0 -0
  35. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  36. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  37. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/zeo/__init__.py +0 -0
  38. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  39. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/data/zeo/zeo_containers.py +0 -0
  40. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/device_features.py +0 -0
  41. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/README.md +0 -0
  42. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/__init__.py +0 -0
  43. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/a01_channel.py +0 -0
  44. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/b01_channel.py +0 -0
  45. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/cache.py +0 -0
  46. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/channel.py +0 -0
  47. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/device.py +0 -0
  48. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/file_cache.py +0 -0
  49. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/local_channel.py +0 -0
  50. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/mqtt_channel.py +0 -0
  51. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/__init__.py +0 -0
  52. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/a01/__init__.py +0 -0
  53. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/b01/__init__.py +0 -0
  54. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  55. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  56. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/traits_mixin.py +0 -0
  57. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/__init__.py +0 -0
  58. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  59. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  60. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/command.py +0 -0
  61. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/common.py +0 -0
  62. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  63. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/device_features.py +0 -0
  64. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  65. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  66. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  67. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/home.py +0 -0
  68. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/led_status.py +0 -0
  69. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/map_content.py +0 -0
  70. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/maps.py +0 -0
  71. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/network_info.py +0 -0
  72. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/rooms.py +0 -0
  73. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/routines.py +0 -0
  74. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  75. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/status.py +0 -0
  76. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  77. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/volume.py +0 -0
  78. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  79. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/devices/v1_channel.py +0 -0
  80. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/exceptions.py +0 -0
  81. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/map/__init__.py +0 -0
  82. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/map/map_parser.py +0 -0
  83. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/mqtt/__init__.py +0 -0
  84. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/mqtt/health_manager.py +0 -0
  85. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/protocol.py +0 -0
  86. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/protocols/__init__.py +0 -0
  87. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/protocols/a01_protocol.py +0 -0
  88. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/protocols/b01_protocol.py +0 -0
  89. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/py.typed +0 -0
  90. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/roborock_future.py +0 -0
  91. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/roborock_typing.py +0 -0
  92. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/version_1_apis/__init__.py +0 -0
  93. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  94. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  95. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  96. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/version_a01_apis/__init__.py +0 -0
  97. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  98. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  99. {python_roborock-3.18.0 → python_roborock-3.19.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 3.18.0
3
+ Version: 3.19.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/humbertogontijo/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "3.18.0"
3
+ version = "3.19.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -585,7 +585,7 @@ class AppInitStatus(RoborockBase):
585
585
  local_info: AppInitStatusLocalInfo
586
586
  feature_info: list[int]
587
587
  new_feature_info: int
588
- new_feature_info_str: str
588
+ new_feature_info_str: str = ""
589
589
  new_feature_info_2: int | None = None
590
590
  carriage_type: int | None = None
591
591
  dsp_version: str | None = None
@@ -3,8 +3,9 @@
3
3
  import asyncio
4
4
  import enum
5
5
  import logging
6
- from collections.abc import Callable
6
+ from collections.abc import Callable, Mapping
7
7
  from dataclasses import dataclass
8
+ from typing import Any
8
9
 
9
10
  import aiohttp
10
11
 
@@ -15,6 +16,7 @@ from roborock.data import (
15
16
  UserData,
16
17
  )
17
18
  from roborock.devices.device import DeviceReadyCallback, RoborockDevice
19
+ from roborock.diagnostics import Diagnostics
18
20
  from roborock.exceptions import RoborockException
19
21
  from roborock.map.map_parser import MapParserConfig
20
22
  from roborock.mqtt.roborock_session import create_lazy_mqtt_session
@@ -58,6 +60,7 @@ class DeviceManager:
58
60
  device_creator: DeviceCreator,
59
61
  mqtt_session: MqttSession,
60
62
  cache: Cache,
63
+ diagnostics: Diagnostics,
61
64
  ) -> None:
62
65
  """Initialize the DeviceManager with user data and optional cache storage.
63
66
 
@@ -68,12 +71,15 @@ class DeviceManager:
68
71
  self._device_creator = device_creator
69
72
  self._devices: dict[str, RoborockDevice] = {}
70
73
  self._mqtt_session = mqtt_session
74
+ self._diagnostics = diagnostics
71
75
 
72
76
  async def discover_devices(self, prefer_cache: bool = True) -> list[RoborockDevice]:
73
77
  """Discover all devices for the logged-in user."""
78
+ self._diagnostics.increment("discover_devices")
74
79
  cache_data = await self._cache.get()
75
80
  if not cache_data.home_data or not prefer_cache:
76
81
  _LOGGER.debug("Fetching home data (prefer_cache=%s)", prefer_cache)
82
+ self._diagnostics.increment("fetch_home_data")
77
83
  try:
78
84
  cache_data.home_data = await self._web_api.get_home_data()
79
85
  except RoborockException as ex:
@@ -116,6 +122,10 @@ class DeviceManager:
116
122
  tasks.append(self._mqtt_session.close())
117
123
  await asyncio.gather(*tasks)
118
124
 
125
+ def diagnostic_data(self) -> Mapping[str, Any]:
126
+ """Return diagnostics information about the device manager."""
127
+ return self._diagnostics.as_dict()
128
+
119
129
 
120
130
  @dataclass
121
131
  class UserParams:
@@ -182,7 +192,10 @@ async def create_device_manager(
182
192
  web_api = create_web_api_wrapper(user_params, session=session, cache=cache)
183
193
  user_data = user_params.user_data
184
194
 
195
+ diagnostics = Diagnostics()
196
+
185
197
  mqtt_params = create_mqtt_params(user_data.rriot)
198
+ mqtt_params.diagnostics = diagnostics.subkey("mqtt_session")
186
199
  mqtt_session = await create_lazy_mqtt_session(mqtt_params)
187
200
 
188
201
  def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
@@ -226,6 +239,6 @@ async def create_device_manager(
226
239
  dev.add_ready_callback(ready_callback)
227
240
  return dev
228
241
 
229
- manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache)
242
+ manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache, diagnostics=diagnostics)
230
243
  await manager.discover_devices()
231
244
  return manager
@@ -0,0 +1,84 @@
1
+ """Diagnostics for debugging.
2
+
3
+ A Diagnostics object can be used to track counts and latencies of various
4
+ operations within a module. This can be useful for debugging performance issues
5
+ or understanding usage patterns.
6
+
7
+ This is an internal facing module and is not intended for public use. Diagnostics
8
+ data is collected and exposed to clients via higher level APIs like the
9
+ DeviceManager.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections import Counter
16
+ from collections.abc import Generator, Mapping
17
+ from contextlib import contextmanager
18
+ from typing import Any
19
+
20
+
21
+ class Diagnostics:
22
+ """A class that holds diagnostics information for a module.
23
+
24
+ You can use this class to hold counter or for recording timing information
25
+ that can be exported as a dictionary for debugging purposes.
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize Diagnostics."""
30
+ self._counter: Counter = Counter()
31
+ self._subkeys: dict[str, Diagnostics] = {}
32
+
33
+ def increment(self, key: str, count: int = 1) -> None:
34
+ """Increment a counter for the specified key/event."""
35
+ self._counter.update(Counter({key: count}))
36
+
37
+ def elapsed(self, key_prefix: str, elapsed_ms: int = 1) -> None:
38
+ """Track a latency event for the specified key/event prefix."""
39
+ self.increment(f"{key_prefix}_count", 1)
40
+ self.increment(f"{key_prefix}_sum", elapsed_ms)
41
+
42
+ def as_dict(self) -> Mapping[str, Any]:
43
+ """Return diagnostics as a debug dictionary."""
44
+ data: dict[str, Any] = {k: self._counter[k] for k in self._counter}
45
+ for k, d in self._subkeys.items():
46
+ v = d.as_dict()
47
+ if not v:
48
+ continue
49
+ data[k] = v
50
+ return data
51
+
52
+ def subkey(self, key: str) -> Diagnostics:
53
+ """Return sub-Diagnostics object with the specified subkey.
54
+
55
+ This will create a new Diagnostics object if one does not already exist
56
+ for the specified subkey. Stats from the sub-Diagnostics will be included
57
+ in the parent Diagnostics when exported as a dictionary.
58
+
59
+ Args:
60
+ key: The subkey for the diagnostics.
61
+
62
+ Returns:
63
+ The Diagnostics object for the specified subkey.
64
+ """
65
+ if key not in self._subkeys:
66
+ self._subkeys[key] = Diagnostics()
67
+ return self._subkeys[key]
68
+
69
+ @contextmanager
70
+ def timer(self, key_prefix: str) -> Generator[None, None, None]:
71
+ """A context manager that records the timing of operations as a diagnostic."""
72
+ start = time.perf_counter()
73
+ try:
74
+ yield
75
+ finally:
76
+ end = time.perf_counter()
77
+ ms = int((end - start) * 1000)
78
+ self.elapsed(key_prefix, ms)
79
+
80
+ def reset(self) -> None:
81
+ """Clear all diagnostics, for testing."""
82
+ self._counter = Counter()
83
+ for d in self._subkeys.values():
84
+ d.reset()
@@ -18,6 +18,7 @@ import aiomqtt
18
18
  from aiomqtt import MqttCodeError, MqttError, TLSParameters
19
19
 
20
20
  from roborock.callbacks import CallbackMap
21
+ from roborock.diagnostics import Diagnostics
21
22
 
22
23
  from .health_manager import HealthManager
23
24
  from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized
@@ -76,6 +77,7 @@ class RoborockMqttSession(MqttSession):
76
77
  self._connection_task: asyncio.Task[None] | None = None
77
78
  self._topic_idle_timeout = topic_idle_timeout
78
79
  self._idle_timers: dict[str, asyncio.Task[None]] = {}
80
+ self._diagnostics = params.diagnostics
79
81
  self._health_manager = HealthManager(self.restart)
80
82
 
81
83
  @property
@@ -96,24 +98,30 @@ class RoborockMqttSession(MqttSession):
96
98
  handle the failure and retry if desired itself. Once connected,
97
99
  the session will retry connecting in the background.
98
100
  """
101
+ self._diagnostics.increment("start_attempt")
99
102
  start_future: asyncio.Future[None] = asyncio.Future()
100
103
  loop = asyncio.get_event_loop()
101
104
  self._reconnect_task = loop.create_task(self._run_reconnect_loop(start_future))
102
105
  try:
103
106
  await start_future
104
107
  except MqttCodeError as err:
108
+ self._diagnostics.increment(f"start_failure:{err.rc}")
105
109
  if err.rc == MqttReasonCode.RC_ERROR_UNAUTHORIZED:
106
110
  raise MqttSessionUnauthorized(f"Authorization error starting MQTT session: {err}") from err
107
111
  raise MqttSessionException(f"Error starting MQTT session: {err}") from err
108
112
  except MqttError as err:
113
+ self._diagnostics.increment("start_failure:unknown")
109
114
  raise MqttSessionException(f"Error starting MQTT session: {err}") from err
110
115
  except Exception as err:
116
+ self._diagnostics.increment("start_failure:uncaught")
111
117
  raise MqttSessionException(f"Unexpected error starting session: {err}") from err
112
118
  else:
119
+ self._diagnostics.increment("start_success")
113
120
  _LOGGER.debug("MQTT session started successfully")
114
121
 
115
122
  async def close(self) -> None:
116
123
  """Cancels the MQTT loop and shutdown the client library."""
124
+ self._diagnostics.increment("close")
117
125
  self._stop = True
118
126
  tasks = [task for task in [self._connection_task, self._reconnect_task, *self._idle_timers.values()] if task]
119
127
  self._connection_task = None
@@ -136,6 +144,7 @@ class RoborockMqttSession(MqttSession):
136
144
  the reconnect loop. This is a no-op if there is no active connection.
137
145
  """
138
146
  _LOGGER.info("Forcing MQTT session restart")
147
+ self._diagnostics.increment("restart")
139
148
  if self._connection_task:
140
149
  self._connection_task.cancel()
141
150
  else:
@@ -144,6 +153,7 @@ class RoborockMqttSession(MqttSession):
144
153
  async def _run_reconnect_loop(self, start_future: asyncio.Future[None] | None) -> None:
145
154
  """Run the MQTT loop."""
146
155
  _LOGGER.info("Starting MQTT session")
156
+ self._diagnostics.increment("start_loop")
147
157
  while True:
148
158
  try:
149
159
  self._connection_task = asyncio.create_task(self._run_connection(start_future))
@@ -164,6 +174,7 @@ class RoborockMqttSession(MqttSession):
164
174
  _LOGGER.debug("MQTT session closed, stopping retry loop")
165
175
  return
166
176
  _LOGGER.info("MQTT session disconnected, retrying in %s seconds", self._backoff.total_seconds())
177
+ self._diagnostics.increment("reconnect_wait")
167
178
  await asyncio.sleep(self._backoff.total_seconds())
168
179
  self._backoff = min(self._backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
169
180
 
@@ -175,17 +186,19 @@ class RoborockMqttSession(MqttSession):
175
186
  is lost, this method will exit.
176
187
  """
177
188
  try:
178
- async with self._mqtt_client(self._params) as client:
179
- self._backoff = MIN_BACKOFF_INTERVAL
180
- self._healthy = True
181
- _LOGGER.info("MQTT Session connected.")
182
- if start_future and not start_future.done():
183
- start_future.set_result(None)
184
-
185
- _LOGGER.debug("Processing MQTT messages")
186
- async for message in client.messages:
187
- _LOGGER.debug("Received message: %s", message)
188
- self._listeners(message.topic.value, message.payload)
189
+ with self._diagnostics.timer("connection"):
190
+ async with self._mqtt_client(self._params) as client:
191
+ self._backoff = MIN_BACKOFF_INTERVAL
192
+ self._healthy = True
193
+ _LOGGER.info("MQTT Session connected.")
194
+ if start_future and not start_future.done():
195
+ start_future.set_result(None)
196
+
197
+ _LOGGER.debug("Processing MQTT messages")
198
+ async for message in client.messages:
199
+ _LOGGER.debug("Received message: %s", message)
200
+ with self._diagnostics.timer("dispatch_message"):
201
+ self._listeners(message.topic.value, message.payload)
189
202
  except MqttError as err:
190
203
  if start_future and not start_future.done():
191
204
  _LOGGER.info("MQTT error starting session: %s", err)
@@ -227,6 +240,7 @@ class RoborockMqttSession(MqttSession):
227
240
  async with self._client_lock:
228
241
  self._client = client
229
242
  for topic in self._client_subscribed_topics:
243
+ self._diagnostics.increment("resubscribe")
230
244
  _LOGGER.debug("Re-establishing subscription to topic %s", topic)
231
245
  # TODO: If this fails it will break the whole connection. Make
232
246
  # this retry again in the background with backoff.
@@ -251,6 +265,7 @@ class RoborockMqttSession(MqttSession):
251
265
 
252
266
  # If there is an idle timer for this topic, cancel it (reuse subscription)
253
267
  if idle_timer := self._idle_timers.pop(topic, None):
268
+ self._diagnostics.increment("unsubscribe_idle_cancel")
254
269
  idle_timer.cancel()
255
270
  _LOGGER.debug("Cancelled idle timer for topic %s (reused subscription)", topic)
256
271
 
@@ -262,13 +277,15 @@ class RoborockMqttSession(MqttSession):
262
277
  if self._client:
263
278
  _LOGGER.debug("Establishing subscription to topic %s", topic)
264
279
  try:
265
- await self._client.subscribe(topic)
280
+ with self._diagnostics.timer("subscribe"):
281
+ await self._client.subscribe(topic)
266
282
  except MqttError as err:
267
283
  # Clean up the callback if subscription fails
268
284
  unsub()
269
285
  self._client_subscribed_topics.discard(topic)
270
286
  raise MqttSessionException(f"Error subscribing to topic: {err}") from err
271
287
  else:
288
+ self._diagnostics.increment("subscribe_pending")
272
289
  _LOGGER.debug("Client not connected, will establish subscription later")
273
290
 
274
291
  def schedule_unsubscribe() -> None:
@@ -301,10 +318,11 @@ class RoborockMqttSession(MqttSession):
301
318
  self._idle_timers[topic] = task
302
319
 
303
320
  def delayed_unsub():
321
+ self._diagnostics.increment("unsubscribe")
304
322
  unsub() # Remove the callback from CallbackMap
305
323
  # If no more callbacks for this topic, start idle timer
306
324
  if not self._listeners.get_callbacks(topic):
307
- _LOGGER.debug("Unsubscribing topic %s, starting idle timer", topic)
325
+ self._diagnostics.increment("unsubscribe_idle_start")
308
326
  schedule_unsubscribe()
309
327
  else:
310
328
  _LOGGER.debug("Unsubscribing topic %s, still have active callbacks", topic)
@@ -320,7 +338,8 @@ class RoborockMqttSession(MqttSession):
320
338
  raise MqttSessionException("Could not publish message, MQTT client not connected")
321
339
  client = self._client
322
340
  try:
323
- await client.publish(topic, message)
341
+ with self._diagnostics.timer("publish"):
342
+ await client.publish(topic, message)
324
343
  except MqttError as err:
325
344
  raise MqttSessionException(f"Error publishing message: {err}") from err
326
345
 
@@ -333,11 +352,12 @@ class LazyMqttSession(MqttSession):
333
352
  is made.
334
353
  """
335
354
 
336
- def __init__(self, session: RoborockMqttSession) -> None:
355
+ def __init__(self, session: RoborockMqttSession, diagnostics: Diagnostics) -> None:
337
356
  """Initialize the lazy session with an existing session."""
338
357
  self._lock = asyncio.Lock()
339
358
  self._started = False
340
359
  self._session = session
360
+ self._diagnostics = diagnostics
341
361
 
342
362
  @property
343
363
  def connected(self) -> bool:
@@ -353,6 +373,7 @@ class LazyMqttSession(MqttSession):
353
373
  """Start the MQTT session if not already started."""
354
374
  async with self._lock:
355
375
  if not self._started:
376
+ self._diagnostics.increment("start")
356
377
  await self._session.start()
357
378
  self._started = True
358
379
 
@@ -403,4 +424,4 @@ async def create_lazy_mqtt_session(params: MqttParams) -> MqttSession:
403
424
  This function is a factory for creating an MQTT session that will
404
425
  only connect when the first attempt to subscribe or publish is made.
405
426
  """
406
- return LazyMqttSession(RoborockMqttSession(params))
427
+ return LazyMqttSession(RoborockMqttSession(params), diagnostics=params.diagnostics.subkey("lazy_mqtt"))
@@ -2,8 +2,9 @@
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
  from collections.abc import Callable
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
 
7
+ from roborock.diagnostics import Diagnostics
7
8
  from roborock.exceptions import RoborockException
8
9
  from roborock.mqtt.health_manager import HealthManager
9
10
 
@@ -32,6 +33,14 @@ class MqttParams:
32
33
  timeout: float = DEFAULT_TIMEOUT
33
34
  """Timeout for communications with the broker in seconds."""
34
35
 
36
+ diagnostics: Diagnostics = field(default_factory=Diagnostics)
37
+ """Diagnostics object for tracking MQTT session stats.
38
+
39
+ This defaults to a new Diagnostics object, but the common case is the
40
+ caller will provide their own (e.g., from a DeviceManager) so that the
41
+ shared MQTT session diagnostics are included in the overall diagnostics.
42
+ """
43
+
35
44
 
36
45
  class MqttSession(ABC):
37
46
  """An MQTT session for sending and receiving messages."""
@@ -5,10 +5,8 @@ from __future__ import annotations
5
5
  import base64
6
6
  import json
7
7
  import logging
8
- import math
9
8
  import secrets
10
9
  import struct
11
- import time
12
10
  from collections.abc import Callable
13
11
  from dataclasses import dataclass, field
14
12
  from enum import StrEnum
@@ -19,7 +17,7 @@ from roborock.exceptions import RoborockException, RoborockUnsupportedFeature
19
17
  from roborock.protocol import Utils
20
18
  from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
21
19
  from roborock.roborock_typing import RoborockCommand
22
- from roborock.util import get_next_int
20
+ from roborock.util import get_next_int, get_timestamp
23
21
 
24
22
  _LOGGER = logging.getLogger(__name__)
25
23
 
@@ -70,7 +68,7 @@ class RequestMessage:
70
68
 
71
69
  method: RoborockCommand | str
72
70
  params: ParamsType
73
- timestamp: int = field(default_factory=lambda: math.floor(time.time()))
71
+ timestamp: int = field(default_factory=lambda: get_timestamp())
74
72
  request_id: int = field(default_factory=lambda: get_next_int(10000, 32767))
75
73
 
76
74
  def encode_message(
@@ -1,12 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import math
4
- import time
5
3
  from dataclasses import dataclass, field
6
4
  from enum import StrEnum
7
5
 
8
6
  from roborock import RoborockEnum
9
- from roborock.util import get_next_int
7
+ from roborock.util import get_next_int, get_timestamp
10
8
 
11
9
 
12
10
  class RoborockMessageProtocol(RoborockEnum):
@@ -245,4 +243,4 @@ class RoborockMessage:
245
243
  seq: int = field(default_factory=lambda: get_next_int(100000, 999999))
246
244
  version: bytes = b"1.0"
247
245
  random: int = field(default_factory=lambda: get_next_int(10000, 99999))
248
- timestamp: int = field(default_factory=lambda: math.floor(time.time()))
246
+ timestamp: int = field(default_factory=lambda: get_timestamp())
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import datetime
5
5
  import logging
6
+ import math
7
+ import time
6
8
  from asyncio import TimerHandle
7
9
  from collections.abc import Callable, Coroutine, MutableMapping
8
10
  from typing import Any, TypeVar
@@ -97,3 +99,11 @@ def get_next_int(min_val: int, max_val: int) -> int:
97
99
  counter_map[(min_val, max_val)] = min_val
98
100
  counter_map[(min_val, max_val)] += 1
99
101
  return counter_map[(min_val, max_val)] % max_val + min_val
102
+
103
+
104
+ def get_timestamp() -> int:
105
+ """Get the current timestamp in seconds since epoch.
106
+
107
+ This is separated out to allow for easier mocking in tests.
108
+ """
109
+ return math.floor(time.time())