Skip to content

Commit ebdbc90

Browse files
feat: adding status and consumable listeners (#83)
* feat: adding status and consumable listeners * fix: api tests * chore: linting
1 parent d420a17 commit ebdbc90

File tree

4 files changed

+102
-30
lines changed

4 files changed

+102
-30
lines changed

roborock/api.py

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@
5555
)
5656
from .protocol import Utils
5757
from .roborock_future import RoborockFuture
58-
from .roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
58+
from .roborock_message import (
59+
ROBOROCK_DATA_CONSUMABLE_PROTOCOL,
60+
ROBOROCK_DATA_STATUS_PROTOCOL,
61+
RoborockDataProtocol,
62+
RoborockMessage,
63+
RoborockMessageProtocol,
64+
)
5965
from .roborock_typing import DeviceProp, DockSummary, RoborockCommand
6066
from .util import RepeatableTask, get_running_loop_or_create_one, unpack_list
6167

@@ -124,18 +130,23 @@ def stop(self):
124130
self.task.cancel()
125131

126132
async def update_value(self, params):
133+
if self.attribute.set_command is None:
134+
raise RoborockException(f"{self.attribute.attribute} have no set command")
127135
response = await self.api._send_command(self.attribute.set_command, params)
128136
await self._async_value()
129137
return response
130138

131139
async def close_value(self):
132140
if self.attribute.close_command is None:
133-
raise RoborockException(f"{self.attribute.attribute} is not closeable")
141+
raise RoborockException(f"{self.attribute.attribute} have no close command")
134142
response = await self.api._send_command(self.attribute.close_command)
135143
await self._async_value()
136144
return response
137145

138146

147+
device_cache: dict[str, dict[CacheableAttribute, AttributeCache]] = {}
148+
149+
139150
class RoborockClient:
140151
def __init__(self, endpoint: str, device_info: DeviceData) -> None:
141152
self.event_loop = get_running_loop_or_create_one()
@@ -147,9 +158,15 @@ def __init__(self, endpoint: str, device_info: DeviceData) -> None:
147158
self._last_disconnection = self.time_func()
148159
self.keep_alive = KEEPALIVE
149160
self._diagnostic_data: dict[str, dict[str, Any]] = {}
150-
self.cache: dict[CacheableAttribute, AttributeCache] = {
151-
cacheable_attribute: AttributeCache(attr, self) for cacheable_attribute, attr in create_cache_map().items()
152-
}
161+
cache = device_cache.get(device_info.device.duid)
162+
if not cache:
163+
cache = {
164+
cacheable_attribute: AttributeCache(attr, self)
165+
for cacheable_attribute, attr in create_cache_map().items()
166+
}
167+
device_cache[device_info.device.duid] = cache
168+
self.cache = cache
169+
self._listeners: list[Callable[[CacheableAttribute, RoborockBase], None]] = []
153170

154171
def __del__(self) -> None:
155172
self.release()
@@ -212,6 +229,30 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None:
212229
if isinstance(result, list) and len(result) == 1:
213230
result = result[0]
214231
queue.resolve((result, None))
232+
else:
233+
try:
234+
data_protocol = RoborockDataProtocol(int(data_point_number))
235+
_LOGGER.debug(f"Got device update for {data_protocol.name}: {data_point}")
236+
if data_protocol in ROBOROCK_DATA_STATUS_PROTOCOL:
237+
_cls: Type[Status] = ModelStatus.get(
238+
self.device_info.model, S7MaxVStatus
239+
) # Default to S7 MAXV if we don't have the data
240+
value = self.cache[CacheableAttribute.status].value
241+
value[data_protocol.name] = data_point
242+
status = _cls.from_dict(value)
243+
for listener in self._listeners:
244+
listener(CacheableAttribute.status, status)
245+
elif data_protocol in ROBOROCK_DATA_CONSUMABLE_PROTOCOL:
246+
value = self.cache[CacheableAttribute.consumable].value
247+
value[data_protocol.name] = data_point
248+
consumable = Consumable.from_dict(value)
249+
for listener in self._listeners:
250+
listener(CacheableAttribute.consumable, consumable)
251+
return
252+
except ValueError:
253+
pass
254+
dps = {data_point_number: data_point}
255+
_LOGGER.debug(f"Got unknown data point {dps}")
215256
elif data.payload and protocol == RoborockMessageProtocol.MAP_RESPONSE:
216257
payload = data.payload[0:24]
217258
[endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", payload)
@@ -223,8 +264,6 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None:
223264
if isinstance(decompressed, list):
224265
decompressed = decompressed[0]
225266
queue.resolve((decompressed, None))
226-
elif data.payload and protocol in RoborockDataProtocol:
227-
_LOGGER.debug(f"Got device update for {RoborockDataProtocol(protocol).name}: {data.payload!r}")
228267
else:
229268
queue = self._waiting_queue.get(data.seq)
230269
if queue:
@@ -329,13 +368,15 @@ async def get_status(self) -> Status | None:
329368
_cls: Type[Status] = ModelStatus.get(
330369
self.device_info.model, S7MaxVStatus
331370
) # Default to S7 MAXV if we don't have the data
332-
return await self.send_command(RoborockCommand.GET_STATUS, return_type=_cls)
371+
return _cls.from_dict(await self.cache[CacheableAttribute.status].async_value())
333372

334373
async def get_dnd_timer(self) -> DnDTimer | None:
335-
return await self.send_command(RoborockCommand.GET_DND_TIMER, return_type=DnDTimer)
374+
return DnDTimer.from_dict(await self.cache[CacheableAttribute.dnd_timer].async_value())
336375

337376
async def get_valley_electricity_timer(self) -> ValleyElectricityTimer | None:
338-
return await self.send_command(RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER, return_type=ValleyElectricityTimer)
377+
return ValleyElectricityTimer.from_dict(
378+
await self.cache[CacheableAttribute.valley_electricity_timer].async_value()
379+
)
339380

340381
async def get_clean_summary(self) -> CleanSummary | None:
341382
clean_summary: dict | list | int = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY)
@@ -357,16 +398,16 @@ async def get_clean_record(self, record_id: int) -> CleanRecord | None:
357398
return await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id], return_type=CleanRecord)
358399

359400
async def get_consumable(self) -> Consumable | None:
360-
return await self.send_command(RoborockCommand.GET_CONSUMABLE, return_type=Consumable)
401+
return Consumable.from_dict(await self.cache[CacheableAttribute.consumable].async_value())
361402

362403
async def get_wash_towel_mode(self) -> WashTowelMode | None:
363-
return await self.send_command(RoborockCommand.GET_WASH_TOWEL_MODE, return_type=WashTowelMode)
404+
return WashTowelMode.from_dict(await self.cache[CacheableAttribute.wash_towel_mode].async_value())
364405

365406
async def get_dust_collection_mode(self) -> DustCollectionMode | None:
366-
return await self.send_command(RoborockCommand.GET_DUST_COLLECTION_MODE, return_type=DustCollectionMode)
407+
return DustCollectionMode.from_dict(await self.cache[CacheableAttribute.dust_collection_mode].async_value())
367408

368409
async def get_smart_wash_params(self) -> SmartWashParams | None:
369-
return await self.send_command(RoborockCommand.GET_SMART_WASH_PARAMS, return_type=SmartWashParams)
410+
return SmartWashParams.from_dict(await self.cache[CacheableAttribute.smart_wash_params].async_value())
370411

371412
async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary | None:
372413
"""Gets the status summary from the dock with the methods available for a given dock.
@@ -432,15 +473,18 @@ async def get_room_mapping(self) -> list[RoomMapping] | None:
432473

433474
async def get_child_lock_status(self) -> ChildLockStatus | None:
434475
"""Gets current child lock status."""
435-
return await self.send_command(RoborockCommand.GET_CHILD_LOCK_STATUS, return_type=ChildLockStatus)
476+
return ChildLockStatus.from_dict(await self.cache[CacheableAttribute.child_lock_status].async_value())
436477

437478
async def get_flow_led_status(self) -> FlowLedStatus | None:
438479
"""Gets current flow led status."""
439-
return await self.send_command(RoborockCommand.GET_FLOW_LED_STATUS, return_type=FlowLedStatus)
480+
return FlowLedStatus.from_dict(await self.cache[CacheableAttribute.flow_led_status].async_value())
440481

441482
async def get_sound_volume(self) -> int | None:
442483
"""Gets current volume level."""
443-
return await self.send_command(RoborockCommand.GET_SOUND_VOLUME)
484+
return await self.cache[CacheableAttribute.sound_volume].async_value()
485+
486+
def add_listener(self, listener: Callable):
487+
self._listeners.append(listener)
444488

445489

446490
class RoborockApiClient:

roborock/command_cache.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212

1313
class CacheableAttribute(str, Enum):
14+
status = "status"
15+
consumable = "consumable"
1416
sound_volume = "sound_volume"
1517
camera_status = "camera_status"
1618
carpet_clean_mode = "carpet_clean_mode"
@@ -36,12 +38,20 @@ class CacheableAttribute(str, Enum):
3638
class RoborockAttribute:
3739
attribute: str
3840
get_command: RoborockCommand
39-
set_command: RoborockCommand
41+
set_command: Optional[RoborockCommand] = None
4042
close_command: Optional[RoborockCommand] = None
4143

4244

4345
def create_cache_map():
4446
cache_map: Mapping[CacheableAttribute, RoborockAttribute] = {
47+
CacheableAttribute.status: RoborockAttribute(
48+
attribute="status",
49+
get_command=RoborockCommand.GET_STATUS,
50+
),
51+
CacheableAttribute.consumable: RoborockAttribute(
52+
attribute="consumable",
53+
get_command=RoborockCommand.GET_CONSUMABLE,
54+
),
4555
CacheableAttribute.sound_volume: RoborockAttribute(
4656
attribute="sound_volume",
4757
get_command=RoborockCommand.GET_SOUND_VOLUME,

roborock/roborock_message.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ class RoborockDataProtocol(RoborockEnum):
2828
BATTERY = 122
2929
FAN_POWER = 123
3030
WATER_BOX_MODE = 124
31-
MAIN_BRUSH_LIFE = 125
32-
SIDE_BRUSH_LIFE = 126
33-
FILTER_LIFE = 127
31+
MAIN_BRUSH_WORK_TIME = 125
32+
SIDE_BRUSH_WORK_TIME = 126
33+
FILTER_WORK_TIME = 127
3434
ADDITIONAL_PROPS = 128
3535
TASK_COMPLETE = 130
3636
TASK_CANCEL_LOW_POWER = 131
@@ -39,6 +39,22 @@ class RoborockDataProtocol(RoborockEnum):
3939
DRYING_STATUS = 134
4040

4141

42+
ROBOROCK_DATA_STATUS_PROTOCOL = [
43+
RoborockDataProtocol.ERROR_CODE,
44+
RoborockDataProtocol.STATE,
45+
RoborockDataProtocol.BATTERY,
46+
RoborockDataProtocol.FAN_POWER,
47+
RoborockDataProtocol.WATER_BOX_MODE,
48+
RoborockDataProtocol.CHARGE_STATUS,
49+
]
50+
51+
ROBOROCK_DATA_CONSUMABLE_PROTOCOL = [
52+
RoborockDataProtocol.MAIN_BRUSH_WORK_TIME,
53+
RoborockDataProtocol.SIDE_BRUSH_WORK_TIME,
54+
RoborockDataProtocol.FILTER_WORK_TIME,
55+
]
56+
57+
4258
@dataclass
4359
class RoborockMessage:
4460
protocol: RoborockMessageProtocol

tests/test_api.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
from roborock.api import PreparedRequest, RoborockApiClient
1414
from roborock.cloud_api import RoborockMqttClient
15-
from roborock.containers import DeviceData, DustCollectionMode, S7MaxVStatus, SmartWashParams, WashTowelMode
15+
from roborock.containers import DeviceData, S7MaxVStatus
1616
from tests.mock_data import BASE_URL_REQUEST, GET_CODE_RESPONSE, HOME_DATA_RAW, STATUS, USER_DATA
1717

1818

@@ -77,8 +77,8 @@ async def test_get_dust_collection_mode():
7777
home_data = HomeData.from_dict(HOME_DATA_RAW)
7878
device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model)
7979
rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info)
80-
with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command:
81-
command.return_value = DustCollectionMode.from_dict({"mode": 1})
80+
with patch("roborock.api.AttributeCache.async_value") as command:
81+
command.return_value = {"mode": 1}
8282
dust = await rmc.get_dust_collection_mode()
8383
assert dust is not None
8484
assert dust.mode == RoborockDockDustCollectionModeCode.light
@@ -89,8 +89,8 @@ async def test_get_mop_wash_mode():
8989
home_data = HomeData.from_dict(HOME_DATA_RAW)
9090
device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model)
9191
rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info)
92-
with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command:
93-
command.return_value = SmartWashParams.from_dict({"smart_wash": 0, "wash_interval": 1500})
92+
with patch("roborock.api.AttributeCache.async_value") as command:
93+
command.return_value = {"smart_wash": 0, "wash_interval": 1500}
9494
mop_wash = await rmc.get_smart_wash_params()
9595
assert mop_wash is not None
9696
assert mop_wash.smart_wash == 0
@@ -102,8 +102,8 @@ async def test_get_washing_mode():
102102
home_data = HomeData.from_dict(HOME_DATA_RAW)
103103
device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model)
104104
rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info)
105-
with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command:
106-
command.return_value = WashTowelMode.from_dict({"wash_mode": 2})
105+
with patch("roborock.api.AttributeCache.async_value") as command:
106+
command.return_value = {"wash_mode": 2}
107107
washing_mode = await rmc.get_wash_towel_mode()
108108
assert washing_mode is not None
109109
assert washing_mode.wash_mode == RoborockDockWashTowelModeCode.deep
@@ -116,8 +116,10 @@ async def test_get_prop():
116116
device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model)
117117
rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info)
118118
with patch("roborock.cloud_api.RoborockMqttClient.get_status") as get_status, patch(
119-
"roborock.cloud_api.RoborockMqttClient.send_command"
120-
), patch("roborock.cloud_api.RoborockMqttClient.get_dust_collection_mode"):
119+
"roborock.api.RoborockClient.send_command"
120+
), patch("roborock.api.AttributeCache.async_value"), patch(
121+
"roborock.cloud_api.RoborockMqttClient.get_dust_collection_mode"
122+
):
121123
status = S7MaxVStatus.from_dict(STATUS)
122124
status.dock_type = RoborockDockTypeCode.auto_empty_dock_pure
123125
get_status.return_value = status

0 commit comments

Comments
 (0)