Skip to content

Commit 505f5e4

Browse files
feat: adding command cache (#77)
* feat: adding command cache * chore: typo * fix: dependencies * feat: adding cache evict time
1 parent cebc9d2 commit 505f5e4

File tree

7 files changed

+133
-88
lines changed

7 files changed

+133
-88
lines changed

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ python = "^3.10"
2323
click = ">=8"
2424
aiohttp = "^3.8.2"
2525
async-timeout = "*"
26-
pycryptodome = ">=3.17,<3.19"
27-
pycryptodomex = {version = ">=3.17,<3.19", markers = "sys_platform == 'darwin'"}
28-
paho-mqtt = "~1.6.1"
29-
dacite = "~1.8.0"
26+
pycryptodome = "^3.18"
27+
pycryptodomex = {version = "^3.18", markers = "sys_platform == 'darwin'"}
28+
paho-mqtt = "^1.6.1"
29+
dacite = "^1.8.0"
3030
construct = "^2.10.68"
3131
alexapy = "^1.26.8"
3232

roborock/api.py

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
import struct
1414
import time
1515
from random import randint
16-
from typing import Any, Callable, Coroutine, Optional, Type, TypeVar
16+
from typing import Any, Callable, Coroutine, Optional, Type, TypeVar, final
1717

1818
import aiohttp
1919

2020
from .code_mappings import RoborockDockTypeCode
21+
from .command_cache import AttributeCache, CacheableAttribute, CommandType, parse_method
2122
from .containers import (
2223
ChildLockStatus,
2324
CleanRecord,
@@ -56,7 +57,7 @@
5657
from .roborock_future import RoborockFuture
5758
from .roborock_message import RoborockDataProtocol, RoborockMessage
5859
from .roborock_typing import DeviceProp, DockSummary, RoborockCommand
59-
from .util import fallback_cache, unpack_list
60+
from .util import unpack_list
6061

6162
_LOGGER = logging.getLogger(__name__)
6263
KEEPALIVE = 60
@@ -103,8 +104,9 @@ def __init__(self, endpoint: str, device_info: DeviceData) -> None:
103104
self._last_disconnection = self.time_func()
104105
self.keep_alive = KEEPALIVE
105106
self._diagnostic_data: dict[str, dict[str, Any]] = {}
106-
self.dnd_timer: DnDTimer | None = None
107-
self.valley_timer: ValleyElectricityTimer | None = None
107+
self.cache: dict[CacheableAttribute, AttributeCache] = {
108+
cacheable_attribute: AttributeCache(cacheable_attribute) for cacheable_attribute in CacheableAttribute
109+
}
108110

109111
def __del__(self) -> None:
110112
self.sync_disconnect()
@@ -240,38 +242,51 @@ def _get_payload(
240242
)
241243
return request_id, timestamp, payload
242244

245+
async def _send_command(
246+
self,
247+
method: RoborockCommand,
248+
params: Optional[list | dict] = None,
249+
):
250+
raise NotImplementedError
251+
252+
@final
243253
async def send_command(
244254
self,
245255
method: RoborockCommand,
246256
params: Optional[list | dict] = None,
247257
return_type: Optional[Type[RT]] = None,
248258
) -> RT:
249-
raise NotImplementedError
259+
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+
269+
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)
274+
if return_type:
275+
return return_type.from_dict(response)
276+
return response
250277

251-
@fallback_cache
252278
async def get_status(self) -> Status | None:
253279
_cls: Type[Status] = ModelStatus.get(
254280
self.device_info.model, S7MaxVStatus
255281
) # Default to S7 MAXV if we don't have the data
256282
return await self.send_command(RoborockCommand.GET_STATUS, return_type=_cls)
257283

258-
@fallback_cache
259284
async def get_dnd_timer(self) -> DnDTimer | None:
260-
result = await self.send_command(RoborockCommand.GET_DND_TIMER, return_type=DnDTimer)
261-
if result is not None:
262-
self.dnd_timer = result
263-
return result
285+
return await self.send_command(RoborockCommand.GET_DND_TIMER, return_type=DnDTimer)
264286

265-
@fallback_cache
266287
async def get_valley_electricity_timer(self) -> ValleyElectricityTimer | None:
267-
result = await self.send_command(
268-
RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER, return_type=ValleyElectricityTimer
269-
)
270-
if result is not None:
271-
self.valley_timer = result
272-
return result
288+
return await self.send_command(RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER, return_type=ValleyElectricityTimer)
273289

274-
@fallback_cache
275290
async def get_clean_summary(self) -> CleanSummary | None:
276291
clean_summary: dict | list | int = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY)
277292
if isinstance(clean_summary, dict):
@@ -288,27 +303,21 @@ async def get_clean_summary(self) -> CleanSummary | None:
288303
return CleanSummary(clean_time=clean_summary)
289304
return None
290305

291-
@fallback_cache
292306
async def get_clean_record(self, record_id: int) -> CleanRecord | None:
293307
return await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id], return_type=CleanRecord)
294308

295-
@fallback_cache
296309
async def get_consumable(self) -> Consumable | None:
297310
return await self.send_command(RoborockCommand.GET_CONSUMABLE, return_type=Consumable)
298311

299-
@fallback_cache
300312
async def get_wash_towel_mode(self) -> WashTowelMode | None:
301313
return await self.send_command(RoborockCommand.GET_WASH_TOWEL_MODE, return_type=WashTowelMode)
302314

303-
@fallback_cache
304315
async def get_dust_collection_mode(self) -> DustCollectionMode | None:
305316
return await self.send_command(RoborockCommand.GET_DUST_COLLECTION_MODE, return_type=DustCollectionMode)
306317

307-
@fallback_cache
308318
async def get_smart_wash_params(self) -> SmartWashParams | None:
309319
return await self.send_command(RoborockCommand.GET_SMART_WASH_PARAMS, return_type=SmartWashParams)
310320

311-
@fallback_cache
312321
async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary | None:
313322
"""Gets the status summary from the dock with the methods available for a given dock.
314323
@@ -330,7 +339,6 @@ async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary
330339
)
331340
return DockSummary(dust_collection_mode, wash_towel_mode, smart_wash_params)
332341

333-
@fallback_cache
334342
async def get_prop(self) -> DeviceProp | None:
335343
"""Gets device general properties."""
336344
[status, clean_summary, consumable] = await asyncio.gather(
@@ -356,15 +364,12 @@ async def get_prop(self) -> DeviceProp | None:
356364
)
357365
return None
358366

359-
@fallback_cache
360367
async def get_multi_maps_list(self) -> MultiMapsList | None:
361368
return await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST, return_type=MultiMapsList)
362369

363-
@fallback_cache
364370
async def get_networking(self) -> NetworkInfo | None:
365371
return await self.send_command(RoborockCommand.GET_NETWORK_INFO, return_type=NetworkInfo)
366372

367-
@fallback_cache
368373
async def get_room_mapping(self) -> list[RoomMapping] | None:
369374
"""Gets the mapping from segment id -> iot id. Only works on local api."""
370375
mapping: list = await self.send_command(RoborockCommand.GET_ROOM_MAPPING)
@@ -375,17 +380,14 @@ async def get_room_mapping(self) -> list[RoomMapping] | None:
375380
]
376381
return None
377382

378-
@fallback_cache
379383
async def get_child_lock_status(self) -> ChildLockStatus | None:
380384
"""Gets current child lock status."""
381385
return await self.send_command(RoborockCommand.GET_CHILD_LOCK_STATUS, return_type=ChildLockStatus)
382386

383-
@fallback_cache
384387
async def get_flow_led_status(self) -> FlowLedStatus | None:
385388
"""Gets current flow led status."""
386389
return await self.send_command(RoborockCommand.GET_FLOW_LED_STATUS, return_type=FlowLedStatus)
387390

388-
@fallback_cache
389391
async def get_sound_volume(self) -> int | None:
390392
"""Gets current volume level."""
391393
return await self.send_command(RoborockCommand.GET_SOUND_VOLUME)

roborock/cloud_api.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
import threading
66
import uuid
77
from asyncio import Lock
8-
from typing import Optional, Type
8+
from typing import Optional
99
from urllib.parse import urlparse
1010

1111
import paho.mqtt.client as mqtt
1212

13-
from .api import COMMANDS_SECURED, KEEPALIVE, RT, RoborockClient, md5hex
13+
from .api import COMMANDS_SECURED, KEEPALIVE, RoborockClient, md5hex
1414
from .containers import DeviceData, UserData
1515
from .exceptions import CommandVacuumError, RoborockException, VacuumError
1616
from .protocol import MessageParser, Utils
@@ -149,12 +149,11 @@ 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(
152+
async def _send_command(
153153
self,
154154
method: RoborockCommand,
155155
params: Optional[list | dict] = None,
156-
return_type: Optional[Type[RT]] = None,
157-
) -> RT:
156+
):
158157
await self.validate_connection()
159158
request_id, timestamp, payload = super()._get_payload(method, params, True)
160159
_LOGGER.debug(f"id={request_id} Requesting method {method} with {params}")
@@ -172,8 +171,6 @@ async def send_command(
172171
_LOGGER.debug(f"id={request_id} Response from {method}: {len(response)} bytes")
173172
else:
174173
_LOGGER.debug(f"id={request_id} Response from {method}: {response}")
175-
if return_type:
176-
return return_type.from_dict(response)
177174
return response
178175

179176
async def get_map_v1(self):

roborock/command_cache.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import time
4+
from dataclasses import dataclass
5+
from enum import Enum
6+
7+
GET_PREFIX = "get_"
8+
SET_PREFIX = ("set_", "change_", "close_")
9+
10+
11+
class CacheableAttribute(str, Enum):
12+
sound_volume = "sound_volume"
13+
camera_status = "camera_status"
14+
carpet_clean_mode = "carpet_clean_mode"
15+
carpet_mode = "carpet_mode"
16+
child_lock_status = "child_lock_status"
17+
collision_avoid_status = "collision_avoid_status"
18+
customize_clean_mode = "customize_clean_mode"
19+
custom_mode = "custom_mode"
20+
dnd_timer = "dnd_timer"
21+
dust_collection_mode = "dust_collection_mode"
22+
flow_led_status = "flow_led_status"
23+
identify_furniture_status = "identify_furniture_status"
24+
identify_ground_material_status = "identify_ground_material_status"
25+
led_status = "led_status"
26+
server_timer = "server_timer"
27+
smart_wash_params = "smart_wash_params"
28+
timezone = "timezone"
29+
valley_electricity_timer = "valley_electricity_timer"
30+
wash_towel_mode = "wash_towel_mode"
31+
32+
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
56+
57+
58+
class CommandType(Enum):
59+
OTHER = -1
60+
GET = 0
61+
SET = 1
62+
63+
64+
@dataclass
65+
class ParserCommand:
66+
type: CommandType
67+
attribute: CacheableAttribute
68+
69+
70+
def parse_method(method: str):
71+
if method is not None:
72+
attribute = method.lower()
73+
command_type = CommandType.OTHER
74+
if attribute.startswith(GET_PREFIX):
75+
attribute = attribute.removeprefix(GET_PREFIX)
76+
command_type = CommandType.GET
77+
elif attribute.startswith(SET_PREFIX):
78+
for prefix in SET_PREFIX:
79+
attribute = attribute.removeprefix(prefix)
80+
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
86+
return None

roborock/local_api.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import asyncio
44
import logging
55
from asyncio import Lock, TimerHandle, Transport
6-
from typing import Optional, Type
6+
from typing import Optional
77

88
import async_timeout
99

1010
from . import DeviceData
11-
from .api import COMMANDS_SECURED, QUEUE_TIMEOUT, RT, RoborockClient
11+
from .api import COMMANDS_SECURED, QUEUE_TIMEOUT, RoborockClient
1212
from .exceptions import CommandVacuumError, RoborockConnectionException, RoborockException
1313
from .protocol import MessageParser
1414
from .roborock_message import RoborockMessage, RoborockMessageProtocol
@@ -123,17 +123,13 @@ async def ping(self):
123123
)
124124
)
125125

126-
async def send_command(
126+
async def _send_command(
127127
self,
128128
method: RoborockCommand,
129129
params: Optional[list | dict] = None,
130-
return_type: Optional[Type[RT]] = None,
131130
):
132131
roborock_message = self.build_roborock_message(method, params)
133-
response = (await self.send_message(roborock_message))[0]
134-
if return_type:
135-
return return_type.from_dict(response)
136-
return response
132+
return (await self.send_message(roborock_message))[0]
137133

138134
async def async_local_response(self, roborock_message: RoborockMessage):
139135
method = roborock_message.get_method()

roborock/roborock_typing.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,6 @@ class RoborockCommand(str, Enum):
127127
UPD_SERVER_TIMER = "upd_server_timer"
128128

129129

130-
CacheableCommands = [
131-
attribute
132-
for attribute in [
133-
command.removeprefix("get_").removeprefix("set_").removeprefix("change_").upper() for command in RoborockCommand
134-
]
135-
if "GET_" + attribute in RoborockCommand.__members__
136-
and ("SET_" + attribute in RoborockCommand.__members__ or "CHANGE_" + attribute in RoborockCommand.__members__)
137-
]
138-
139-
140130
@dataclass
141131
class CommandInfo:
142132
params: Optional[list | dict] = None

0 commit comments

Comments
 (0)