Add new Roborock Integration (#89456)

* init roborock commit

* init commit of roborock

* removed some non-vacuum related code

* removed some non-needed constants

* removed translations

* removed options flow

* removed manual control

* remove password login

* removed go-to

* removed unneeded function and improved device_stat

* removed utils as it is unused

* typing changes in vacuum.py

* fixed test patch paths

* removed unneeded records

* removing unneeded code in tests

* remove password from strings

* removed maps in code

* changed const, reworked functions

* remove menu

* fixed tests

* 100% code coverage config_flow

* small changes

* removed unneeded patch

* bump to 0.1.7

* removed services

* removed extra functions and mop

* add () to configEntryNotReady

* moved coordinator into seperate file

* update roborock testing

* removed stale options code

* normalize username for unique id

* removed unneeded variables

* fixed linter problems

* removed stale comment

* additional pr changes

* simplify config_flow

* fix config flow test

* Apply suggestions from code review

Co-authored-by: Allen Porter <allen.porter@gmail.com>

* First pass at resolving PR comments

* reworked config flow

* moving vacuum attr

* attempt to clean up conflig flow more

* update package and use offline functionality

* Fixed errors and fan bug

* rework model and some other small changes

* bump version

* used default factory

* moved some client creation into coord

* fixed patch

* Update homeassistant/components/roborock/coordinator.py

Co-authored-by: Allen Porter <allen.porter@gmail.com>

* moved async functions into gather

* reworked gathers

* removed random line

* error catch if networking doesn't exist or timeout

* bump to 0.6.5

* fixed mocked data reference url

* change checking if we have no network information

Co-authored-by: Allen Porter <allen.porter@gmail.com>

---------

Co-authored-by: Allen Porter <allen.porter@gmail.com>
Co-authored-by: Allen Porter <allen@thebends.org>
This commit is contained in:
Luke
2023-04-20 10:02:58 -04:00
committed by GitHub
parent af193094b5
commit b4e0a1f1fc
22 changed files with 1218 additions and 4 deletions

View File

@@ -0,0 +1 @@
"""Tests for Roborock integration."""

View File

@@ -0,0 +1,37 @@
"""Common methods used across tests for Roborock."""
from unittest.mock import patch
from homeassistant.components.roborock.const import (
CONF_BASE_URL,
CONF_USER_DATA,
DOMAIN,
)
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .mock_data import BASE_URL, HOME_DATA, USER_DATA, USER_EMAIL
from tests.common import MockConfigEntry
async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry:
"""Set up the Roborock platform."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
title=USER_EMAIL,
data={
CONF_USERNAME: USER_EMAIL,
CONF_USER_DATA: USER_DATA.as_dict(),
CONF_BASE_URL: BASE_URL,
},
)
mock_entry.add_to_hass(hass)
with patch("homeassistant.components.roborock.PLATFORMS", [platform]), patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data",
return_value=HOME_DATA,
), patch("homeassistant.components.roborock.RoborockMqttClient.get_networking"):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
return mock_entry

View File

@@ -0,0 +1,18 @@
"""Global fixtures for Roborock integration."""
from unittest.mock import patch
import pytest
from .mock_data import PROP
@pytest.fixture(name="bypass_api_fixture")
def bypass_api_fixture() -> None:
"""Skip calls to the API."""
with patch("homeassistant.components.roborock.RoborockMqttClient.connect"), patch(
"homeassistant.components.roborock.RoborockMqttClient.send_command"
), patch(
"homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop",
return_value=PROP,
):
yield

View File

@@ -0,0 +1,370 @@
"""Mock data for Roborock tests."""
from __future__ import annotations
from roborock.containers import (
CleanRecord,
CleanSummary,
Consumable,
DNDTimer,
HomeData,
Status,
UserData,
)
from roborock.typing import RoborockDeviceProp
# All data is based on a U.S. customer with a Roborock S7 MaxV Ultra
USER_EMAIL = "user@domain.com"
BASE_URL = "https://usiot.roborock.com"
USER_DATA = UserData.from_dict(
{
"tuyaname": "abc123",
"tuyapwd": "abc123",
"uid": 123456,
"tokentype": "",
"token": "abc123",
"rruid": "abc123",
"region": "us",
"countrycode": "1",
"country": "US",
"nickname": "user_nickname",
"rriot": {
"u": "abc123",
"s": "abc123",
"h": "abc123",
"k": "abc123",
"r": {
"r": "US",
"a": "https://api-us.roborock.com",
"m": "ssl://mqtt-us-2.roborock.com:8883",
"l": "https://wood-us.roborock.com",
},
},
"tuyaDeviceState": 2,
"avatarurl": "https://files.roborock.com/iottest/default_avatar.png",
}
)
MOCK_CONFIG = {
"username": USER_EMAIL,
"user_data": USER_DATA.as_dict(),
"base_url": None,
}
HOME_DATA_RAW = {
"id": 123456,
"name": "My Home",
"lon": None,
"lat": None,
"geoName": None,
"products": [
{
"id": "abc123",
"name": "Roborock S7 MaxV",
"code": "a27",
"model": "roborock.vacuum.a27",
"iconUrl": None,
"attribute": None,
"capability": 0,
"category": "robot.vacuum.cleaner",
"schema": [
{
"id": "101",
"name": "rpc_request",
"code": "rpc_request",
"mode": "rw",
"type": "RAW",
"property": None,
"desc": None,
},
{
"id": "102",
"name": "rpc_response",
"code": "rpc_response",
"mode": "rw",
"type": "RAW",
"property": None,
"desc": None,
},
{
"id": "120",
"name": "错误代码",
"code": "error_code",
"mode": "ro",
"type": "ENUM",
"property": '{"range": []}',
"desc": None,
},
{
"id": "121",
"name": "设备状态",
"code": "state",
"mode": "ro",
"type": "ENUM",
"property": '{"range": []}',
"desc": None,
},
{
"id": "122",
"name": "设备电量",
"code": "battery",
"mode": "ro",
"type": "ENUM",
"property": '{"range": []}',
"desc": None,
},
{
"id": "123",
"name": "清扫模式",
"code": "fan_power",
"mode": "rw",
"type": "ENUM",
"property": '{"range": []}',
"desc": None,
},
{
"id": "124",
"name": "拖地模式",
"code": "water_box_mode",
"mode": "rw",
"type": "ENUM",
"property": '{"range": []}',
"desc": None,
},
{
"id": "125",
"name": "主刷寿命",
"code": "main_brush_life",
"mode": "rw",
"type": "VALUE",
"property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}',
"desc": None,
},
{
"id": "126",
"name": "边刷寿命",
"code": "side_brush_life",
"mode": "rw",
"type": "VALUE",
"property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}',
"desc": None,
},
{
"id": "127",
"name": "滤网寿命",
"code": "filter_life",
"mode": "rw",
"type": "VALUE",
"property": '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}',
"desc": None,
},
{
"id": "128",
"name": "额外状态",
"code": "additional_props",
"mode": "ro",
"type": "RAW",
"property": None,
"desc": None,
},
{
"id": "130",
"name": "完成事件",
"code": "task_complete",
"mode": "ro",
"type": "RAW",
"property": None,
"desc": None,
},
{
"id": "131",
"name": "电量不足任务取消",
"code": "task_cancel_low_power",
"mode": "ro",
"type": "RAW",
"property": None,
"desc": None,
},
{
"id": "132",
"name": "运动中任务取消",
"code": "task_cancel_in_motion",
"mode": "ro",
"type": "RAW",
"property": None,
"desc": None,
},
{
"id": "133",
"name": "充电状态",
"code": "charge_status",
"mode": "ro",
"type": "RAW",
"property": None,
"desc": None,
},
{
"id": "134",
"name": "烘干状态",
"code": "drying_status",
"mode": "ro",
"type": "RAW",
"property": None,
"desc": None,
},
],
}
],
"devices": [
{
"duid": "abc123",
"name": "Roborock S7 MaxV",
"attribute": None,
"activeTime": 1672364449,
"localKey": "abc123",
"runtimeEnv": None,
"timeZoneId": "America/Los_Angeles",
"iconUrl": "",
"productId": "abc123",
"lon": None,
"lat": None,
"share": False,
"shareTime": None,
"online": True,
"fv": "02.56.02",
"pv": "1.0",
"roomId": 2362003,
"tuyaUuid": None,
"tuyaMigrated": False,
"extra": '{"RRPhotoPrivacyVersion": "1"}',
"sn": "abc123",
"featureSet": "2234201184108543",
"newFeatureSet": "0000000000002041",
"deviceStatus": {
"121": 8,
"122": 100,
"123": 102,
"124": 203,
"125": 94,
"126": 90,
"127": 87,
"128": 0,
"133": 1,
"120": 0,
},
"silentOtaSwitch": True,
}
],
"receivedDevices": [],
"rooms": [
{"id": 2362048, "name": "Example room 1"},
{"id": 2362044, "name": "Example room 2"},
{"id": 2362041, "name": "Example room 3"},
],
}
HOME_DATA: HomeData = HomeData.from_dict(HOME_DATA_RAW)
CLEAN_RECORD = CleanRecord.from_dict(
{
"begin": 1672543330,
"end": 1672544638,
"duration": 1176,
"area": 20965000,
"error": 0,
"complete": 1,
"start_type": 2,
"clean_type": 3,
"finish_reason": 56,
"dust_collection_status": 1,
"avoid_count": 19,
"wash_count": 2,
"map_flag": 0,
}
)
CLEAN_SUMMARY = CleanSummary.from_dict(
{
"clean_time": 74382,
"clean_area": 1159182500,
"clean_count": 31,
"dust_collection_count": 25,
"records": [
1672543330,
1672458041,
],
}
)
CONSUMABLE = Consumable.from_dict(
{
"main_brush_work_time": 74382,
"side_brush_work_time": 74382,
"filter_work_time": 74382,
"filter_element_work_time": 0,
"sensor_dirty_time": 74382,
"strainer_work_times": 65,
"dust_collection_work_times": 25,
"cleaning_brush_work_times": 65,
}
)
DND_TIMER = DNDTimer.from_dict(
{
"start_hour": 22,
"start_minute": 0,
"end_hour": 7,
"end_minute": 0,
"enabled": 1,
}
)
STATUS = Status.from_dict(
{
"msg_ver": 2,
"msg_seq": 458,
"state": 8,
"battery": 100,
"clean_time": 1176,
"clean_area": 20965000,
"error_code": 0,
"map_present": 1,
"in_cleaning": 0,
"in_returning": 0,
"in_fresh_state": 1,
"lab_status": 1,
"water_box_status": 1,
"back_type": -1,
"wash_phase": 0,
"wash_ready": 0,
"fan_power": 102,
"dnd_enabled": 0,
"map_status": 3,
"is_locating": 0,
"lock_status": 0,
"water_box_mode": 203,
"water_box_carriage_status": 1,
"mop_forbidden_enable": 1,
"camera_status": 3457,
"is_exploring": 0,
"home_sec_status": 0,
"home_sec_enable_password": 0,
"adbumper_status": [0, 0, 0],
"water_shortage_status": 0,
"dock_type": 3,
"dust_collection_status": 0,
"auto_dust_collection": 1,
"avoid_count": 19,
"mop_mode": 300,
"debug_mode": 0,
"collision_avoid_status": 1,
"switch_map_mode": 0,
"dock_error_status": 0,
"charge_status": 1,
"unsave_map_reason": 0,
"unsave_map_flag": 0,
}
)
PROP = RoborockDeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD)

View File

@@ -0,0 +1,169 @@
"""Test Roborock config flow."""
from unittest.mock import patch
import pytest
from roborock.exceptions import RoborockException
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN
from homeassistant.core import HomeAssistant
from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL
async def test_config_flow_success(
hass: HomeAssistant,
bypass_api_fixture,
) -> None:
"""Handle the config flow and make sure it succeeds."""
with patch(
"homeassistant.components.roborock.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.request_code"
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"username": USER_EMAIL}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "code"
assert result["errors"] == {}
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.code_login",
return_value=USER_DATA,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USER_EMAIL
assert result["data"] == MOCK_CONFIG
assert result["result"]
assert len(mock_setup.mock_calls) == 1
@pytest.mark.parametrize(
(
"request_code_side_effect",
"request_code_errors",
),
[
(RoborockException(), {"base": "invalid_email"}),
(Exception(), {"base": "unknown"}),
],
)
async def test_config_flow_failures_request_code(
hass: HomeAssistant,
bypass_api_fixture,
request_code_side_effect: Exception | None,
request_code_errors: dict[str, str],
) -> None:
"""Handle applying errors to request code recovering from the errors."""
with patch(
"homeassistant.components.roborock.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.request_code",
side_effect=request_code_side_effect,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"username": USER_EMAIL}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == request_code_errors
# Recover from error
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.request_code"
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"username": USER_EMAIL}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "code"
assert result["errors"] == {}
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.code_login",
return_value=USER_DATA,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USER_EMAIL
assert result["data"] == MOCK_CONFIG
assert result["result"]
assert len(mock_setup.mock_calls) == 1
@pytest.mark.parametrize(
(
"code_login_side_effect",
"code_login_errors",
),
[
(RoborockException(), {"base": "invalid_code"}),
(Exception(), {"base": "unknown"}),
],
)
async def test_config_flow_failures_code_login(
hass: HomeAssistant,
bypass_api_fixture,
code_login_side_effect: Exception | None,
code_login_errors: dict[str, str],
) -> None:
"""Handle applying errors to code login and recovering from the errors."""
with patch(
"homeassistant.components.roborock.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.request_code"
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"username": USER_EMAIL}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "code"
assert result["errors"] == {}
# Raise exception for invalid code
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.code_login",
side_effect=code_login_side_effect,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == code_login_errors
with patch(
"homeassistant.components.roborock.config_flow.RoborockApiClient.code_login",
return_value=USER_DATA,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USER_EMAIL
assert result["data"] == MOCK_CONFIG
assert result["result"]
assert len(mock_setup.mock_calls) == 1

View File

@@ -0,0 +1,35 @@
"""Test for Roborock init."""
from unittest.mock import patch
from homeassistant.components.roborock.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import UpdateFailed
from .common import setup_platform
async def test_unload_entry(hass: HomeAssistant, bypass_api_fixture) -> None:
"""Test unloading roboorck integration."""
entry = await setup_platform(hass, Platform.VACUUM)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert entry.state is ConfigEntryState.LOADED
with patch(
"homeassistant.components.roborock.coordinator.RoborockLocalClient.async_disconnect"
) as mock_disconnect:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert mock_disconnect.call_count == 1
assert entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
"""Test that when coordinator update fails, entry retries."""
with patch(
"homeassistant.components.roborock.RoborockDataUpdateCoordinator._async_update_data",
side_effect=UpdateFailed(),
):
entry = await setup_platform(hass, Platform.VACUUM)
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -0,0 +1,19 @@
"""Tests for Roborock vacuums."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .common import setup_platform
ENTITY_ID = "vacuum.roborock_s7_maxv"
DEVICE_ID = "abc123"
async def test_registry_entries(hass: HomeAssistant, bypass_api_fixture) -> None:
"""Tests devices are registered in the entity registry."""
await setup_platform(hass, Platform.VACUUM)
entity_registry = er.async_get(hass)
entry = entity_registry.async_get(ENTITY_ID)
assert entry.unique_id == DEVICE_ID