Skip to content

Commit e6d48af

Browse files
feat: improving cache and refactoring (#82)
1 parent 6681698 commit e6d48af

File tree

7 files changed

+258
-117
lines changed

7 files changed

+258
-117
lines changed

roborock/api.py

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import aiohttp
1919

2020
from .code_mappings import RoborockDockTypeCode
21-
from .command_cache import AttributeCache, CacheableAttribute, CommandType, parse_method
21+
from .command_cache import CacheableAttribute, CommandType, RoborockAttribute, create_cache_map, parse_method
2222
from .containers import (
2323
ChildLockStatus,
2424
CleanRecord,
@@ -55,9 +55,9 @@
5555
)
5656
from .protocol import Utils
5757
from .roborock_future import RoborockFuture
58-
from .roborock_message import RoborockDataProtocol, RoborockMessage
58+
from .roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
5959
from .roborock_typing import DeviceProp, DockSummary, RoborockCommand
60-
from .util import unpack_list
60+
from .util import RepeatableTask, get_running_loop_or_create_one, unpack_list
6161

6262
_LOGGER = logging.getLogger(__name__)
6363
KEEPALIVE = 60
@@ -94,8 +94,42 @@ async def request(self, method: str, url: str, params=None, data=None, headers=N
9494
return await resp.json()
9595

9696

97+
EVICT_TIME = 60
98+
99+
100+
class AttributeCache:
101+
def __init__(self, attribute: RoborockAttribute, api: RoborockClient):
102+
self.attribute = attribute
103+
self.api = api
104+
self.attribute = attribute
105+
self.task = RepeatableTask(self.api.event_loop, self._async_value, EVICT_TIME)
106+
self._value: Any = None
107+
108+
@property
109+
def value(self):
110+
return self._value
111+
112+
async def _async_value(self):
113+
self._value = await self.api._send_command(self.attribute.get_command)
114+
return self._value
115+
116+
async def async_value(self):
117+
if self._value is None:
118+
return await self.task.reset()
119+
return self._value
120+
121+
def stop(self):
122+
self.task.cancel()
123+
124+
async def update_value(self, params):
125+
response = await self.api._send_command(self.attribute.set_command, params)
126+
await self._async_value()
127+
return response
128+
129+
97130
class RoborockClient:
98131
def __init__(self, endpoint: str, device_info: DeviceData) -> None:
132+
self.event_loop = get_running_loop_or_create_one()
99133
self.device_info = device_info
100134
self._endpoint = endpoint
101135
self._nonce = secrets.token_bytes(16)
@@ -105,11 +139,15 @@ def __init__(self, endpoint: str, device_info: DeviceData) -> None:
105139
self.keep_alive = KEEPALIVE
106140
self._diagnostic_data: dict[str, dict[str, Any]] = {}
107141
self.cache: dict[CacheableAttribute, AttributeCache] = {
108-
cacheable_attribute: AttributeCache(cacheable_attribute) for cacheable_attribute in CacheableAttribute
142+
cacheable_attribute: AttributeCache(attr, self) for cacheable_attribute, attr in create_cache_map().items()
109143
}
110144

111145
def __del__(self) -> None:
146+
self.release()
147+
148+
def release(self):
112149
self.sync_disconnect()
150+
[item.stop() for item in self.cache.values()]
113151

114152
@property
115153
def diagnostic_data(self) -> dict:
@@ -138,7 +176,10 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None:
138176
self._last_device_msg_in = self.time_func()
139177
for data in messages:
140178
protocol = data.protocol
141-
if data.payload and (protocol == 102 or protocol == 4):
179+
if data.payload and protocol in [
180+
RoborockMessageProtocol.RPC_RESPONSE,
181+
RoborockMessageProtocol.GENERAL_REQUEST,
182+
]:
142183
payload = json.loads(data.payload.decode())
143184
for data_point_number, data_point in payload.get("dps").items():
144185
if data_point_number == "102":
@@ -162,7 +203,7 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None:
162203
if isinstance(result, list) and len(result) == 1:
163204
result = result[0]
164205
queue.resolve((result, None))
165-
elif data.payload and protocol == 301:
206+
elif data.payload and protocol == RoborockMessageProtocol.MAP_RESPONSE:
166207
payload = data.payload[0:24]
167208
[endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", payload)
168209
if endpoint.decode().startswith(self._endpoint):
@@ -242,6 +283,9 @@ def _get_payload(
242283
)
243284
return request_id, timestamp, payload
244285

286+
async def send_message(self, roborock_message: RoborockMessage):
287+
raise NotImplementedError
288+
245289
async def _send_command(
246290
self,
247291
method: RoborockCommand,
@@ -257,20 +301,17 @@ async def send_command(
257301
return_type: Optional[Type[RT]] = None,
258302
) -> RT:
259303
parsed_method = parse_method(method)
260-
if parsed_method is not None and parsed_method.type == CommandType.GET:
261-
cache = self.cache[parsed_method.attribute].value
262-
if cache is not None:
263-
if return_type:
264-
return return_type.from_dict(cache)
265-
return cache
266-
267-
response = await self._send_command(method, params)
268-
304+
cache = None
269305
if parsed_method is not None:
270-
if parsed_method.type == CommandType.SET:
271-
self.cache[parsed_method.attribute].evict()
272-
elif parsed_method.type == CommandType.GET:
273-
self.cache[parsed_method.attribute].load(response)
306+
cache = self.cache[parsed_method.attribute]
307+
response: Any = None
308+
if cache is not None:
309+
if parsed_method.type == CommandType.GET:
310+
response = await cache.async_value()
311+
elif parsed_method.type == CommandType.SET:
312+
response = await cache.update_value(params)
313+
else:
314+
response = await self._send_command(method, params)
274315
if return_type:
275316
return return_type.from_dict(response)
276317
return response

roborock/cloud_api.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .exceptions import CommandVacuumError, RoborockException, VacuumError
1616
from .protocol import MessageParser, Utils
1717
from .roborock_future import RoborockFuture
18-
from .roborock_message import RoborockMessage
18+
from .roborock_message import RoborockMessage, RoborockMessageProtocol
1919
from .roborock_typing import RoborockCommand
2020

2121
_LOGGER = logging.getLogger(__name__)
@@ -149,33 +149,44 @@ def _send_msg_raw(self, msg: bytes) -> None:
149149
if info.rc != mqtt.MQTT_ERR_SUCCESS:
150150
raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})")
151151

152-
async def _send_command(
153-
self,
154-
method: RoborockCommand,
155-
params: Optional[list | dict] = None,
156-
):
152+
async def send_message(self, roborock_message: RoborockMessage):
157153
await self.validate_connection()
158-
request_id, timestamp, payload = super()._get_payload(method, params, True)
159-
_LOGGER.debug(f"id={request_id} Requesting method {method} with {params}")
160-
request_protocol = 101
161-
response_protocol = 301 if method in COMMANDS_SECURED else 102
162-
roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload)
154+
method = roborock_message.get_method()
155+
params = roborock_message.get_params()
156+
request_id = roborock_message.get_request_id()
157+
if request_id is None:
158+
raise RoborockException(f"Failed build message {roborock_message}")
159+
response_protocol = (
160+
RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE
161+
)
162+
163163
local_key = self.device_info.device.local_key
164164
msg = MessageParser.build(roborock_message, local_key, False)
165+
_LOGGER.debug(f"id={request_id} Requesting method {method} with {params}")
165166
self._send_msg_raw(msg)
166167
(response, err) = await self._async_response(request_id, response_protocol)
167168
self._diagnostic_data[method if method is not None else "unknown"] = {
168-
"params": params,
169+
"params": roborock_message.get_params(),
169170
"response": response,
170171
"error": err,
171172
}
172173
if err:
173174
raise CommandVacuumError(method, err) from err
174-
if response_protocol == 301:
175+
if response_protocol == RoborockMessageProtocol.MAP_RESPONSE:
175176
_LOGGER.debug(f"id={request_id} Response from {method}: {len(response)} bytes")
176177
else:
177178
_LOGGER.debug(f"id={request_id} Response from {method}: {response}")
178179
return response
179180

181+
async def _send_command(
182+
self,
183+
method: RoborockCommand,
184+
params: Optional[list | dict] = None,
185+
):
186+
request_id, timestamp, payload = super()._get_payload(method, params, True)
187+
request_protocol = RoborockMessageProtocol.RPC_REQUEST
188+
roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload)
189+
return await self.send_message(roborock_message)
190+
180191
async def get_map_v1(self):
181192
return await self.send_command(RoborockCommand.GET_MAP_V1)

roborock/command_cache.py

Lines changed: 108 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3-
import time
43
from dataclasses import dataclass
54
from enum import Enum
5+
from typing import Mapping
6+
7+
from roborock import RoborockCommand
68

79
GET_PREFIX = "get_"
810
SET_PREFIX = ("set_", "change_", "close_")
@@ -30,29 +32,108 @@ class CacheableAttribute(str, Enum):
3032
wash_towel_mode = "wash_towel_mode"
3133

3234

33-
EVICT_TIME = 60
34-
35-
36-
class AttributeCache:
37-
_value: dict | None = None
38-
last_update: float | None = None
39-
40-
def __init__(self, attribute: str):
41-
self.attribute = attribute
42-
43-
@property
44-
def value(self):
45-
if self.last_update is None or time.monotonic() - self.last_update > EVICT_TIME:
46-
self._value = None
47-
return self._value
48-
49-
def load(self, value: dict):
50-
self._value = value
51-
self.last_update = time.monotonic()
52-
return self._value
53-
54-
def evict(self):
55-
self._value = None
35+
@dataclass
36+
class RoborockAttribute:
37+
attribute: str
38+
get_command: RoborockCommand
39+
set_command: RoborockCommand
40+
41+
42+
def create_cache_map():
43+
cache_map: Mapping[CacheableAttribute, RoborockAttribute] = {
44+
CacheableAttribute.sound_volume: RoborockAttribute(
45+
attribute="sound_volume",
46+
get_command=RoborockCommand.GET_SOUND_VOLUME,
47+
set_command=RoborockCommand.CHANGE_SOUND_VOLUME,
48+
),
49+
CacheableAttribute.camera_status: RoborockAttribute(
50+
attribute="camera_status",
51+
get_command=RoborockCommand.GET_CAMERA_STATUS,
52+
set_command=RoborockCommand.SET_CAMERA_STATUS,
53+
),
54+
CacheableAttribute.carpet_clean_mode: RoborockAttribute(
55+
attribute="carpet_clean_mode",
56+
get_command=RoborockCommand.GET_CARPET_CLEAN_MODE,
57+
set_command=RoborockCommand.SET_CARPET_CLEAN_MODE,
58+
),
59+
CacheableAttribute.carpet_mode: RoborockAttribute(
60+
attribute="carpet_mode",
61+
get_command=RoborockCommand.GET_CARPET_MODE,
62+
set_command=RoborockCommand.SET_CARPET_MODE,
63+
),
64+
CacheableAttribute.child_lock_status: RoborockAttribute(
65+
attribute="child_lock_status",
66+
get_command=RoborockCommand.GET_CHILD_LOCK_STATUS,
67+
set_command=RoborockCommand.SET_CHILD_LOCK_STATUS,
68+
),
69+
CacheableAttribute.collision_avoid_status: RoborockAttribute(
70+
attribute="collision_avoid_status",
71+
get_command=RoborockCommand.GET_COLLISION_AVOID_STATUS,
72+
set_command=RoborockCommand.SET_COLLISION_AVOID_STATUS,
73+
),
74+
CacheableAttribute.customize_clean_mode: RoborockAttribute(
75+
attribute="customize_clean_mode",
76+
get_command=RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE,
77+
set_command=RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE,
78+
),
79+
CacheableAttribute.custom_mode: RoborockAttribute(
80+
attribute="custom_mode",
81+
get_command=RoborockCommand.GET_CUSTOM_MODE,
82+
set_command=RoborockCommand.SET_CUSTOM_MODE,
83+
),
84+
CacheableAttribute.dnd_timer: RoborockAttribute(
85+
attribute="dnd_timer", get_command=RoborockCommand.GET_DND_TIMER, set_command=RoborockCommand.SET_DND_TIMER
86+
),
87+
CacheableAttribute.dust_collection_mode: RoborockAttribute(
88+
attribute="dust_collection_mode",
89+
get_command=RoborockCommand.GET_DUST_COLLECTION_MODE,
90+
set_command=RoborockCommand.SET_DUST_COLLECTION_MODE,
91+
),
92+
CacheableAttribute.flow_led_status: RoborockAttribute(
93+
attribute="flow_led_status",
94+
get_command=RoborockCommand.GET_FLOW_LED_STATUS,
95+
set_command=RoborockCommand.SET_FLOW_LED_STATUS,
96+
),
97+
CacheableAttribute.identify_furniture_status: RoborockAttribute(
98+
attribute="identify_furniture_status",
99+
get_command=RoborockCommand.GET_IDENTIFY_FURNITURE_STATUS,
100+
set_command=RoborockCommand.SET_IDENTIFY_FURNITURE_STATUS,
101+
),
102+
CacheableAttribute.identify_ground_material_status: RoborockAttribute(
103+
attribute="identify_ground_material_status",
104+
get_command=RoborockCommand.GET_IDENTIFY_GROUND_MATERIAL_STATUS,
105+
set_command=RoborockCommand.SET_IDENTIFY_GROUND_MATERIAL_STATUS,
106+
),
107+
CacheableAttribute.led_status: RoborockAttribute(
108+
attribute="led_status",
109+
get_command=RoborockCommand.GET_LED_STATUS,
110+
set_command=RoborockCommand.SET_LED_STATUS,
111+
),
112+
CacheableAttribute.server_timer: RoborockAttribute(
113+
attribute="server_timer",
114+
get_command=RoborockCommand.GET_SERVER_TIMER,
115+
set_command=RoborockCommand.SET_SERVER_TIMER,
116+
),
117+
CacheableAttribute.smart_wash_params: RoborockAttribute(
118+
attribute="smart_wash_params",
119+
get_command=RoborockCommand.GET_SMART_WASH_PARAMS,
120+
set_command=RoborockCommand.SET_SMART_WASH_PARAMS,
121+
),
122+
CacheableAttribute.timezone: RoborockAttribute(
123+
attribute="timezone", get_command=RoborockCommand.GET_TIMEZONE, set_command=RoborockCommand.SET_TIMEZONE
124+
),
125+
CacheableAttribute.valley_electricity_timer: RoborockAttribute(
126+
attribute="valley_electricity_timer",
127+
get_command=RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER,
128+
set_command=RoborockCommand.SET_VALLEY_ELECTRICITY_TIMER,
129+
),
130+
CacheableAttribute.wash_towel_mode: RoborockAttribute(
131+
attribute="wash_towel_mode",
132+
get_command=RoborockCommand.GET_WASH_TOWEL_MODE,
133+
set_command=RoborockCommand.SET_WASH_TOWEL_MODE,
134+
),
135+
}
136+
return cache_map
56137

57138

58139
class CommandType(Enum):
@@ -78,9 +159,7 @@ def parse_method(method: str):
78159
for prefix in SET_PREFIX:
79160
attribute = attribute.removeprefix(prefix)
80161
command_type = CommandType.SET
81-
try:
82-
cacheable_attribute = CacheableAttribute(attribute)
83-
return ParserCommand(type=command_type, attribute=cacheable_attribute)
84-
except ValueError:
85-
pass
162+
cacheable_attribute = next((attr for attr in CacheableAttribute if attr == attribute), None)
163+
if cacheable_attribute:
164+
return ParserCommand(type=command_type, attribute=CacheableAttribute(cacheable_attribute))
86165
return None

roborock/exceptions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Roborock exceptions."""
2+
from __future__ import annotations
23

34

45
class RoborockException(Exception):
@@ -24,8 +25,8 @@ class VacuumError(RoborockException):
2425
class CommandVacuumError(RoborockException):
2526
"""Class for command vacuum errors."""
2627

27-
def __init__(self, command: str, vacuum_error: VacuumError):
28-
self.message = f"{command}: {str(vacuum_error)}"
28+
def __init__(self, command: str | None, vacuum_error: VacuumError):
29+
self.message = f"{command or 'unknown'}: {str(vacuum_error)}"
2930
super().__init__(self.message)
3031

3132

0 commit comments

Comments
 (0)