Add support for Roborock Zeo (#121334)

pull/120641/head
Luke Lashley 2024-07-06 05:24:32 -04:00 committed by GitHub
parent ac8bbe9db4
commit 43481ffeac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 508 additions and 25 deletions

View File

@ -10,7 +10,6 @@ import logging
from typing import Any
from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials
from roborock.code_mappings import RoborockCategory
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockMqttClientA01
@ -151,8 +150,7 @@ async def setup_device(
hass, user_data, device, product_info, home_data_rooms
)
if device.pv == "A01":
if product_info.category == RoborockCategory.WET_DRY_VAC:
return await setup_device_a01(hass, user_data, device, product_info)
return await setup_device_a01(hass, user_data, device, product_info)
_LOGGER.info(
"Not adding device %s because its protocol version %s or category %s is not supported",
device.duid,

View File

@ -8,6 +8,7 @@ from functools import cached_property
import logging
from roborock import HomeDataRoom
from roborock.code_mappings import RoborockCategory
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
@ -179,14 +180,27 @@ class RoborockDataUpdateCoordinatorA01(
model=product_info.model,
sw_version=device.fv,
)
self.request_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol] = [
RoborockDyadDataProtocol.STATUS,
RoborockDyadDataProtocol.POWER,
RoborockDyadDataProtocol.MESH_LEFT,
RoborockDyadDataProtocol.BRUSH_LEFT,
RoborockDyadDataProtocol.ERROR,
RoborockDyadDataProtocol.TOTAL_RUN_TIME,
]
self.request_protocols: list[
RoborockDyadDataProtocol | RoborockZeoProtocol
] = []
if product_info.category == RoborockCategory.WET_DRY_VAC:
self.request_protocols = [
RoborockDyadDataProtocol.STATUS,
RoborockDyadDataProtocol.POWER,
RoborockDyadDataProtocol.MESH_LEFT,
RoborockDyadDataProtocol.BRUSH_LEFT,
RoborockDyadDataProtocol.ERROR,
RoborockDyadDataProtocol.TOTAL_RUN_TIME,
]
elif product_info.category == RoborockCategory.WASHING_MACHINE:
self.request_protocols = [
RoborockZeoProtocol.STATE,
RoborockZeoProtocol.COUNTDOWN,
RoborockZeoProtocol.WASHING_LEFT,
RoborockZeoProtocol.ERROR,
]
else:
_LOGGER.warning("The device you added is not yet supported")
self.roborock_device_info = RoborockA01HassDeviceInfo(device, product_info)
async def _async_update_data(

View File

@ -75,6 +75,18 @@
},
"dock_error": {
"default": "mdi:garage-open"
},
"zeo_error": {
"default": "mdi:alert-circle"
},
"zeo_state": {
"default": "mdi:information-outline"
},
"washing_left": {
"default": "mdi:clock-outline"
},
"countdown": {
"default": "mdi:clock-outline"
}
},
"switch": {

View File

@ -6,14 +6,18 @@ from collections.abc import Callable
from dataclasses import dataclass
import datetime
from roborock.code_mappings import DyadError, RoborockDyadStateCode
from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState
from roborock.containers import (
RoborockDockErrorCode,
RoborockDockTypeCode,
RoborockErrorCode,
RoborockStateCode,
)
from roborock.roborock_message import RoborockDataProtocol, RoborockDyadDataProtocol
from roborock.roborock_message import (
RoborockDataProtocol,
RoborockDyadDataProtocol,
RoborockZeoProtocol,
)
from roborock.roborock_typing import DeviceProp
from homeassistant.components.sensor import (
@ -49,7 +53,7 @@ class RoborockSensorDescription(SensorEntityDescription):
class RoborockSensorDescriptionA01(SensorEntityDescription):
"""A class that describes Roborock sensors."""
data_protocol: RoborockDyadDataProtocol
data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol
def _dock_error_value_fn(properties: DeviceProp) -> str | None:
@ -247,6 +251,38 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
translation_key="total_cleaning_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
RoborockSensorDescriptionA01(
key="state",
data_protocol=RoborockZeoProtocol.STATE,
translation_key="zeo_state",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=ZeoState.keys(),
),
RoborockSensorDescriptionA01(
key="countdown",
native_unit_of_measurement=UnitOfTime.MINUTES,
data_protocol=RoborockZeoProtocol.COUNTDOWN,
device_class=SensorDeviceClass.DURATION,
translation_key="countdown",
entity_category=EntityCategory.DIAGNOSTIC,
),
RoborockSensorDescriptionA01(
key="washing_left",
native_unit_of_measurement=UnitOfTime.MINUTES,
data_protocol=RoborockZeoProtocol.WASHING_LEFT,
device_class=SensorDeviceClass.DURATION,
translation_key="washing_left",
entity_category=EntityCategory.DIAGNOSTIC,
),
RoborockSensorDescriptionA01(
key="error",
data_protocol=RoborockZeoProtocol.ERROR,
device_class=SensorDeviceClass.ENUM,
translation_key="zeo_error",
entity_category=EntityCategory.DIAGNOSTIC,
options=ZeoError.keys(),
),
]

View File

@ -152,6 +152,9 @@
"clean_percent": {
"name": "Cleaning progress"
},
"countdown": {
"name": "Countdown"
},
"dock_error": {
"name": "Dock error",
"state": {
@ -272,6 +275,47 @@
"mopping_roller_2": "[%key:component::roborock::entity::sensor::vacuum_error::state::mopping_roller_1%]",
"temperature_protection": "Unit temperature protection"
}
},
"washing_left": {
"name": "Washing left"
},
"zeo_error": {
"name": "Error",
"state": {
"none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]",
"refill_error": "Refill error",
"drain_error": "Drain error",
"door_lock_error": "Door lock error",
"water_level_error": "Water level error",
"inverter_error": "Inverter error",
"heating_error": "Heating error",
"temperature_error": "Temperature error",
"communication_error": "Communication error",
"drying_error": "Drying error",
"drying_error_e_12": "Drying error E12",
"drying_error_e_13": "Drying error E13",
"drying_error_e_14": "Drying error E14",
"drying_error_e_15": "Drying error E15",
"drying_error_e_16": "Drying error E16",
"drying_error_water_flow": "Check water flow",
"drying_error_restart": "Restart the washer",
"spin_error": "Re-arrange clothes"
}
},
"zeo_state": {
"name": "State",
"state": {
"standby": "Standby",
"weighing": "Weighing",
"soaking": "Soaking",
"washing": "Washing",
"rinsing": "Rinsing",
"spinning": "Spinning",
"drying": "Drying",
"cooling": "Cooling",
"under_delay_start": "Delayed start",
"done": "Done"
}
}
},
"select": {

View File

@ -4,8 +4,8 @@ from copy import deepcopy
from unittest.mock import patch
import pytest
from roborock import RoomMapping
from roborock.code_mappings import DyadError, RoborockDyadStateCode
from roborock import RoborockCategory, RoomMapping
from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from roborock.version_a01_apis import RoborockMqttClientA01
@ -38,14 +38,22 @@ class A01Mock(RoborockMqttClientA01):
def __init__(self, user_data, device_info, category) -> None:
"""Initialize the A01Mock."""
super().__init__(user_data, device_info, category)
self.protocol_responses = {
RoborockDyadDataProtocol.STATUS: RoborockDyadStateCode.drying.name,
RoborockDyadDataProtocol.POWER: 100,
RoborockDyadDataProtocol.MESH_LEFT: 111,
RoborockDyadDataProtocol.BRUSH_LEFT: 222,
RoborockDyadDataProtocol.ERROR: DyadError.none.name,
RoborockDyadDataProtocol.TOTAL_RUN_TIME: 213,
}
if category == RoborockCategory.WET_DRY_VAC:
self.protocol_responses = {
RoborockDyadDataProtocol.STATUS: RoborockDyadStateCode.drying.name,
RoborockDyadDataProtocol.POWER: 100,
RoborockDyadDataProtocol.MESH_LEFT: 111,
RoborockDyadDataProtocol.BRUSH_LEFT: 222,
RoborockDyadDataProtocol.ERROR: DyadError.none.name,
RoborockDyadDataProtocol.TOTAL_RUN_TIME: 213,
}
elif category == RoborockCategory.WASHING_MACHINE:
self.protocol_responses: list[RoborockZeoProtocol] = {
RoborockZeoProtocol.STATE: ZeoState.drying.name,
RoborockZeoProtocol.COUNTDOWN: 0,
RoborockZeoProtocol.WASHING_LEFT: 253,
RoborockZeoProtocol.ERROR: ZeoError.none.name,
}
async def update_values(
self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol]

View File

@ -951,6 +951,355 @@
}),
}),
}),
'**REDACTED-3**': dict({
'api': dict({
'misc_info': dict({
}),
}),
'roborock_device_info': dict({
'device': dict({
'activeTime': 1699964128,
'deviceStatus': dict({
'10001': '{"f":"t"}',
'10005': '{"sn":"zeo_sn","ssid":"internet","timezone":"Europe/Berlin","posix_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ip":"192.111.11.11","mac":"b0:4a:00:00:00:00","rssi":-57,"oba":{"language":"en","name":"A.03.0403_CE","bom":"A.03.0403","location":"de","wifiplan":"EU","timezone":"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin","logserver":"awsde0","loglevel":"4","featureset":"0"}}',
'10007': '{"mqttOtaData":{"mqttOtaStatus":{"status":"IDLE"}}}',
'200': 1,
'201': 0,
'202': 1,
'203': 7,
'204': 1,
'205': 33,
'206': 0,
'207': 4,
'208': 2,
'209': 7,
'210': 1,
'211': 1,
'212': 1,
'213': 2,
'214': 2,
'217': 0,
'218': 227,
'219': 0,
'220': 0,
'221': 0,
'222': 347414,
'223': 0,
'224': 21,
'225': 0,
'226': 0,
'227': 1,
'232': 0,
}),
'duid': '**REDACTED**',
'f': False,
'featureSet': '0',
'fv': '01.00.94',
'iconUrl': '',
'localKey': '**REDACTED**',
'name': 'Zeo One',
'newFeatureSet': '40',
'online': True,
'productId': 'zeo_id',
'pv': 'A01',
'share': True,
'shareTime': 1712763572,
'silentOtaSwitch': False,
'sn': 'zeo_sn',
'timeZoneId': 'Europe/Berlin',
'tuyaMigrated': False,
}),
'product': dict({
'capability': 2,
'category': 'roborock.wm',
'id': 'zeo_id',
'model': 'roborock.wm.a102',
'name': 'Zeo One',
'schema': list([
dict({
'code': 'drying_status',
'id': '134',
'mode': 'ro',
'name': '烘干状态',
'type': 'RAW',
}),
dict({
'code': 'start',
'id': '200',
'mode': 'rw',
'name': '启动',
'type': 'BOOL',
}),
dict({
'code': 'pause',
'id': '201',
'mode': 'rw',
'name': '暂停',
'type': 'BOOL',
}),
dict({
'code': 'shutdown',
'id': '202',
'mode': 'rw',
'name': '关机',
'type': 'BOOL',
}),
dict({
'code': 'status',
'id': '203',
'mode': 'ro',
'name': '状态',
'type': 'VALUE',
}),
dict({
'code': 'mode',
'id': '204',
'mode': 'rw',
'name': '模式',
'type': 'VALUE',
}),
dict({
'code': 'program',
'id': '205',
'mode': 'rw',
'name': '程序',
'type': 'VALUE',
}),
dict({
'code': 'child_lock',
'id': '206',
'mode': 'rw',
'name': '童锁',
'type': 'BOOL',
}),
dict({
'code': 'temp',
'id': '207',
'mode': 'rw',
'name': '洗涤温度',
'type': 'VALUE',
}),
dict({
'code': 'rinse_times',
'id': '208',
'mode': 'rw',
'name': '漂洗次数',
'type': 'VALUE',
}),
dict({
'code': 'spin_level',
'id': '209',
'mode': 'rw',
'name': '滚筒转速',
'type': 'VALUE',
}),
dict({
'code': 'drying_mode',
'id': '210',
'mode': 'rw',
'name': '干燥度',
'type': 'VALUE',
}),
dict({
'code': 'detergent_set',
'id': '211',
'mode': 'rw',
'name': '自动投放-洗衣液',
'type': 'BOOL',
}),
dict({
'code': 'softener_set',
'id': '212',
'mode': 'rw',
'name': '自动投放-柔顺剂',
'type': 'BOOL',
}),
dict({
'code': 'detergent_type',
'id': '213',
'mode': 'rw',
'name': '洗衣液投放量',
'type': 'VALUE',
}),
dict({
'code': 'softener_type',
'id': '214',
'mode': 'rw',
'name': '柔顺剂投放量',
'type': 'VALUE',
}),
dict({
'code': 'countdown',
'id': '217',
'mode': 'rw',
'name': '预约时间',
'type': 'VALUE',
}),
dict({
'code': 'washing_left',
'id': '218',
'mode': 'ro',
'name': '洗衣剩余时间',
'type': 'VALUE',
}),
dict({
'code': 'doorlock_state',
'id': '219',
'mode': 'ro',
'name': '门锁状态',
'type': 'BOOL',
}),
dict({
'code': 'error',
'id': '220',
'mode': 'ro',
'name': '故障',
'type': 'VALUE',
}),
dict({
'code': 'custom_param_save',
'id': '221',
'mode': 'rw',
'name': '云程序设置',
'type': 'VALUE',
}),
dict({
'code': 'custom_param_get',
'id': '222',
'mode': 'ro',
'name': '云程序读取',
'type': 'VALUE',
}),
dict({
'code': 'sound_set',
'id': '223',
'mode': 'rw',
'name': '提示音',
'type': 'BOOL',
}),
dict({
'code': 'times_after_clean',
'id': '224',
'mode': 'ro',
'name': '距离上次筒自洁次数',
'type': 'VALUE',
}),
dict({
'code': 'default_setting',
'id': '225',
'mode': 'rw',
'name': '记忆洗衣偏好开关',
'type': 'BOOL',
}),
dict({
'code': 'detergent_empty',
'id': '226',
'mode': 'ro',
'name': '洗衣液用尽',
'type': 'BOOL',
}),
dict({
'code': 'softener_empty',
'id': '227',
'mode': 'ro',
'name': '柔顺剂用尽',
'type': 'BOOL',
}),
dict({
'code': 'light_setting',
'id': '229',
'mode': 'rw',
'name': '筒灯设定',
'type': 'BOOL',
}),
dict({
'code': 'detergent_volume',
'id': '230',
'mode': 'rw',
'name': '洗衣液投放量(单次)',
'type': 'VALUE',
}),
dict({
'code': 'softener_volume',
'id': '231',
'mode': 'rw',
'name': '柔顺剂投放量(单次)',
'type': 'VALUE',
}),
dict({
'code': 'app_authorization',
'id': '232',
'mode': 'rw',
'name': '远程控制授权',
'type': 'VALUE',
}),
dict({
'code': 'id_query',
'id': '10000',
'mode': 'rw',
'name': 'ID点查询',
'type': 'STRING',
}),
dict({
'code': 'f_c',
'id': '10001',
'mode': 'ro',
'name': '防串货',
'type': 'STRING',
}),
dict({
'code': 'snd_state',
'id': '10004',
'mode': 'rw',
'name': '语音包/OBA信息',
'type': 'STRING',
}),
dict({
'code': 'product_info',
'id': '10005',
'mode': 'ro',
'name': '产品信息',
'type': 'STRING',
}),
dict({
'code': 'privacy_info',
'id': '10006',
'mode': 'rw',
'name': '隐私协议',
'type': 'STRING',
}),
dict({
'code': 'ota_nfo',
'id': '10007',
'mode': 'rw',
'name': 'OTA info',
'type': 'STRING',
}),
dict({
'code': 'washing_log',
'id': '10008',
'mode': 'ro',
'name': '洗衣记录',
'type': 'BOOL',
}),
dict({
'code': 'rpc_req',
'id': '10101',
'mode': 'wo',
'name': 'rpc req',
'type': 'STRING',
}),
dict({
'code': 'rpc_resp',
'id': '10102',
'mode': 'ro',
'name': 'rpc resp',
'type': 'STRING',
}),
]),
}),
}),
}),
}),
})
# ---

View File

@ -176,3 +176,21 @@ async def test_not_supported_protocol(
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert "because its protocol version random" in caplog.text
async def test_not_supported_a01_device(
hass: HomeAssistant,
bypass_api_fixture,
mock_roborock_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that we output a message on incorrect category."""
home_data_copy = deepcopy(HOME_DATA)
home_data_copy.products[2].category = "random"
with patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
return_value=home_data_copy,
):
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert "The device you added is not yet supported" in caplog.text

View File

@ -21,7 +21,7 @@ from tests.common import MockConfigEntry
async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None:
"""Test sensors and check test values are correctly set."""
assert len(hass.states.async_all("sensor")) == 34
assert len(hass.states.async_all("sensor")) == 38
assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str(
MAIN_BRUSH_REPLACE_TIME - 74382
)
@ -60,6 +60,10 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non
assert hass.states.get("sensor.dyad_pro_roller_left").state == "222"
assert hass.states.get("sensor.dyad_pro_error").state == "none"
assert hass.states.get("sensor.dyad_pro_total_cleaning_time").state == "213"
assert hass.states.get("sensor.zeo_one_state").state == "drying"
assert hass.states.get("sensor.zeo_one_countdown").state == "0"
assert hass.states.get("sensor.zeo_one_washing_left").state == "253"
assert hass.states.get("sensor.zeo_one_error").state == "none"
async def test_listener_update(