roborock-cli 0.1.1__py3-none-any.whl

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 (106) hide show
  1. roborock_cli/__init__.py +3 -0
  2. roborock_cli/__main__.py +76 -0
  3. roborock_cli/_vendor/VERSION +6 -0
  4. roborock_cli/_vendor/__init__.py +0 -0
  5. roborock_cli/_vendor/roborock/__init__.py +27 -0
  6. roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
  7. roborock_cli/_vendor/roborock/callbacks.py +130 -0
  8. roborock_cli/_vendor/roborock/cli.py +1338 -0
  9. roborock_cli/_vendor/roborock/const.py +84 -0
  10. roborock_cli/_vendor/roborock/data/__init__.py +9 -0
  11. roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
  12. roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
  13. roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
  14. roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
  15. roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
  16. roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
  17. roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
  18. roborock_cli/_vendor/roborock/data/containers.py +530 -0
  19. roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
  20. roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
  21. roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
  22. roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
  23. roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
  24. roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
  25. roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
  26. roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
  27. roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
  28. roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
  29. roborock_cli/_vendor/roborock/device_features.py +668 -0
  30. roborock_cli/_vendor/roborock/devices/README.md +41 -0
  31. roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
  32. roborock_cli/_vendor/roborock/devices/cache.py +143 -0
  33. roborock_cli/_vendor/roborock/devices/device.py +240 -0
  34. roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
  35. roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
  36. roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
  37. roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
  38. roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
  39. roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
  40. roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
  41. roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
  42. roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
  43. roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
  44. roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
  45. roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
  46. roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
  47. roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
  48. roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
  49. roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
  50. roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
  51. roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
  52. roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
  53. roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
  54. roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
  55. roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
  56. roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
  57. roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
  58. roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
  59. roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
  60. roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
  61. roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
  62. roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
  63. roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
  64. roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
  65. roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
  66. roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
  67. roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
  68. roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
  69. roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
  70. roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
  71. roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
  72. roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
  73. roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
  74. roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
  75. roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
  76. roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
  77. roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
  78. roborock_cli/_vendor/roborock/diagnostics.py +166 -0
  79. roborock_cli/_vendor/roborock/exceptions.py +95 -0
  80. roborock_cli/_vendor/roborock/map/__init__.py +7 -0
  81. roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
  82. roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
  83. roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
  84. roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
  85. roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
  86. roborock_cli/_vendor/roborock/protocol.py +558 -0
  87. roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
  88. roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
  89. roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
  90. roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
  91. roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
  92. roborock_cli/_vendor/roborock/py.typed +0 -0
  93. roborock_cli/_vendor/roborock/roborock_message.py +246 -0
  94. roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
  95. roborock_cli/_vendor/roborock/util.py +54 -0
  96. roborock_cli/_vendor/roborock/web_api.py +761 -0
  97. roborock_cli/cli.py +715 -0
  98. roborock_cli/connection.py +202 -0
  99. roborock_cli/helpers.py +71 -0
  100. roborock_cli/server.py +759 -0
  101. roborock_cli/setup_auth.py +92 -0
  102. roborock_cli-0.1.1.dist-info/METADATA +172 -0
  103. roborock_cli-0.1.1.dist-info/RECORD +106 -0
  104. roborock_cli-0.1.1.dist-info/WHEEL +4 -0
  105. roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
  106. roborock_cli-0.1.1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,123 @@
1
+ """Module for parsing v1 Roborock map content."""
2
+
3
+ import io
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+
7
+ from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
8
+ from vacuum_map_parser_base.config.drawable import Drawable
9
+ from vacuum_map_parser_base.config.image_config import ImageConfig
10
+ from vacuum_map_parser_base.config.size import Size, Sizes
11
+ from vacuum_map_parser_base.map_data import MapData
12
+ from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
13
+
14
+ from roborock_cli._vendor.roborock.exceptions import RoborockException
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+ DEFAULT_DRAWABLES = {
19
+ Drawable.CHARGER: True,
20
+ Drawable.CLEANED_AREA: False,
21
+ Drawable.GOTO_PATH: False,
22
+ Drawable.IGNORED_OBSTACLES: False,
23
+ Drawable.IGNORED_OBSTACLES_WITH_PHOTO: False,
24
+ Drawable.MOP_PATH: False,
25
+ Drawable.NO_CARPET_AREAS: False,
26
+ Drawable.NO_GO_AREAS: False,
27
+ Drawable.NO_MOPPING_AREAS: False,
28
+ Drawable.OBSTACLES: False,
29
+ Drawable.OBSTACLES_WITH_PHOTO: False,
30
+ Drawable.PATH: True,
31
+ Drawable.PREDICTED_PATH: False,
32
+ Drawable.VACUUM_POSITION: True,
33
+ Drawable.VIRTUAL_WALLS: False,
34
+ Drawable.ZONES: False,
35
+ }
36
+ DEFAULT_MAP_SCALE = 4
37
+ MAP_FILE_FORMAT = "PNG"
38
+
39
+
40
+ def _default_drawable_factory() -> list[Drawable]:
41
+ return [drawable for drawable, default_value in DEFAULT_DRAWABLES.items() if default_value]
42
+
43
+
44
+ @dataclass
45
+ class MapParserConfig:
46
+ """Configuration for the Roborock map parser."""
47
+
48
+ drawables: list[Drawable] = field(default_factory=_default_drawable_factory)
49
+ """List of drawables to include in the map rendering."""
50
+
51
+ show_background: bool = True
52
+ """Whether to show the background of the map."""
53
+
54
+ show_walls: bool = True
55
+ """Whether to show the walls of the map."""
56
+
57
+ show_rooms: bool = True
58
+ """Whether to show the rooms of the map."""
59
+
60
+ map_scale: int = DEFAULT_MAP_SCALE
61
+ """Scale factor for the map."""
62
+
63
+
64
+ @dataclass
65
+ class ParsedMapData:
66
+ """Roborock Map Data.
67
+
68
+ This class holds the parsed map data and the rendered image.
69
+ """
70
+
71
+ image_content: bytes | None
72
+ """The rendered image of the map in PNG format."""
73
+
74
+ map_data: MapData | None
75
+ """The parsed map data which contains metadata for points on the map."""
76
+
77
+
78
+ class MapParser:
79
+ """Roborock Map Parser.
80
+
81
+ This class is used to parse the map data from the device and render it into an image.
82
+ """
83
+
84
+ def __init__(self, config: MapParserConfig) -> None:
85
+ """Initialize the MapParser."""
86
+ self._map_parser = _create_map_data_parser(config)
87
+
88
+ def parse(self, map_bytes: bytes) -> ParsedMapData | None:
89
+ """Parse map_bytes and return MapData and the image."""
90
+ try:
91
+ parsed_map = self._map_parser.parse(map_bytes)
92
+ except (IndexError, ValueError) as err:
93
+ raise RoborockException("Failed to parse map data") from err
94
+ if parsed_map.image is None:
95
+ raise RoborockException("Failed to render map image")
96
+ img_byte_arr = io.BytesIO()
97
+ parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT)
98
+ return ParsedMapData(image_content=img_byte_arr.getvalue(), map_data=parsed_map)
99
+
100
+
101
+ def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
102
+ """Create a RoborockMapDataParser based on the config entry."""
103
+ color_dicts = {}
104
+ room_colors = {}
105
+
106
+ if not config.show_background:
107
+ color_dicts[SupportedColor.MAP_OUTSIDE] = (0, 0, 0, 0)
108
+
109
+ if not config.show_walls:
110
+ color_dicts[SupportedColor.GREY_WALL] = (0, 0, 0, 0)
111
+ color_dicts[SupportedColor.MAP_WALL] = (0, 0, 0, 0)
112
+ color_dicts[SupportedColor.MAP_WALL_V2] = (0, 0, 0, 0)
113
+
114
+ if not config.show_rooms:
115
+ room_colors = {str(x): (0, 0, 0, 0) for x in range(1, 32)}
116
+
117
+ return RoborockMapDataParser(
118
+ ColorsPalette(color_dicts, room_colors),
119
+ Sizes({k: v * config.map_scale for k, v in Sizes.SIZES.items() if k != Size.MOP_PATH_WIDTH}),
120
+ config.drawables,
121
+ ImageConfig(scale=config.map_scale),
122
+ [],
123
+ )
@@ -0,0 +1,10 @@
1
+ """This module contains the low level MQTT client for the Roborock vacuum cleaner.
2
+
3
+ This is not meant to be used directly, but rather as a base for the higher level
4
+ modules.
5
+ """
6
+
7
+ # This module is part of the Roborock Python library, which provides a way to
8
+ # interact with Roborock devices using MQTT. It is not intended to be used directly,
9
+ # but rather as a base for higher level modules.
10
+ __all__: list[str] = []
@@ -0,0 +1,60 @@
1
+ """A health manager for monitoring MQTT connections to Roborock devices.
2
+
3
+ We observe a problem where sometimes the MQTT connection appears to be alive but
4
+ no messages are being received. To mitigate this, we track consecutive timeouts
5
+ and restart the connection if too many timeouts occur in succession.
6
+ """
7
+
8
+ import datetime
9
+ import logging
10
+ from collections.abc import Awaitable, Callable
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ # Number of consecutive timeouts before considering the connection unhealthy.
15
+ TIMEOUT_THRESHOLD = 3
16
+
17
+ # We won't restart the session more often than this interval.
18
+ RESTART_COOLDOWN = datetime.timedelta(minutes=30)
19
+
20
+
21
+ class HealthManager:
22
+ """Manager for monitoring the health of MQTT connections.
23
+
24
+ This tracks communication timeouts and can trigger restarts of the MQTT
25
+ session if too many timeouts occur in succession.
26
+ """
27
+
28
+ def __init__(self, restart: Callable[[], Awaitable[None]]) -> None:
29
+ """Initialize the health manager.
30
+
31
+ Args:
32
+ restart: A callable to restart the MQTT session.
33
+ """
34
+ self._consecutive_timeouts = 0
35
+ self._restart = restart
36
+ self._last_restart: datetime.datetime | None = None
37
+
38
+ async def on_success(self) -> None:
39
+ """Record a successful communication event."""
40
+ self._consecutive_timeouts = 0
41
+
42
+ async def on_timeout(self) -> None:
43
+ """Record a timeout event.
44
+
45
+ This may trigger a restart of the MQTT session if too many timeouts
46
+ have occurred in succession.
47
+ """
48
+ self._consecutive_timeouts += 1
49
+ if self._consecutive_timeouts >= TIMEOUT_THRESHOLD:
50
+ now = datetime.datetime.now(datetime.UTC)
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
+ )
58
+ await self._restart()
59
+ self._last_restart = now
60
+ self._consecutive_timeouts = 0
@@ -0,0 +1,463 @@
1
+ """An MQTT session for sending and receiving messages.
2
+
3
+ See create_mqtt_session for a factory function to create an MQTT session.
4
+
5
+ This is a thin wrapper around the async MQTT client that handles dispatching messages
6
+ from a topic to a callback function, since the async MQTT client does not
7
+ support this out of the box. It also handles the authentication process and
8
+ receiving messages from the vacuum cleaner.
9
+ """
10
+
11
+ import asyncio
12
+ import datetime
13
+ import logging
14
+ import ssl
15
+ from collections.abc import Callable
16
+ from contextlib import asynccontextmanager
17
+
18
+ import aiomqtt
19
+ import certifi
20
+ from aiomqtt import MqttCodeError, MqttError, TLSParameters
21
+
22
+ from roborock_cli._vendor.roborock.callbacks import CallbackMap
23
+ from roborock_cli._vendor.roborock.diagnostics import Diagnostics, redact_topic_name
24
+
25
+ from .health_manager import HealthManager
26
+ from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+ _MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt")
30
+
31
+ CLIENT_KEEPALIVE = datetime.timedelta(seconds=45)
32
+ TOPIC_KEEPALIVE = datetime.timedelta(seconds=60)
33
+
34
+ # Exponential backoff parameters
35
+ MIN_BACKOFF_INTERVAL = datetime.timedelta(seconds=10)
36
+ MAX_BACKOFF_INTERVAL = datetime.timedelta(hours=6)
37
+ BACKOFF_MULTIPLIER = 1.5
38
+
39
+
40
+ class MqttReasonCode:
41
+ """MQTT Reason Codes used by Roborock devices.
42
+
43
+ This is a subset of paho.mqtt.reasoncodes.ReasonCode where we would like
44
+ different error handling behavior.
45
+ """
46
+
47
+ RC_ERROR_UNAUTHORIZED = 135
48
+
49
+
50
+ class RoborockMqttSession(MqttSession):
51
+ """An MQTT session for sending and receiving messages.
52
+
53
+ You can start a session invoking the start() method which will connect to
54
+ the MQTT broker. A caller may subscribe to a topic, and the session keeps
55
+ track of which callbacks to invoke for each topic.
56
+
57
+ The client is run as a background task that will run until shutdown. Once
58
+ connected, the client will wait for messages to be received in a loop. If
59
+ the connection is lost, the client will be re-created and reconnected. There
60
+ is backoff to avoid spamming the broker with connection attempts.
61
+
62
+ Reconnect attempts are deferred while there are no active subscriptions,
63
+ which avoids unnecessary reconnect churn for idle sessions. Reconnects
64
+ resume as soon as a subscription is added again. The client automatically
65
+ re-establishes any existing subscriptions when the connection returns.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ params: MqttParams,
71
+ topic_idle_timeout: datetime.timedelta = TOPIC_KEEPALIVE,
72
+ ):
73
+ self._params = params
74
+ self._reconnect_task: asyncio.Task[None] | None = None
75
+ self._healthy = False
76
+ self._stop = False
77
+ self._backoff = MIN_BACKOFF_INTERVAL
78
+ self._client: aiomqtt.Client | None = None
79
+ self._client_subscribed_topics: set[str] = set()
80
+ self._client_lock = asyncio.Lock()
81
+ self._listeners: CallbackMap[str, bytes] = CallbackMap(_LOGGER)
82
+ self._connection_task: asyncio.Task[None] | None = None
83
+ self._topic_idle_timeout = topic_idle_timeout
84
+ self._idle_timers: dict[str, asyncio.Task[None]] = {}
85
+ self._diagnostics = params.diagnostics
86
+ self._health_manager = HealthManager(self.restart)
87
+ self._unauthorized_hook = params.unauthorized_hook
88
+
89
+ @property
90
+ def connected(self) -> bool:
91
+ """True if the session is connected to the broker."""
92
+ return self._healthy
93
+
94
+ @property
95
+ def health_manager(self) -> HealthManager:
96
+ """Return the health manager for the session."""
97
+ return self._health_manager
98
+
99
+ async def start(self) -> None:
100
+ """Start the MQTT session.
101
+
102
+ This has special behavior for the first connection attempt where any
103
+ failures are raised immediately. This is to allow the caller to
104
+ handle the failure and retry if desired itself. Once connected,
105
+ the session will retry connecting in the background.
106
+ """
107
+ self._diagnostics.increment("start_attempt")
108
+ start_future: asyncio.Future[None] = asyncio.Future()
109
+ loop = asyncio.get_event_loop()
110
+ self._reconnect_task = loop.create_task(self._run_reconnect_loop(start_future))
111
+ try:
112
+ await start_future
113
+ except MqttCodeError as err:
114
+ self._diagnostics.increment(f"start_failure:{err.rc}")
115
+ if err.rc == MqttReasonCode.RC_ERROR_UNAUTHORIZED:
116
+ raise MqttSessionUnauthorized(f"Authorization error starting MQTT session: {err}") from err
117
+ raise MqttSessionException(f"Error starting MQTT session: {err}") from err
118
+ except MqttError as err:
119
+ self._diagnostics.increment("start_failure:unknown")
120
+ raise MqttSessionException(f"Error starting MQTT session: {err}") from err
121
+ except Exception as err:
122
+ self._diagnostics.increment("start_failure:uncaught")
123
+ raise MqttSessionException(f"Unexpected error starting session: {err}") from err
124
+ else:
125
+ self._diagnostics.increment("start_success")
126
+ _LOGGER.debug("MQTT session started successfully")
127
+
128
+ async def close(self) -> None:
129
+ """Cancels the MQTT loop and shutdown the client library."""
130
+ self._diagnostics.increment("close")
131
+ self._stop = True
132
+ tasks = [task for task in [self._connection_task, self._reconnect_task, *self._idle_timers.values()] if task]
133
+ self._connection_task = None
134
+ self._reconnect_task = None
135
+ self._idle_timers.clear()
136
+
137
+ for task in tasks:
138
+ task.cancel()
139
+ try:
140
+ await asyncio.gather(*tasks, return_exceptions=True)
141
+ except asyncio.CancelledError:
142
+ pass
143
+
144
+ self._healthy = False
145
+
146
+ async def restart(self) -> None:
147
+ """Force the session to disconnect and reconnect.
148
+
149
+ The active connection task will be cancelled and restarted in the background, retried by
150
+ the reconnect loop. This is a no-op if there is no active connection.
151
+ """
152
+ _LOGGER.info("Forcing MQTT session restart")
153
+ self._diagnostics.increment("restart")
154
+ if self._connection_task:
155
+ self._connection_task.cancel()
156
+ else:
157
+ _LOGGER.debug("No message loop task to cancel")
158
+
159
+ async def _run_reconnect_loop(self, start_future: asyncio.Future[None] | None) -> None:
160
+ """Run the MQTT loop."""
161
+ _LOGGER.info("Starting MQTT session")
162
+ self._diagnostics.increment("start_loop")
163
+ while True:
164
+ try:
165
+ self._connection_task = asyncio.create_task(self._run_connection(start_future))
166
+ await self._connection_task
167
+ except asyncio.CancelledError:
168
+ _LOGGER.debug("MQTT connection task cancelled")
169
+ except Exception:
170
+ # Exceptions are logged and handled in _run_connection.
171
+ # There is a special case for exceptions on startup where we return
172
+ # immediately. Otherwise, we let the reconnect loop retry with
173
+ # backoff when the reconnect loop is active.
174
+ if start_future and start_future.done() and start_future.exception():
175
+ return
176
+
177
+ self._healthy = False
178
+ start_future = None
179
+ if self._stop:
180
+ _LOGGER.debug("MQTT session closed, stopping retry loop")
181
+ return
182
+ if not self._client_subscribed_topics and not self._listeners.keys():
183
+ _LOGGER.debug("MQTT session disconnected with no active subscriptions, deferring reconnect")
184
+ self._diagnostics.increment("reconnect_deferred")
185
+ while not self._stop and not self._client_subscribed_topics and not self._listeners.keys():
186
+ await asyncio.sleep(0.1)
187
+ if self._stop:
188
+ _LOGGER.debug("MQTT session closed while waiting for active subscriptions")
189
+ return
190
+ self._backoff = MIN_BACKOFF_INTERVAL
191
+ continue
192
+ _LOGGER.info("MQTT session disconnected, retrying in %s seconds", self._backoff.total_seconds())
193
+ self._diagnostics.increment("reconnect_wait")
194
+ await asyncio.sleep(self._backoff.total_seconds())
195
+ self._backoff = min(self._backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
196
+
197
+ async def _run_connection(self, start_future: asyncio.Future[None] | None) -> None:
198
+ """Connect to the MQTT broker and listen for messages.
199
+
200
+ This is the primary connection loop for the MQTT session that is
201
+ long running and processes incoming messages. If the connection
202
+ is lost, this method will exit.
203
+ """
204
+ try:
205
+ with self._diagnostics.timer("connection"):
206
+ async with self._mqtt_client(self._params) as client:
207
+ self._backoff = MIN_BACKOFF_INTERVAL
208
+ self._healthy = True
209
+ _LOGGER.info("MQTT Session connected.")
210
+ if start_future and not start_future.done():
211
+ start_future.set_result(None)
212
+
213
+ _LOGGER.debug("Processing MQTT messages")
214
+ async for message in client.messages:
215
+ _LOGGER.debug("Received message: %s", message)
216
+ with self._diagnostics.timer("dispatch_message"):
217
+ self._listeners(message.topic.value, message.payload)
218
+ except MqttCodeError as err:
219
+ self._diagnostics.increment(f"connect_failure:{err.rc}")
220
+ if start_future and not start_future.done():
221
+ _LOGGER.debug("MQTT error starting session: %s", err)
222
+ start_future.set_exception(err)
223
+ else:
224
+ _LOGGER.debug("MQTT error: %s", err)
225
+ if err.rc == MqttReasonCode.RC_ERROR_UNAUTHORIZED and self._unauthorized_hook:
226
+ _LOGGER.info("MQTT unauthorized/rate-limit error received, setting backoff to maximum")
227
+ self._unauthorized_hook()
228
+ self._backoff = MAX_BACKOFF_INTERVAL
229
+ raise
230
+ except MqttError as err:
231
+ self._diagnostics.increment("connect_failure:unknown")
232
+ if start_future and not start_future.done():
233
+ _LOGGER.info("MQTT error starting session: %s", err)
234
+ start_future.set_exception(err)
235
+ else:
236
+ _LOGGER.info("MQTT error: %s", err)
237
+ raise
238
+ except Exception as err:
239
+ self._diagnostics.increment("connect_failure:uncaught")
240
+ # This error is thrown when the MQTT loop is cancelled
241
+ # and the generator is not stopped.
242
+ if "generator didn't stop" in str(err) or "generator didn't yield" in str(err):
243
+ _LOGGER.debug("MQTT loop was cancelled")
244
+ return
245
+ if start_future and not start_future.done():
246
+ _LOGGER.error("Uncaught error starting MQTT session: %s", err)
247
+ start_future.set_exception(err)
248
+ else:
249
+ _LOGGER.exception("Uncaught error during MQTT session: %s", err)
250
+ raise
251
+
252
+ @asynccontextmanager
253
+ async def _mqtt_client(self, params: MqttParams) -> aiomqtt.Client:
254
+ """Connect to the MQTT broker and listen for messages."""
255
+ _LOGGER.debug("Connecting to %s:%s for %s", params.host, params.port, params.username)
256
+ tls_params = None
257
+ if params.tls:
258
+ tls_params = TLSParameters(
259
+ ca_certs=certifi.where(),
260
+ cert_reqs=ssl.CERT_REQUIRED if params.verify_tls else ssl.CERT_NONE,
261
+ )
262
+ try:
263
+ async with aiomqtt.Client(
264
+ hostname=params.host,
265
+ port=params.port,
266
+ username=params.username,
267
+ password=params.password,
268
+ keepalive=int(CLIENT_KEEPALIVE.total_seconds()),
269
+ protocol=aiomqtt.ProtocolVersion.V5,
270
+ tls_params=tls_params,
271
+ timeout=params.timeout,
272
+ logger=_MQTT_LOGGER,
273
+ ) as client:
274
+ _LOGGER.debug("Connected to MQTT broker")
275
+ # Re-establish any existing subscriptions
276
+ async with self._client_lock:
277
+ self._client = client
278
+ for topic in self._client_subscribed_topics:
279
+ self._diagnostics.increment("resubscribe")
280
+ _LOGGER.debug("Re-establishing subscription to topic %s", redact_topic_name(topic))
281
+ # TODO: If this fails it will break the whole connection. Make
282
+ # this retry again in the background with backoff.
283
+ await client.subscribe(topic)
284
+
285
+ yield client
286
+ finally:
287
+ async with self._client_lock:
288
+ self._client = None
289
+
290
+ async def subscribe(self, topic: str, callback: Callable[[bytes], None]) -> Callable[[], None]:
291
+ """Subscribe to messages on the specified topic and invoke the callback for new messages.
292
+
293
+ The callback will be called with the message payload as a bytes object. The callback
294
+ should not block since it runs in the async loop. It should not raise any exceptions.
295
+
296
+ The returned callable unsubscribes from the topic when called, but will delay actual
297
+ unsubscription for the idle timeout period. If a new subscription comes in during the
298
+ timeout, the timer is cancelled and the subscription is reused.
299
+ """
300
+ _LOGGER.debug("Subscribing to topic %s", redact_topic_name(topic))
301
+
302
+ # If there is an idle timer for this topic, cancel it (reuse subscription)
303
+ if idle_timer := self._idle_timers.pop(topic, None):
304
+ self._diagnostics.increment("unsubscribe_idle_cancel")
305
+ idle_timer.cancel()
306
+ _LOGGER.debug("Cancelled idle timer for topic %s (reused subscription)", redact_topic_name(topic))
307
+
308
+ unsub = self._listeners.add_callback(topic, callback)
309
+
310
+ async with self._client_lock:
311
+ if topic not in self._client_subscribed_topics:
312
+ self._client_subscribed_topics.add(topic)
313
+ if self._client:
314
+ _LOGGER.debug("Establishing subscription to topic %s", topic)
315
+ try:
316
+ with self._diagnostics.timer("subscribe"):
317
+ await self._client.subscribe(topic)
318
+ except MqttError as err:
319
+ # Clean up the callback if subscription fails
320
+ unsub()
321
+ self._client_subscribed_topics.discard(topic)
322
+ raise MqttSessionException(f"Error subscribing to topic: {err}") from err
323
+ else:
324
+ self._diagnostics.increment("subscribe_pending")
325
+ _LOGGER.debug("Client not connected, will establish subscription later")
326
+
327
+ def schedule_unsubscribe() -> None:
328
+ async def idle_unsubscribe():
329
+ try:
330
+ await asyncio.sleep(self._topic_idle_timeout.total_seconds())
331
+ # Only unsubscribe if there are no callbacks left for this topic
332
+ if not self._listeners.get_callbacks(topic):
333
+ async with self._client_lock:
334
+ # Check again if we have listeners, in case a subscribe happened
335
+ # while we were waiting for the lock or after we popped the timer.
336
+ if self._listeners.get_callbacks(topic):
337
+ _LOGGER.debug("Skipping unsubscribe for %s, new listeners added", topic)
338
+ return
339
+
340
+ self._idle_timers.pop(topic, None)
341
+ self._client_subscribed_topics.discard(topic)
342
+
343
+ if self._client:
344
+ _LOGGER.debug("Idle timeout expired, unsubscribing from topic %s", topic)
345
+ try:
346
+ await self._client.unsubscribe(topic)
347
+ except MqttError as err:
348
+ _LOGGER.warning("Error unsubscribing from topic %s: %s", topic, err)
349
+ except asyncio.CancelledError:
350
+ _LOGGER.debug("Idle unsubscribe for topic %s cancelled", topic)
351
+
352
+ # Start the idle timer task
353
+ task = asyncio.create_task(idle_unsubscribe())
354
+ self._idle_timers[topic] = task
355
+
356
+ def delayed_unsub():
357
+ self._diagnostics.increment("unsubscribe")
358
+ unsub() # Remove the callback from CallbackMap
359
+ # If no more callbacks for this topic, start idle timer
360
+ if not self._listeners.get_callbacks(topic):
361
+ self._diagnostics.increment("unsubscribe_idle_start")
362
+ schedule_unsubscribe()
363
+ else:
364
+ _LOGGER.debug("Unsubscribing topic %s, still have active callbacks", topic)
365
+
366
+ return delayed_unsub
367
+
368
+ async def publish(self, topic: str, message: bytes) -> None:
369
+ """Publish a message on the topic."""
370
+ _LOGGER.debug("Sending message to topic %s: %s", topic, message)
371
+ client: aiomqtt.Client
372
+ async with self._client_lock:
373
+ if self._client is None:
374
+ raise MqttSessionException("Could not publish message, MQTT client not connected")
375
+ client = self._client
376
+ try:
377
+ with self._diagnostics.timer("publish"):
378
+ await client.publish(topic, message)
379
+ except MqttError as err:
380
+ raise MqttSessionException(f"Error publishing message: {err}") from err
381
+
382
+
383
+ class LazyMqttSession(MqttSession):
384
+ """An MQTT session that is started on first attempt to subscribe.
385
+
386
+ This is a wrapper around an existing MqttSession that will only start
387
+ the underlying session when the first attempt to subscribe or publish
388
+ is made.
389
+ """
390
+
391
+ def __init__(self, session: RoborockMqttSession, diagnostics: Diagnostics) -> None:
392
+ """Initialize the lazy session with an existing session."""
393
+ self._lock = asyncio.Lock()
394
+ self._started = False
395
+ self._session = session
396
+ self._diagnostics = diagnostics
397
+
398
+ @property
399
+ def connected(self) -> bool:
400
+ """True if the session is connected to the broker."""
401
+ return self._session.connected
402
+
403
+ @property
404
+ def health_manager(self) -> HealthManager:
405
+ """Return the health manager for the session."""
406
+ return self._session.health_manager
407
+
408
+ async def _maybe_start(self) -> None:
409
+ """Start the MQTT session if not already started."""
410
+ async with self._lock:
411
+ if not self._started:
412
+ self._diagnostics.increment("start")
413
+ await self._session.start()
414
+ self._started = True
415
+
416
+ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> Callable[[], None]:
417
+ """Invoke the callback when messages are received on the topic.
418
+
419
+ The returned callable unsubscribes from the topic when called.
420
+ """
421
+ await self._maybe_start()
422
+ return await self._session.subscribe(device_id, callback)
423
+
424
+ async def publish(self, topic: str, message: bytes) -> None:
425
+ """Publish a message on the specified topic.
426
+
427
+ This will raise an exception if the message could not be sent.
428
+ """
429
+ await self._maybe_start()
430
+ return await self._session.publish(topic, message)
431
+
432
+ async def close(self) -> None:
433
+ """Cancels the mqtt loop.
434
+
435
+ This will close the underlying session and will not allow it to be
436
+ restarted again.
437
+ """
438
+ await self._session.close()
439
+
440
+ async def restart(self) -> None:
441
+ """Force the session to disconnect and reconnect."""
442
+ await self._session.restart()
443
+
444
+
445
+ async def create_mqtt_session(params: MqttParams) -> MqttSession:
446
+ """Create an MQTT session.
447
+
448
+ This function is a factory for creating an MQTT session. This will
449
+ raise an exception if initial attempt to connect fails. Once connected,
450
+ the session will retry connecting on failure in the background.
451
+ """
452
+ session = RoborockMqttSession(params)
453
+ await session.start()
454
+ return session
455
+
456
+
457
+ async def create_lazy_mqtt_session(params: MqttParams) -> MqttSession:
458
+ """Create a lazy MQTT session.
459
+
460
+ This function is a factory for creating an MQTT session that will
461
+ only connect when the first attempt to subscribe or publish is made.
462
+ """
463
+ return LazyMqttSession(RoborockMqttSession(params), diagnostics=params.diagnostics.subkey("lazy_mqtt"))