Skip to content

Commit 9fb124e

Browse files
authored
feat: add v1 api (#194)
* feat: add v1 api * fix: change some imports * fix: bug and versioning * chore: move location of v1 * fix: random exception
1 parent 4afbc98 commit 9fb124e

File tree

12 files changed

+377
-289
lines changed

12 files changed

+377
-289
lines changed

poetry.lock

Lines changed: 21 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ construct = "^2.10.57"
3333

3434

3535
[build-system]
36-
requires = ["poetry-core==1.7.1"]
36+
requires = ["poetry-core==1.8.0"]
3737
build-backend = "poetry.core.masonry.api"
3838

3939
[tool.poetry.group.dev.dependencies]

roborock/api.py

Lines changed: 3 additions & 199 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,20 @@
88
import hashlib
99
import json
1010
import logging
11-
import math
1211
import secrets
1312
import struct
1413
import time
1514
from collections.abc import Callable, Coroutine
16-
from random import randint
1715
from typing import Any, TypeVar, final
1816

19-
from .code_mappings import RoborockDockTypeCode
2017
from .command_cache import CacheableAttribute, CommandType, RoborockAttribute, find_cacheable_attribute, get_cache_map
2118
from .containers import (
22-
ChildLockStatus,
23-
CleanRecord,
24-
CleanSummary,
2519
Consumable,
2620
DeviceData,
27-
DnDTimer,
28-
DustCollectionMode,
29-
FlowLedStatus,
3021
ModelStatus,
31-
MultiMapsList,
32-
NetworkInfo,
3322
RoborockBase,
34-
RoomMapping,
3523
S7MaxVStatus,
36-
ServerTimer,
37-
SmartWashParams,
3824
Status,
39-
ValleyElectricityTimer,
40-
WashTowelMode,
4125
)
4226
from .exceptions import (
4327
RoborockException,
@@ -54,21 +38,12 @@
5438
RoborockMessage,
5539
RoborockMessageProtocol,
5640
)
57-
from .roborock_typing import DeviceProp, DockSummary, RoborockCommand
58-
from .util import RepeatableTask, RoborockLoggerAdapter, get_running_loop_or_create_one, unpack_list
41+
from .roborock_typing import RoborockCommand
42+
from .util import RepeatableTask, RoborockLoggerAdapter, get_running_loop_or_create_one
5943

6044
_LOGGER = logging.getLogger(__name__)
6145
KEEPALIVE = 60
62-
COMMANDS_SECURED = [
63-
RoborockCommand.GET_MAP_V1,
64-
RoborockCommand.GET_MULTI_MAP,
65-
]
6646
RT = TypeVar("RT", bound=RoborockBase)
67-
WASH_N_FILL_DOCK = [
68-
RoborockDockTypeCode.empty_wash_fill_dock,
69-
RoborockDockTypeCode.s8_dock,
70-
RoborockDockTypeCode.p10_dock,
71-
]
7247

7348

7449
def md5hex(message: str) -> str:
@@ -286,7 +261,7 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None:
286261
try:
287262
decrypted = Utils.decrypt_cbc(data.payload[24:], self._nonce)
288263
except ValueError as err:
289-
raise RoborockException("Failed to decode %s for %s", data.payload, data.protocol) from err
264+
raise RoborockException(f"Failed to decode {data.payload!r} for {data.protocol}") from err
290265
decompressed = Utils.decompress(decrypted)
291266
queue = self._waiting_queue.get(request_id)
292267
if queue:
@@ -336,35 +311,6 @@ def _async_response(
336311
self._waiting_queue[request_id] = queue
337312
return self._wait_response(request_id, queue)
338313

339-
def _get_payload(
340-
self,
341-
method: RoborockCommand | str,
342-
params: list | dict | int | None = None,
343-
secured=False,
344-
):
345-
timestamp = math.floor(time.time())
346-
request_id = randint(10000, 32767)
347-
inner = {
348-
"id": request_id,
349-
"method": method,
350-
"params": params or [],
351-
}
352-
if secured:
353-
inner["security"] = {
354-
"endpoint": self._endpoint,
355-
"nonce": self._nonce.hex().lower(),
356-
}
357-
payload = bytes(
358-
json.dumps(
359-
{
360-
"dps": {"101": json.dumps(inner, separators=(",", ":"))},
361-
"t": timestamp,
362-
},
363-
separators=(",", ":"),
364-
).encode()
365-
)
366-
return request_id, timestamp, payload
367-
368314
async def send_message(self, roborock_message: RoborockMessage):
369315
raise NotImplementedError
370316

@@ -402,148 +348,6 @@ async def send_command(
402348
return return_type.from_dict(response)
403349
return response
404350

405-
async def get_status(self) -> Status:
406-
data = self._status_type.from_dict(await self.cache[CacheableAttribute.status].async_value())
407-
if data is None:
408-
return self._status_type()
409-
return data
410-
411-
async def get_dnd_timer(self) -> DnDTimer | None:
412-
return DnDTimer.from_dict(await self.cache[CacheableAttribute.dnd_timer].async_value())
413-
414-
async def get_valley_electricity_timer(self) -> ValleyElectricityTimer | None:
415-
return ValleyElectricityTimer.from_dict(
416-
await self.cache[CacheableAttribute.valley_electricity_timer].async_value()
417-
)
418-
419-
async def get_clean_summary(self) -> CleanSummary | None:
420-
clean_summary: dict | list | int = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY)
421-
if isinstance(clean_summary, dict):
422-
return CleanSummary.from_dict(clean_summary)
423-
elif isinstance(clean_summary, list):
424-
clean_time, clean_area, clean_count, records = unpack_list(clean_summary, 4)
425-
return CleanSummary(
426-
clean_time=clean_time,
427-
clean_area=clean_area,
428-
clean_count=clean_count,
429-
records=records,
430-
)
431-
elif isinstance(clean_summary, int):
432-
return CleanSummary(clean_time=clean_summary)
433-
return None
434-
435-
async def get_clean_record(self, record_id: int) -> CleanRecord | None:
436-
record: dict | list = await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id])
437-
if isinstance(record, dict):
438-
return CleanRecord.from_dict(record)
439-
elif isinstance(record, list):
440-
# There are still a few unknown variables in this.
441-
begin, end, duration, area = unpack_list(record, 4)
442-
return CleanRecord(begin=begin, end=end, duration=duration, area=area)
443-
else:
444-
_LOGGER.warning("Clean record was of a new type, please submit an issue request: %s", record)
445-
return None
446-
447-
async def get_consumable(self) -> Consumable:
448-
data = Consumable.from_dict(await self.cache[CacheableAttribute.consumable].async_value())
449-
if data is None:
450-
return Consumable()
451-
return data
452-
453-
async def get_wash_towel_mode(self) -> WashTowelMode | None:
454-
return WashTowelMode.from_dict(await self.cache[CacheableAttribute.wash_towel_mode].async_value())
455-
456-
async def get_dust_collection_mode(self) -> DustCollectionMode | None:
457-
return DustCollectionMode.from_dict(await self.cache[CacheableAttribute.dust_collection_mode].async_value())
458-
459-
async def get_smart_wash_params(self) -> SmartWashParams | None:
460-
return SmartWashParams.from_dict(await self.cache[CacheableAttribute.smart_wash_params].async_value())
461-
462-
async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary:
463-
"""Gets the status summary from the dock with the methods available for a given dock.
464-
465-
:param dock_type: RoborockDockTypeCode"""
466-
commands: list[
467-
Coroutine[
468-
Any,
469-
Any,
470-
DustCollectionMode | WashTowelMode | SmartWashParams | None,
471-
]
472-
] = [self.get_dust_collection_mode()]
473-
if dock_type in WASH_N_FILL_DOCK:
474-
commands += [
475-
self.get_wash_towel_mode(),
476-
self.get_smart_wash_params(),
477-
]
478-
[dust_collection_mode, wash_towel_mode, smart_wash_params] = unpack_list(
479-
list(await asyncio.gather(*commands)), 3
480-
) # type: DustCollectionMode, WashTowelMode | None, SmartWashParams | None # type: ignore
481-
482-
return DockSummary(dust_collection_mode, wash_towel_mode, smart_wash_params)
483-
484-
async def get_prop(self) -> DeviceProp | None:
485-
"""Gets device general properties."""
486-
# Mypy thinks that each one of these is typed as a union of all the others. so we do type ignore.
487-
status, clean_summary, consumable = await asyncio.gather(
488-
*[
489-
self.get_status(),
490-
self.get_clean_summary(),
491-
self.get_consumable(),
492-
]
493-
) # type: Status, CleanSummary, Consumable # type: ignore
494-
last_clean_record = None
495-
if clean_summary and clean_summary.records and len(clean_summary.records) > 0:
496-
last_clean_record = await self.get_clean_record(clean_summary.records[0])
497-
dock_summary = None
498-
if status and status.dock_type is not None and status.dock_type != RoborockDockTypeCode.no_dock:
499-
dock_summary = await self.get_dock_summary(status.dock_type)
500-
if any([status, clean_summary, consumable]):
501-
return DeviceProp(
502-
status,
503-
clean_summary,
504-
consumable,
505-
last_clean_record,
506-
dock_summary,
507-
)
508-
return None
509-
510-
async def get_multi_maps_list(self) -> MultiMapsList | None:
511-
return await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST, return_type=MultiMapsList)
512-
513-
async def get_networking(self) -> NetworkInfo | None:
514-
return await self.send_command(RoborockCommand.GET_NETWORK_INFO, return_type=NetworkInfo)
515-
516-
async def get_room_mapping(self) -> list[RoomMapping] | None:
517-
"""Gets the mapping from segment id -> iot id. Only works on local api."""
518-
mapping: list = await self.send_command(RoborockCommand.GET_ROOM_MAPPING)
519-
if isinstance(mapping, list):
520-
return [
521-
RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore
522-
for segment_id, iot_id in [unpack_list(room, 2) for room in mapping if isinstance(room, list)]
523-
]
524-
return None
525-
526-
async def get_child_lock_status(self) -> ChildLockStatus:
527-
"""Gets current child lock status."""
528-
return ChildLockStatus.from_dict(await self.cache[CacheableAttribute.child_lock_status].async_value())
529-
530-
async def get_flow_led_status(self) -> FlowLedStatus:
531-
"""Gets current flow led status."""
532-
return FlowLedStatus.from_dict(await self.cache[CacheableAttribute.flow_led_status].async_value())
533-
534-
async def get_sound_volume(self) -> int | None:
535-
"""Gets current volume level."""
536-
return await self.cache[CacheableAttribute.sound_volume].async_value()
537-
538-
async def get_server_timer(self) -> list[ServerTimer]:
539-
"""Gets current server timer."""
540-
server_timers = await self.cache[CacheableAttribute.server_timer].async_value()
541-
if server_timers:
542-
if isinstance(server_timers[0], list):
543-
return [ServerTimer(*server_timer) for server_timer in server_timers]
544-
return [ServerTimer(*server_timers)]
545-
return []
546-
547351
def add_listener(
548352
self, protocol: RoborockDataProtocol, listener: Callable, cache: dict[CacheableAttribute, AttributeCache]
549353
) -> None:

roborock/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
from pyshark.packet.packet import Packet # type: ignore
1212

1313
from roborock import RoborockException
14-
from roborock.cloud_api import RoborockMqttClient
1514
from roborock.containers import DeviceData, LoginData
1615
from roborock.protocol import MessageParser
1716
from roborock.util import run_sync
17+
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
1818
from roborock.web_api import RoborockApiClient
1919

2020
_LOGGER = logging.getLogger(__name__)
@@ -135,7 +135,7 @@ async def command(ctx, cmd, device_id, params):
135135
if model is None:
136136
raise RoborockException(f"Could not find model for device {device.name}")
137137
device_info = DeviceData(device=device, model=model)
138-
mqtt_client = RoborockMqttClient(login_data.user_data, device_info)
138+
mqtt_client = RoborockMqttClientV1(login_data.user_data, device_info)
139139
await mqtt_client.send_command(cmd, json.loads(params) if params is not None else None)
140140
mqtt_client.__del__()
141141

0 commit comments

Comments
 (0)