diff --git a/.coveragerc b/.coveragerc index 84ff641d400..37eb7d7372e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -995,6 +995,8 @@ omit = homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py + homeassistant/components/roborock/coordinator.py + homeassistant/components/roborock/vacuum.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 672d83c6956..272e2f1a075 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -999,6 +999,8 @@ build.json @home-assistant/supervisor /tests/components/rituals_perfume_genie/ @milanmeu /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi +/homeassistant/components/roborock/ @humbertogontijo @Lash-L +/tests/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington /homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py new file mode 100644 index 00000000000..497d30b41cf --- /dev/null +++ b/homeassistant/components/roborock/__init__.py @@ -0,0 +1,77 @@ +"""The Roborock component.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from roborock.api import RoborockApiClient +from roborock.cloud_api import RoborockMqttClient +from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS +from .coordinator import RoborockDataUpdateCoordinator + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up roborock from a config entry.""" + _LOGGER.debug("Integration async setup entry: %s", entry.as_dict()) + + user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) + api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) + _LOGGER.debug("Getting home data") + home_data = await api_client.get_home_data(user_data) + _LOGGER.debug("Got home data %s", home_data) + devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices + # Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map. + mqtt_client = RoborockMqttClient( + user_data, {device.duid: RoborockDeviceInfo(device) for device in devices} + ) + network_results = await asyncio.gather( + *(mqtt_client.get_networking(device.duid) for device in devices) + ) + network_info = { + device.duid: result + for device, result in zip(devices, network_results) + if result is not None + } + await mqtt_client.async_disconnect() + if not network_info: + raise ConfigEntryNotReady( + "Could not get network information about your devices" + ) + + product_info = {product.id: product for product in home_data.products} + coordinator = RoborockDataUpdateCoordinator( + hass, + devices, + network_info, + product_info, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await hass.data[DOMAIN][entry.entry_id].release() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py new file mode 100644 index 00000000000..d0c2147c1ee --- /dev/null +++ b/homeassistant/components/roborock/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Roborock.""" +from __future__ import annotations + +import logging +from typing import Any + +from roborock.api import RoborockApiClient +from roborock.containers import UserData +from roborock.exceptions import RoborockException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_BASE_URL, CONF_ENTRY_CODE, CONF_USER_DATA, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Roborock.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._username: str | None = None + self._client: RoborockApiClient | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + await self.async_set_unique_id(username.lower()) + self._abort_if_unique_id_configured() + self._username = username + _LOGGER.debug("Requesting code for Roborock account") + self._client = RoborockApiClient(username) + try: + await self._client.request_code() + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "invalid_email" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return await self.async_step_code() + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_USERNAME): str}), + errors=errors, + ) + + async def async_step_code( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + assert self._client + assert self._username + if user_input is not None: + code = user_input[CONF_ENTRY_CODE] + _LOGGER.debug("Logging into Roborock account using email provided code") + try: + login_data = await self._client.code_login(code) + except RoborockException as ex: + _LOGGER.exception(ex) + errors["base"] = "invalid_code" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception(ex) + errors["base"] = "unknown" + else: + return self._create_entry(self._client, self._username, login_data) + + return self.async_show_form( + step_id="code", + data_schema=vol.Schema({vol.Required(CONF_ENTRY_CODE): str}), + errors=errors, + ) + + def _create_entry( + self, client: RoborockApiClient, username: str, user_data: UserData + ) -> FlowResult: + """Finished config flow and create entry.""" + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_USER_DATA: user_data.as_dict(), + CONF_BASE_URL: client.base_url, + }, + ) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py new file mode 100644 index 00000000000..597d1923f20 --- /dev/null +++ b/homeassistant/components/roborock/const.py @@ -0,0 +1,9 @@ +"""Constants for Roborock.""" +from homeassistant.const import Platform + +DOMAIN = "roborock" +CONF_ENTRY_CODE = "code" +CONF_BASE_URL = "base_url" +CONF_USER_DATA = "user_data" + +PLATFORMS = [Platform.VACUUM] diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py new file mode 100644 index 00000000000..daf3257d26a --- /dev/null +++ b/homeassistant/components/roborock/coordinator.py @@ -0,0 +1,88 @@ +"""Roborock Coordinator.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +from roborock.containers import ( + HomeDataDevice, + HomeDataProduct, + NetworkInfo, + RoborockLocalDeviceInfo, +) +from roborock.exceptions import RoborockException +from roborock.local_api import RoborockLocalClient +from roborock.typing import RoborockDeviceProp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .models import RoborockHassDeviceInfo + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class RoborockDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, RoborockDeviceProp]] +): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + devices: list[HomeDataDevice], + devices_networking: dict[str, NetworkInfo], + product_info: dict[str, HomeDataProduct], + ) -> None: + """Initialize.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + local_devices_info: dict[str, RoborockLocalDeviceInfo] = {} + hass_devices_info: dict[str, RoborockHassDeviceInfo] = {} + for device in devices: + if not (networking := devices_networking.get(device.duid)): + _LOGGER.warning("Device %s is offline and cannot be setup", device.duid) + continue + hass_devices_info[device.duid] = RoborockHassDeviceInfo( + device, + networking, + product_info[device.product_id], + RoborockDeviceProp(), + ) + local_devices_info[device.duid] = RoborockLocalDeviceInfo( + device, networking + ) + self.api = RoborockLocalClient(local_devices_info) + self.devices_info = hass_devices_info + + async def release(self) -> None: + """Disconnect from API.""" + await self.api.async_disconnect() + + async def _update_device_prop(self, device_info: RoborockHassDeviceInfo) -> None: + """Update device properties.""" + device_prop = await self.api.get_prop(device_info.device.duid) + if device_prop: + if device_info.props: + device_info.props.update(device_prop) + else: + device_info.props = device_prop + + async def _async_update_data(self) -> dict[str, RoborockDeviceProp]: + """Update data via library.""" + try: + asyncio.gather( + *( + self._update_device_prop(device_info) + for device_info in self.devices_info.values() + ) + ) + except RoborockException as ex: + raise UpdateFailed(ex) from ex + return { + device_id: device_info.props + for device_id, device_info in self.devices_info.items() + } diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py new file mode 100644 index 00000000000..86dcbcade01 --- /dev/null +++ b/homeassistant/components/roborock/device.py @@ -0,0 +1,62 @@ +"""Support for Roborock device base class.""" + +from typing import Any + +from roborock.containers import Status +from roborock.typing import RoborockCommand + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .models import RoborockHassDeviceInfo + + +class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): + """Representation of a base a coordinated Roborock Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + unique_id: str, + device_info: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize the coordinated Roborock Device.""" + super().__init__(coordinator) + self._attr_unique_id = unique_id + self._device_name = device_info.device.name + self._device_id = device_info.device.duid + self._device_model = device_info.product.model + self._fw_version = device_info.device.fv + + @property + def _device_status(self) -> Status: + """Return the status of the device.""" + data = self.coordinator.data + if data: + device_data = data.get(self._device_id) + if device_data: + status = device_data.status + if status: + return status + return Status({}) + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + name=self._device_name, + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Roborock", + model=self._device_model, + sw_version=self._fw_version, + ) + + async def send( + self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + ) -> dict: + """Send a command to a vacuum cleaner.""" + return await self.coordinator.api.send_command(self._device_id, command, params) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 00f90271cfe..20dee34db05 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -1,6 +1,10 @@ { "domain": "roborock", "name": "Roborock", - "integration_type": "virtual", - "supported_by": "xiaomi_miio" + "codeowners": ["@humbertogontijo", "@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/roborock", + "iot_class": "local_polling", + "loggers": ["roborock"], + "requirements": ["python-roborock==0.6.5"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py new file mode 100644 index 00000000000..ae0adb4ad7d --- /dev/null +++ b/homeassistant/components/roborock/models.py @@ -0,0 +1,15 @@ +"""Roborock Models.""" +from dataclasses import dataclass + +from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo +from roborock.typing import RoborockDeviceProp + + +@dataclass +class RoborockHassDeviceInfo: + """A model to describe roborock devices.""" + + device: HomeDataDevice + network_info: NetworkInfo + product: HomeDataProduct + props: RoborockDeviceProp diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json new file mode 100644 index 00000000000..eb4a5e7d901 --- /dev/null +++ b/homeassistant/components/roborock/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Roborock email address.", + "data": { + "username": "Email" + } + }, + "code": { + "description": "Type the verification code sent to your email", + "data": { + "code": "Verification code" + } + } + }, + "error": { + "invalid_code": "The code you entered was incorrect, please check it and try again.", + "invalid_email": "There is no account associated with the email you entered, please try again.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py new file mode 100644 index 00000000000..4306afb25e4 --- /dev/null +++ b/homeassistant/components/roborock/vacuum.py @@ -0,0 +1,173 @@ +"""Support for Roborock vacuum class.""" +from typing import Any + +from roborock.code_mappings import RoborockFanPowerCode, RoborockStateCode +from roborock.typing import RoborockCommand + +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity +from .models import RoborockHassDeviceInfo + +STATE_CODE_TO_STATE = { + RoborockStateCode["1"]: STATE_IDLE, # "Starting" + RoborockStateCode["2"]: STATE_IDLE, # "Charger disconnected" + RoborockStateCode["3"]: STATE_IDLE, # "Idle" + RoborockStateCode["4"]: STATE_CLEANING, # "Remote control active" + RoborockStateCode["5"]: STATE_CLEANING, # "Cleaning" + RoborockStateCode["6"]: STATE_RETURNING, # "Returning home" + RoborockStateCode["7"]: STATE_CLEANING, # "Manual mode" + RoborockStateCode["8"]: STATE_DOCKED, # "Charging" + RoborockStateCode["9"]: STATE_ERROR, # "Charging problem" + RoborockStateCode["10"]: STATE_PAUSED, # "Paused" + RoborockStateCode["11"]: STATE_CLEANING, # "Spot cleaning" + RoborockStateCode["12"]: STATE_ERROR, # "Error" + RoborockStateCode["13"]: STATE_IDLE, # "Shutting down" + RoborockStateCode["14"]: STATE_DOCKED, # "Updating" + RoborockStateCode["15"]: STATE_RETURNING, # "Docking" + RoborockStateCode["16"]: STATE_CLEANING, # "Going to target" + RoborockStateCode["17"]: STATE_CLEANING, # "Zoned cleaning" + RoborockStateCode["18"]: STATE_CLEANING, # "Segment cleaning" + RoborockStateCode["22"]: STATE_DOCKED, # "Emptying the bin" on s7+ + RoborockStateCode["23"]: STATE_DOCKED, # "Washing the mop" on s7maxV + RoborockStateCode["26"]: STATE_RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode["100"]: STATE_DOCKED, # "Charging complete" + RoborockStateCode["101"]: STATE_ERROR, # "Device offline" +} + + +ATTR_STATUS = "status" +ATTR_ERROR = "error" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock sensor.""" + coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockVacuum(slugify(device_id), device_info, coordinator) + for device_id, device_info in coordinator.devices_info.items() + ) + + +class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): + """General Representation of a Roborock vacuum.""" + + _attr_icon = "mdi:robot-vacuum" + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.STATUS + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + _attr_fan_speed_list = RoborockFanPowerCode.values() + + def __init__( + self, + unique_id: str, + device: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize a vacuum.""" + StateVacuumEntity.__init__(self) + RoborockCoordinatedEntity.__init__(self, unique_id, device, coordinator) + + @property + def state(self) -> str | None: + """Return the status of the vacuum cleaner.""" + return STATE_CODE_TO_STATE.get(self._device_status.state) + + @property + def status(self) -> str | None: + """Return the status of the vacuum cleaner.""" + return self._device_status.status + + @property + def battery_level(self) -> int | None: + """Return the battery level of the vacuum cleaner.""" + return self._device_status.battery + + @property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + return self._device_status.fan_power + + @property + def error(self) -> str | None: + """Get the error str if an error code exists.""" + return self._device_status.error + + async def async_start(self) -> None: + """Start the vacuum.""" + await self.send(RoborockCommand.APP_START) + + async def async_pause(self) -> None: + """Pause the vacuum.""" + await self.send(RoborockCommand.APP_PAUSE) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum.""" + await self.send(RoborockCommand.APP_STOP) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Send vacuum back to base.""" + await self.send(RoborockCommand.APP_CHARGE) + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Spot clean.""" + await self.send(RoborockCommand.APP_SPOT) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate vacuum.""" + await self.send(RoborockCommand.FIND_ME) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set vacuum fan speed.""" + await self.send( + RoborockCommand.SET_CUSTOM_MODE, + [k for k, v in RoborockFanPowerCode.items() if v == fan_speed], + ) + await self.coordinator.async_request_refresh() + + async def async_start_pause(self): + """Start, pause or resume the cleaning task.""" + if self.state == STATE_CLEANING: + await self.async_pause() + else: + await self.async_start() + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to a vacuum cleaner.""" + await self.send(command, params) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 074fc1ee858..bf034053ffc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -361,6 +361,7 @@ FLOWS = { "ring", "risco", "rituals_perfume_genie", + "roborock", "roku", "roomba", "roon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fd7e8af36a5..bae8aa3fd39 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4590,8 +4590,9 @@ }, "roborock": { "name": "Roborock", - "integration_type": "virtual", - "supported_by": "xiaomi_miio" + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" }, "rocketchat": { "name": "Rocket.Chat", diff --git a/requirements_all.txt b/requirements_all.txt index 672c905ec0d..426fcbad9ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2104,6 +2104,9 @@ python-qbittorrent==0.4.2 # homeassistant.components.ripple python-ripple-api==0.0.3 +# homeassistant.components.roborock +python-roborock==0.6.5 + # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01eeb155baf..e25537c3805 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1512,6 +1512,9 @@ python-picnic-api==1.1.0 # homeassistant.components.qbittorrent python-qbittorrent==0.4.2 +# homeassistant.components.roborock +python-roborock==0.6.5 + # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/__init__.py b/tests/components/roborock/__init__.py new file mode 100644 index 00000000000..f5de63d5819 --- /dev/null +++ b/tests/components/roborock/__init__.py @@ -0,0 +1 @@ +"""Tests for Roborock integration.""" diff --git a/tests/components/roborock/common.py b/tests/components/roborock/common.py new file mode 100644 index 00000000000..5155c6e0c0c --- /dev/null +++ b/tests/components/roborock/common.py @@ -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 diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py new file mode 100644 index 00000000000..82e7be4751a --- /dev/null +++ b/tests/components/roborock/conftest.py @@ -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 diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py new file mode 100644 index 00000000000..d6afff440f7 --- /dev/null +++ b/tests/components/roborock/mock_data.py @@ -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) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py new file mode 100644 index 00000000000..d319f0e165d --- /dev/null +++ b/tests/components/roborock/test_config_flow.py @@ -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 diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py new file mode 100644 index 00000000000..bf57105a7f7 --- /dev/null +++ b/tests/components/roborock/test_init.py @@ -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 diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py new file mode 100644 index 00000000000..2bb0432c661 --- /dev/null +++ b/tests/components/roborock/test_vacuum.py @@ -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