-
Notifications
You must be signed in to change notification settings - Fork 74
feat: Implement L01 protocol #487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
da21a0f
95308ed
99d3252
67f9ed6
d7ce9ba
8d8ff89
f926359
89a0eed
b4b6730
3a5874e
3362aac
8af494c
fbaf426
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,8 @@ | ||
| import asyncio | ||
| import json | ||
| import logging | ||
| import math | ||
| import time | ||
| from asyncio import Lock, TimerHandle, Transport, get_running_loop | ||
| from collections.abc import Callable | ||
| from dataclasses import dataclass | ||
|
|
@@ -12,25 +15,12 @@ | |
| from ..protocol import Decoder, Encoder, create_local_decoder, create_local_encoder | ||
| from ..protocols.v1_protocol import RequestMessage | ||
| from ..roborock_message import RoborockMessage, RoborockMessageProtocol | ||
| from ..util import RoborockLoggerAdapter | ||
| from ..util import RoborockLoggerAdapter, get_next_int | ||
| from .roborock_client_v1 import CLOUD_REQUIRED, RoborockClientV1 | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| _HELLO_REQUEST_MESSAGE = RoborockMessage( | ||
| protocol=RoborockMessageProtocol.HELLO_REQUEST, | ||
| seq=1, | ||
| random=22, | ||
| ) | ||
|
|
||
| _PING_REQUEST_MESSAGE = RoborockMessage( | ||
| protocol=RoborockMessageProtocol.PING_REQUEST, | ||
| seq=2, | ||
| random=23, | ||
| ) | ||
|
|
||
|
|
||
| @dataclass | ||
| class _LocalProtocol(asyncio.Protocol): | ||
| """Callbacks for the Roborock local client transport.""" | ||
|
|
@@ -50,7 +40,7 @@ def connection_lost(self, exc: Exception | None) -> None: | |
| class RoborockLocalClientV1(RoborockClientV1, RoborockClient): | ||
| """Roborock local client for v1 devices.""" | ||
|
|
||
| def __init__(self, device_data: DeviceData, queue_timeout: int = 4): | ||
| def __init__(self, device_data: DeviceData, queue_timeout: int = 4, version: str | None = None): | ||
|
Lash-L marked this conversation as resolved.
Outdated
|
||
| """Initialize the Roborock local client.""" | ||
| if device_data.host is None: | ||
| raise RoborockException("Host is required") | ||
|
|
@@ -63,8 +53,14 @@ def __init__(self, device_data: DeviceData, queue_timeout: int = 4): | |
| RoborockClientV1.__init__(self, device_data, security_data=None) | ||
| RoborockClient.__init__(self, device_data) | ||
| self._local_protocol = _LocalProtocol(self._data_received, self._connection_lost) | ||
| self._encoder: Encoder = create_local_encoder(device_data.device.local_key) | ||
| self._decoder: Decoder = create_local_decoder(device_data.device.local_key) | ||
| self._version = version | ||
| self._connect_nonce: int | None = None | ||
| self._ack_nonce: int | None = None | ||
| if version == "L01": | ||
| self._set_l01_encoder_decoder() | ||
| else: | ||
| self._encoder: Encoder = create_local_encoder(device_data.device.local_key) | ||
| self._decoder: Decoder = create_local_decoder(device_data.device.local_key) | ||
| self.queue_timeout = queue_timeout | ||
| self._logger = RoborockLoggerAdapter(device_data.device.name, _LOGGER) | ||
|
|
||
|
|
@@ -121,20 +117,58 @@ async def async_disconnect(self) -> None: | |
| async with self._mutex: | ||
| self._sync_disconnect() | ||
|
|
||
| async def hello(self): | ||
| def _set_l01_encoder_decoder(self): | ||
|
Lash-L marked this conversation as resolved.
Outdated
|
||
| """Tell the system to use the L01 encoder/decoder.""" | ||
| self._encoder = create_local_encoder(self.device_info.device.local_key, self._connect_nonce, self._ack_nonce) | ||
| self._decoder = create_local_decoder(self.device_info.device.local_key, self._connect_nonce, self._ack_nonce) | ||
|
|
||
| async def _do_hello(self, version: str) -> bool: | ||
| """Perform the initial handshaking.""" | ||
| self._logger.debug(f"Attempting to use the {version} protocol for client {self.device_info.device.duid}...") | ||
|
Lash-L marked this conversation as resolved.
Outdated
|
||
| self._connect_nonce = get_next_int(10000, 32767) | ||
| request = RoborockMessage( | ||
| protocol=RoborockMessageProtocol.HELLO_REQUEST, | ||
| version=version.encode(), | ||
| random=self._connect_nonce, | ||
| seq=1, | ||
| ) | ||
| try: | ||
| return await self._send_message( | ||
| roborock_message=_HELLO_REQUEST_MESSAGE, | ||
| request_id=_HELLO_REQUEST_MESSAGE.seq, | ||
| response = await self._send_message( | ||
| roborock_message=request, | ||
| request_id=request.seq, | ||
| response_protocol=RoborockMessageProtocol.HELLO_RESPONSE, | ||
| ) | ||
| except Exception as e: | ||
| self._logger.error(e) | ||
| if response.version.decode() == "L01": | ||
| self._ack_nonce = response.random | ||
| self._set_l01_encoder_decoder() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, up until this point the encoder was used without a connect nonce or an ack nonce. But then for all future messages beyond the hello, these must be used? Is that a requirement or can the connect nonce be set up front in the encoder even for the hello message? Is
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. response.random is not None for non-L01 protocols. However, it is actually fine if they exist, that is a good call. It isn't used on encrypting the 1.0 payload, so those values can be anything. As well, the hello message does not include any payload, so the value doesn't matter there as well. The only potential issues to setting connect_nonce upfront is that if the user reconnects, it will be the same nonce, but the vac should handle that fine as it is a new transport thread. |
||
| self._version = version | ||
| self._logger.debug(f"Client {self.device_info.device.duid} speaks the {version} protocol.") | ||
| return True | ||
| except RoborockException as e: | ||
| self._logger.debug( | ||
| f"Client {self.device_info.device.duid} did not respond or does not speak the {version} protocol. {e}" | ||
| ) | ||
| return False | ||
|
|
||
| async def hello(self): | ||
| """Send hello to the device to negotiate protocol.""" | ||
| if self._version: | ||
| # version is forced | ||
| if not await self._do_hello(self._version): | ||
| raise RoborockException(f"Failed to connect to device with protocol {self._version}") | ||
| else: | ||
| # try 1.0, then L01 | ||
| if not await self._do_hello("1.0"): | ||
| if not await self._do_hello("L01"): | ||
| raise RoborockException("Failed to connect to device with any known protocol") | ||
|
|
||
| async def ping(self) -> None: | ||
| ping_message = RoborockMessage( | ||
| protocol=RoborockMessageProtocol.PING_REQUEST, | ||
| ) | ||
| await self._send_message( | ||
| roborock_message=_PING_REQUEST_MESSAGE, | ||
| request_id=_PING_REQUEST_MESSAGE.seq, | ||
| roborock_message=ping_message, | ||
| request_id=ping_message.seq, | ||
| response_protocol=RoborockMessageProtocol.PING_RESPONSE, | ||
| ) | ||
|
Comment on lines
+166
to
194
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't need to set ping request statically like we did. I know we talked about this before but I wasn't fully sure. This works |
||
|
|
||
|
|
@@ -153,6 +187,33 @@ async def _send_command( | |
| ): | ||
| if method in CLOUD_REQUIRED: | ||
| raise RoborockException(f"Method {method} is not supported over local connection") | ||
| if self._version == "L01": | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This all seems like the responsibility of
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great call, as it turns out all the logic was similar enough to just carry over with a small change. |
||
| request_id = get_next_int(10000, 999999) | ||
| dps_payload = { | ||
| "id": request_id, | ||
| "method": method, | ||
| "params": params, | ||
| } | ||
| ts = math.floor(time.time()) | ||
| payload = { | ||
| "dps": {str(RoborockMessageProtocol.RPC_REQUEST.value): json.dumps(dps_payload, separators=(",", ":"))}, | ||
| "t": ts, | ||
| } | ||
| roborock_message = RoborockMessage( | ||
| protocol=RoborockMessageProtocol.GENERAL_REQUEST, | ||
| payload=json.dumps(payload, separators=(",", ":")).encode("utf-8"), | ||
| version=self._version.encode(), | ||
| timestamp=ts, | ||
| ) | ||
| self._logger.debug("Building message id %s for method %s", request_id, method) | ||
| return await self._send_message( | ||
| roborock_message, | ||
| request_id=request_id, | ||
| response_protocol=RoborockMessageProtocol.GENERAL_REQUEST, | ||
| method=method, | ||
| params=params, | ||
| ) | ||
|
|
||
| request_message = RequestMessage(method=method, params=params) | ||
| roborock_message = request_message.encode_message(RoborockMessageProtocol.GENERAL_REQUEST) | ||
| self._logger.debug("Building message id %s for method %s", request_message.request_id, method) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.