Skip to content
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

feat: Add Zeo support and fix some A01 weirdness #200

Merged
merged 14 commits into from
Apr 11, 2024
9 changes: 0 additions & 9 deletions roborock/api.py
Original file line number Diff line number Diff line change
@@ -12,9 +12,6 @@

from .containers import (
DeviceData,
ModelStatus,
S7MaxVStatus,
Status,
)
from .exceptions import (
RoborockTimeout,
@@ -48,16 +45,10 @@ def __init__(self, endpoint: str, device_info: DeviceData, queue_timeout: int =
self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER)
self.is_available: bool = True
self.queue_timeout = queue_timeout
self._status_type: type[Status] = ModelStatus.get(self.device_info.model, S7MaxVStatus)

def __del__(self) -> None:
self.release()

@property
def status_type(self) -> type[Status]:
"""Gets the status type for this device"""
return self._status_type

def release(self):
self.sync_disconnect()

135 changes: 135 additions & 0 deletions roborock/code_mappings.py
Original file line number Diff line number Diff line change
@@ -450,3 +450,138 @@ class DyadError(RoborockEnum):
dirty_charging_contacts = 10007 # Disconnection between the device and dock. Wipe charging contacts.
low_battery = 20017 # Low battery level. Charge before starting self-cleaning.
battery_under_10 = 20018 # Charge until the battery level exceeds 10% before manually starting self-cleaning.


class ZeoMode(RoborockEnum):
wash = 1
wash_and_dry = 2
dry = 3


class ZeoState(RoborockEnum):
standby = 1
weighing = 2
soaking = 3
washing = 4
rinsing = 5
spinning = 6
drying = 7
cooling = 8
under_delay_start = 9
done = 10


class ZeoProgram(RoborockEnum):
standard = 1
quick = 2
sanitize = 3
wool = 4
air_refresh = 5
custom = 6
bedding = 7
down = 8
silk = 9
rinse_and_spin = 10
spin = 11
down_clean = 12
baby_care = 13
anti_allergen = 14
sportswear = 15
night = 16
new_clothes = 17
shirts = 18
synthetics = 19
underwear = 20
gentle = 21
intensive = 22
cotton_linen = 23
season = 24
warming = 25
bra = 26
panties = 27
boiling_wash = 28
socks = 30
towels = 31
anti_mite = 32
exo_40_60 = 33
twenty_c = 34
t_shirts = 35
stain_removal = 36


class ZeoSoak(RoborockEnum):
normal = 0
low = 1
medium = 2
high = 3
max = 4


class ZeoTemperature(RoborockEnum):
normal = 1
low = 2
medium = 3
high = 4
max = 5
twenty_c = 6


class ZeoRinse(RoborockEnum):
none = 0
min = 1
low = 2
mid = 3
high = 4
max = 5


class ZeoSpin(RoborockEnum):
none = 1
very_low = 2
low = 3
mid = 4
high = 5
very_high = 6
max = 7


class ZeoDryingMode(RoborockEnum):
none = 0
quick = 1
iron = 2
store = 3


class ZeoDetergentType(RoborockEnum):
empty = 0
low = 1
medium = 2
high = 3


class ZeoSoftenerType(RoborockEnum):
empty = 0
low = 1
medium = 2
high = 3


class ZeoError(RoborockEnum):
none = 0
refill_error = 1
drain_error = 2
door_lock_error = 3
water_level_error = 4
inverter_error = 5
heating_error = 6
temperature_error = 7
communication_error = 10
drying_error = 11
drying_error_e_12 = 12
drying_error_e_13 = 13
drying_error_e_14 = 14
drying_error_e_15 = 15
drying_error_e_16 = 16
drying_error_water_flow = 17 # Check for normal water flow
drying_error_restart = 18 # Restart the washer and try again
spin_error = 19 # re-arrange clothes
5 changes: 2 additions & 3 deletions roborock/protocol.py
Original file line number Diff line number Diff line change
@@ -36,7 +36,6 @@
_LOGGER = logging.getLogger(__name__)
SALT = b"TXdfu$jyZ#TZHsg4"
A01_HASH = "726f626f726f636b2d67a6d6da"
A01_AES_DECIPHER = "ELSYN0wTI4AUm7C4"
BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
AP_CONFIG = 1
SOCK_DISCOVERY = 2
@@ -208,7 +207,7 @@ def _encode(self, obj, context, _):
"""
if context.version == b"A01":
iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24]
decipher = AES.new(bytes(A01_AES_DECIPHER, "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
f = decipher.encrypt(obj)
return f
token = self.token_func(context)
@@ -219,7 +218,7 @@ def _decode(self, obj, context, _):
"""Decrypts the given payload with the token stored in the context."""
if context.version == b"A01":
iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24]
decipher = AES.new(bytes(A01_AES_DECIPHER, "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
f = decipher.decrypt(obj)
return f
token = self.token_func(context)
42 changes: 42 additions & 0 deletions roborock/roborock_message.py
Original file line number Diff line number Diff line change
@@ -87,6 +87,48 @@ class RoborockDyadDataProtocol(RoborockEnum):
RPC_RESPONSE = 10102


class RoborockZeoProtocol(RoborockEnum):
START = 200 # rw
PAUSE = 201 # rw
SHUTDOWN = 202 # rw
STATE = 203 # ro
MODE = 204 # rw
PROGRAM = 205 # rw
CHILD_LOCK = 206 # rw
TEMP = 207 # rw
RINSE_TIMES = 208 # rw
SPIN_LEVEL = 209 # rw
DRYING_MODE = 210 # rw
DETERGENT_SET = 211 # rw
SOFTENER_SET = 212 # rw
DETERGENT_TYPE = 213 # rw
SOFTENER_TYPE = 214 # rw
COUNTDOWN = 217 # rw
WASHING_LEFT = 218 # ro
DOORLOCK_STATE = 219 # ro
ERROR = 220 # ro
CUSTOM_PARAM_SAVE = 221 # rw
CUSTOM_PARAM_GET = 222 # ro
SOUND_SET = 223 # rw
TIMES_AFTER_CLEAN = 224 # ro
DEFAULT_SETTING = 225 # rw
DETERGENT_EMPTY = 226 # ro
SOFTENER_EMPTY = 227 # ro
LIGHT_SETTING = 229 # rw
DETERGENT_VOLUME = 230 # rw
SOFTENER_VOLUME = 231 # rw
APP_AUTHORIZATION = 232 # rw
ID_QUERY = 10000
F_C = 10001
SND_STATE = 10004
PRODUCT_INFO = 10005
PRIVACY_INFO = 10006
OTA_NFO = 10007
WASHING_LOG = 10008
RPC_REQ = 10101
RPC_RESp = 10102


ROBOROCK_DATA_STATUS_PROTOCOL = [
RoborockDataProtocol.ERROR_CODE,
RoborockDataProtocol.STATE,
106 changes: 74 additions & 32 deletions roborock/version_a01_apis/roborock_client_a01.py
Original file line number Diff line number Diff line change
@@ -19,59 +19,92 @@
DyadWarmLevel,
DyadWaterLevel,
RoborockDyadStateCode,
ZeoDetergentType,
ZeoDryingMode,
ZeoError,
ZeoMode,
ZeoProgram,
ZeoRinse,
ZeoSoftenerType,
ZeoSpin,
ZeoState,
ZeoTemperature,
)
from roborock.containers import DyadProductInfo, DyadSndState
from roborock.containers import DyadProductInfo, DyadSndState, RoborockCategory
from roborock.roborock_message import (
RoborockDyadDataProtocol,
RoborockMessage,
RoborockMessageProtocol,
RoborockZeoProtocol,
)


@dataclasses.dataclass
class DyadProtocolCacheEntry:
class A01ProtocolCacheEntry:
post_process_fn: Callable
value: typing.Any | None = None


# Right now this cache is not active, it was too much complexity for the initial addition of dyad.
protocol_entries = {
RoborockDyadDataProtocol.STATUS: DyadProtocolCacheEntry(lambda val: RoborockDyadStateCode(val).name),
RoborockDyadDataProtocol.SELF_CLEAN_MODE: DyadProtocolCacheEntry(lambda val: DyadSelfCleanMode(val).name),
RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: DyadProtocolCacheEntry(lambda val: DyadSelfCleanLevel(val).name),
RoborockDyadDataProtocol.WARM_LEVEL: DyadProtocolCacheEntry(lambda val: DyadWarmLevel(val).name),
RoborockDyadDataProtocol.CLEAN_MODE: DyadProtocolCacheEntry(lambda val: DyadCleanMode(val).name),
RoborockDyadDataProtocol.SUCTION: DyadProtocolCacheEntry(lambda val: DyadSuction(val).name),
RoborockDyadDataProtocol.WATER_LEVEL: DyadProtocolCacheEntry(lambda val: DyadWaterLevel(val).name),
RoborockDyadDataProtocol.BRUSH_SPEED: DyadProtocolCacheEntry(lambda val: DyadBrushSpeed(val).name),
RoborockDyadDataProtocol.POWER: DyadProtocolCacheEntry(lambda val: int(val)),
RoborockDyadDataProtocol.AUTO_DRY: DyadProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.MESH_LEFT: DyadProtocolCacheEntry(lambda val: int(360000 - val * 60)),
RoborockDyadDataProtocol.BRUSH_LEFT: DyadProtocolCacheEntry(lambda val: int(360000 - val * 60)),
RoborockDyadDataProtocol.ERROR: DyadProtocolCacheEntry(lambda val: DyadError(val).name),
RoborockDyadDataProtocol.VOLUME_SET: DyadProtocolCacheEntry(lambda val: int(val)),
RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: DyadProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.AUTO_DRY_MODE: DyadProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.SILENT_DRY_DURATION: DyadProtocolCacheEntry(lambda val: int(val)), # in minutes
RoborockDyadDataProtocol.SILENT_MODE: DyadProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.SILENT_MODE_START_TIME: DyadProtocolCacheEntry(
RoborockDyadDataProtocol.STATUS: A01ProtocolCacheEntry(lambda val: RoborockDyadStateCode(val).name),
RoborockDyadDataProtocol.SELF_CLEAN_MODE: A01ProtocolCacheEntry(lambda val: DyadSelfCleanMode(val).name),
RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: A01ProtocolCacheEntry(lambda val: DyadSelfCleanLevel(val).name),
RoborockDyadDataProtocol.WARM_LEVEL: A01ProtocolCacheEntry(lambda val: DyadWarmLevel(val).name),
RoborockDyadDataProtocol.CLEAN_MODE: A01ProtocolCacheEntry(lambda val: DyadCleanMode(val).name),
RoborockDyadDataProtocol.SUCTION: A01ProtocolCacheEntry(lambda val: DyadSuction(val).name),
RoborockDyadDataProtocol.WATER_LEVEL: A01ProtocolCacheEntry(lambda val: DyadWaterLevel(val).name),
RoborockDyadDataProtocol.BRUSH_SPEED: A01ProtocolCacheEntry(lambda val: DyadBrushSpeed(val).name),
RoborockDyadDataProtocol.POWER: A01ProtocolCacheEntry(lambda val: int(val)),
RoborockDyadDataProtocol.AUTO_DRY: A01ProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.MESH_LEFT: A01ProtocolCacheEntry(lambda val: int(360000 - val * 60)),
RoborockDyadDataProtocol.BRUSH_LEFT: A01ProtocolCacheEntry(lambda val: int(360000 - val * 60)),
RoborockDyadDataProtocol.ERROR: A01ProtocolCacheEntry(lambda val: DyadError(val).name),
RoborockDyadDataProtocol.VOLUME_SET: A01ProtocolCacheEntry(lambda val: int(val)),
RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: A01ProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.AUTO_DRY_MODE: A01ProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.SILENT_DRY_DURATION: A01ProtocolCacheEntry(lambda val: int(val)), # in minutes
RoborockDyadDataProtocol.SILENT_MODE: A01ProtocolCacheEntry(lambda val: bool(val)),
RoborockDyadDataProtocol.SILENT_MODE_START_TIME: A01ProtocolCacheEntry(
lambda val: time(hour=int(val / 60), minute=val % 60)
), # in minutes since 00:00
RoborockDyadDataProtocol.SILENT_MODE_END_TIME: DyadProtocolCacheEntry(
RoborockDyadDataProtocol.SILENT_MODE_END_TIME: A01ProtocolCacheEntry(
lambda val: time(hour=int(val / 60), minute=val % 60)
), # in minutes since 00:00
RoborockDyadDataProtocol.RECENT_RUN_TIME: DyadProtocolCacheEntry(
RoborockDyadDataProtocol.RECENT_RUN_TIME: A01ProtocolCacheEntry(
lambda val: [int(v) for v in val.split(",")]
), # minutes of cleaning in past few days.
RoborockDyadDataProtocol.TOTAL_RUN_TIME: DyadProtocolCacheEntry(lambda val: int(val)),
RoborockDyadDataProtocol.SND_STATE: DyadProtocolCacheEntry(lambda val: DyadSndState.from_dict(val)),
RoborockDyadDataProtocol.PRODUCT_INFO: DyadProtocolCacheEntry(lambda val: DyadProductInfo.from_dict(val)),
RoborockDyadDataProtocol.TOTAL_RUN_TIME: A01ProtocolCacheEntry(lambda val: int(val)),
RoborockDyadDataProtocol.SND_STATE: A01ProtocolCacheEntry(lambda val: DyadSndState.from_dict(val)),
RoborockDyadDataProtocol.PRODUCT_INFO: A01ProtocolCacheEntry(lambda val: DyadProductInfo.from_dict(val)),
}

zeo_data_protocol_entries = {
# ro
RoborockZeoProtocol.STATE: A01ProtocolCacheEntry(lambda val: ZeoState(val).name),
RoborockZeoProtocol.COUNTDOWN: A01ProtocolCacheEntry(lambda val: int(val)),
RoborockZeoProtocol.WASHING_LEFT: A01ProtocolCacheEntry(lambda val: int(val)),
RoborockZeoProtocol.ERROR: A01ProtocolCacheEntry(lambda val: ZeoError(val).name),
RoborockZeoProtocol.TIMES_AFTER_CLEAN: A01ProtocolCacheEntry(lambda val: int(val)),
RoborockZeoProtocol.DETERGENT_EMPTY: A01ProtocolCacheEntry(lambda val: bool(val)),
RoborockZeoProtocol.SOFTENER_EMPTY: A01ProtocolCacheEntry(lambda val: bool(val)),
# rw
RoborockZeoProtocol.MODE: A01ProtocolCacheEntry(lambda val: ZeoMode(val).name),
RoborockZeoProtocol.PROGRAM: A01ProtocolCacheEntry(lambda val: ZeoProgram(val).name),
RoborockZeoProtocol.TEMP: A01ProtocolCacheEntry(lambda val: ZeoTemperature(val).name),
RoborockZeoProtocol.RINSE_TIMES: A01ProtocolCacheEntry(lambda val: ZeoRinse(val).name),
RoborockZeoProtocol.SPIN_LEVEL: A01ProtocolCacheEntry(lambda val: ZeoSpin(val).name),
RoborockZeoProtocol.DRYING_MODE: A01ProtocolCacheEntry(lambda val: ZeoDryingMode(val).name),
RoborockZeoProtocol.DETERGENT_TYPE: A01ProtocolCacheEntry(lambda val: ZeoDetergentType(val).name),
RoborockZeoProtocol.SOFTENER_TYPE: A01ProtocolCacheEntry(lambda val: ZeoSoftenerType(val).name),
RoborockZeoProtocol.SOUND_SET: A01ProtocolCacheEntry(lambda val: bool(val)),
}


class RoborockClientA01(RoborockClient):
def __init__(self, endpoint: str, device_info: DeviceData):
super().__init__(endpoint, device_info)
def __init__(self, endpoint: str, device_info: DeviceData, category: RoborockCategory, queue_timeout: int = 4):
super().__init__(endpoint, device_info, queue_timeout)
self.category = category

def on_message_received(self, messages: list[RoborockMessage]) -> None:
for message in messages:
@@ -87,14 +120,23 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None:
continue
payload_json = json.loads(payload.decode())
for data_point_number, data_point in payload_json.get("dps").items():
data_point_protocol = RoborockDyadDataProtocol(int(data_point_number))
if data_point_protocol in protocol_entries:
data_point_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol
entries: dict
if self.category == RoborockCategory.WET_DRY_VAC:
data_point_protocol = RoborockDyadDataProtocol(int(data_point_number))
entries = protocol_entries
elif self.category == RoborockCategory.WASHING_MACHINE:
data_point_protocol = RoborockZeoProtocol(int(data_point_number))
entries = zeo_data_protocol_entries
else:
continue
if data_point_protocol in entries:
# Auto convert into data struct we want.
converted_response = protocol_entries[data_point_protocol].post_process_fn(data_point)
converted_response = entries[data_point_protocol].post_process_fn(data_point)
queue = self._waiting_queue.get(int(data_point_number))
if queue and queue.protocol == protocol:
queue.resolve((converted_response, None))

async def update_values(self, dyad_data_protocols: list[RoborockDyadDataProtocol]):
async def update_values(self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]):
"""This should handle updating for each given protocol."""
raise NotImplementedError
29 changes: 21 additions & 8 deletions roborock/version_a01_apis/roborock_mqtt_client_a01.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
import asyncio
import base64
import json
import typing

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

from roborock.cloud_api import RoborockMqttClient
from roborock.containers import DeviceData, UserData
from roborock.containers import DeviceData, RoborockCategory, UserData
from roborock.exceptions import RoborockException
from roborock.protocol import MessageParser, Utils
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockMessage, RoborockMessageProtocol
from roborock.roborock_message import (
RoborockDyadDataProtocol,
RoborockMessage,
RoborockMessageProtocol,
RoborockZeoProtocol,
)

from .roborock_client_a01 import RoborockClientA01


class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01):
def __init__(self, user_data: UserData, device_info: DeviceData, queue_timeout: int = 10) -> None:
def __init__(
self, user_data: UserData, device_info: DeviceData, category: RoborockCategory, queue_timeout: int = 10
) -> None:
rriot = user_data.rriot
if rriot is None:
raise RoborockException("Got no rriot data from user_data")
endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode()

RoborockMqttClient.__init__(self, user_data, device_info, queue_timeout)
RoborockClientA01.__init__(self, endpoint, device_info)
RoborockClientA01.__init__(self, endpoint, device_info, category, queue_timeout)

async def send_message(self, roborock_message: RoborockMessage):
await self.validate_connection()
@@ -37,14 +45,19 @@ async def send_message(self, roborock_message: RoborockMessage):
for dps in json.loads(payload["dps"]["10000"]):
futures.append(asyncio.ensure_future(self._async_response(dps, response_protocol)))
self._send_msg_raw(m)
responses = await asyncio.gather(*futures)
dps_responses = {}
responses = await asyncio.gather(*futures, return_exceptions=True)
dps_responses: dict[int, typing.Any] = {}
if "10000" in payload["dps"]:
for i, dps in enumerate(json.loads(payload["dps"]["10000"])):
dps_responses[dps] = responses[i][0]
response = responses[i]
if isinstance(response, BaseException):
self._logger.warning("Timed out get req for %s after %s s", dps, self.queue_timeout)
dps_responses[dps] = None
else:
dps_responses[dps] = response[0]
return dps_responses

async def update_values(self, dyad_data_protocols: list[RoborockDyadDataProtocol]):
async def update_values(self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]):
payload = {"dps": {RoborockDyadDataProtocol.ID_QUERY: str([int(protocol) for protocol in dyad_data_protocols])}}
return await self.send_message(
RoborockMessage(