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.
- roborock_cli/__init__.py +3 -0
- roborock_cli/__main__.py +76 -0
- roborock_cli/_vendor/VERSION +6 -0
- roborock_cli/_vendor/__init__.py +0 -0
- roborock_cli/_vendor/roborock/__init__.py +27 -0
- roborock_cli/_vendor/roborock/broadcast_protocol.py +114 -0
- roborock_cli/_vendor/roborock/callbacks.py +130 -0
- roborock_cli/_vendor/roborock/cli.py +1338 -0
- roborock_cli/_vendor/roborock/const.py +84 -0
- roborock_cli/_vendor/roborock/data/__init__.py +9 -0
- roborock_cli/_vendor/roborock/data/b01_q10/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_code_mappings.py +213 -0
- roborock_cli/_vendor/roborock/data/b01_q10/b01_q10_containers.py +102 -0
- roborock_cli/_vendor/roborock/data/b01_q7/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_code_mappings.py +303 -0
- roborock_cli/_vendor/roborock/data/b01_q7/b01_q7_containers.py +302 -0
- roborock_cli/_vendor/roborock/data/code_mappings.py +198 -0
- roborock_cli/_vendor/roborock/data/containers.py +530 -0
- roborock_cli/_vendor/roborock/data/dyad/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_code_mappings.py +102 -0
- roborock_cli/_vendor/roborock/data/dyad/dyad_containers.py +28 -0
- roborock_cli/_vendor/roborock/data/v1/__init__.py +3 -0
- roborock_cli/_vendor/roborock/data/v1/v1_clean_modes.py +192 -0
- roborock_cli/_vendor/roborock/data/v1/v1_code_mappings.py +644 -0
- roborock_cli/_vendor/roborock/data/v1/v1_containers.py +800 -0
- roborock_cli/_vendor/roborock/data/zeo/__init__.py +2 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_code_mappings.py +138 -0
- roborock_cli/_vendor/roborock/data/zeo/zeo_containers.py +0 -0
- roborock_cli/_vendor/roborock/device_features.py +668 -0
- roborock_cli/_vendor/roborock/devices/README.md +41 -0
- roborock_cli/_vendor/roborock/devices/__init__.py +11 -0
- roborock_cli/_vendor/roborock/devices/cache.py +143 -0
- roborock_cli/_vendor/roborock/devices/device.py +240 -0
- roborock_cli/_vendor/roborock/devices/device_manager.py +269 -0
- roborock_cli/_vendor/roborock/devices/file_cache.py +79 -0
- roborock_cli/_vendor/roborock/devices/rpc/__init__.py +14 -0
- roborock_cli/_vendor/roborock/devices/rpc/a01_channel.py +94 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q10_channel.py +57 -0
- roborock_cli/_vendor/roborock/devices/rpc/b01_q7_channel.py +101 -0
- roborock_cli/_vendor/roborock/devices/rpc/v1_channel.py +457 -0
- roborock_cli/_vendor/roborock/devices/traits/__init__.py +28 -0
- roborock_cli/_vendor/roborock/devices/traits/a01/__init__.py +191 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/__init__.py +12 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/__init__.py +76 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/command.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/common.py +115 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/status.py +32 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q10/vacuum.py +81 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/__init__.py +136 -0
- roborock_cli/_vendor/roborock/devices/traits/b01/q7/clean_summary.py +75 -0
- roborock_cli/_vendor/roborock/devices/traits/traits_mixin.py +64 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/__init__.py +344 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/child_lock.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/clean_summary.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/command.py +38 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/common.py +172 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/consumeable.py +48 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/device_features.py +74 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/do_not_disturb.py +41 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/dust_collection_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/flow_led_status.py +29 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/home.py +285 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/led_status.py +43 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/map_content.py +83 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/maps.py +80 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/network_info.py +55 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/rooms.py +105 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/routines.py +26 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/smart_wash_params.py +13 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/status.py +101 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/valley_electricity_timer.py +44 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/volume.py +27 -0
- roborock_cli/_vendor/roborock/devices/traits/v1/wash_towel_mode.py +13 -0
- roborock_cli/_vendor/roborock/devices/transport/__init__.py +8 -0
- roborock_cli/_vendor/roborock/devices/transport/channel.py +32 -0
- roborock_cli/_vendor/roborock/devices/transport/local_channel.py +295 -0
- roborock_cli/_vendor/roborock/devices/transport/mqtt_channel.py +118 -0
- roborock_cli/_vendor/roborock/diagnostics.py +166 -0
- roborock_cli/_vendor/roborock/exceptions.py +95 -0
- roborock_cli/_vendor/roborock/map/__init__.py +7 -0
- roborock_cli/_vendor/roborock/map/map_parser.py +123 -0
- roborock_cli/_vendor/roborock/mqtt/__init__.py +10 -0
- roborock_cli/_vendor/roborock/mqtt/health_manager.py +60 -0
- roborock_cli/_vendor/roborock/mqtt/roborock_session.py +463 -0
- roborock_cli/_vendor/roborock/mqtt/session.py +108 -0
- roborock_cli/_vendor/roborock/protocol.py +558 -0
- roborock_cli/_vendor/roborock/protocols/__init__.py +3 -0
- roborock_cli/_vendor/roborock/protocols/a01_protocol.py +74 -0
- roborock_cli/_vendor/roborock/protocols/b01_q10_protocol.py +87 -0
- roborock_cli/_vendor/roborock/protocols/b01_q7_protocol.py +81 -0
- roborock_cli/_vendor/roborock/protocols/v1_protocol.py +271 -0
- roborock_cli/_vendor/roborock/py.typed +0 -0
- roborock_cli/_vendor/roborock/roborock_message.py +246 -0
- roborock_cli/_vendor/roborock/roborock_typing.py +382 -0
- roborock_cli/_vendor/roborock/util.py +54 -0
- roborock_cli/_vendor/roborock/web_api.py +761 -0
- roborock_cli/cli.py +715 -0
- roborock_cli/connection.py +202 -0
- roborock_cli/helpers.py +71 -0
- roborock_cli/server.py +759 -0
- roborock_cli/setup_auth.py +92 -0
- roborock_cli-0.1.1.dist-info/METADATA +172 -0
- roborock_cli-0.1.1.dist-info/RECORD +106 -0
- roborock_cli-0.1.1.dist-info/WHEEL +4 -0
- roborock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- 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"))
|