diff --git a/.coveragerc b/.coveragerc index 5265493ece1..d0ce82dd735 100644 --- a/.coveragerc +++ b/.coveragerc @@ -272,7 +272,8 @@ omit = homeassistant/components/econet/climate.py homeassistant/components/econet/sensor.py homeassistant/components/econet/water_heater.py - homeassistant/components/ecovacs/__init__.py + homeassistant/components/ecovacs/controller.py + homeassistant/components/ecovacs/entity.py homeassistant/components/ecovacs/util.py homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py diff --git a/.strict-typing b/.strict-typing index 3d76bb68224..d528484cc98 100644 --- a/.strict-typing +++ b/.strict-typing @@ -154,6 +154,7 @@ homeassistant.components.duckdns.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* homeassistant.components.easyenergy.* +homeassistant.components.ecovacs.* homeassistant.components.ecowitt.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index f8d6fc912e9..e4c8a965695 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -1,26 +1,14 @@ """Support for Ecovacs Deebot vacuums.""" -import logging - -from sucks import EcoVacsAPI, VacBot import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_COUNTRY, - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_CONTINENT, DOMAIN -from .util import get_client_device_id - -_LOGGER = logging.getLogger(__name__) - +from .controller import EcovacsController CONFIG_SCHEMA = vol.Schema( { @@ -54,56 +42,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" + controller = EcovacsController(hass, entry.data) + await controller.initialize() - def get_devices() -> list[VacBot]: - ecovacs_api = EcoVacsAPI( - get_client_device_id(), - entry.data[CONF_USERNAME], - EcoVacsAPI.md5(entry.data[CONF_PASSWORD]), - entry.data[CONF_COUNTRY], - entry.data[CONF_CONTINENT], - ) - ecovacs_devices = ecovacs_api.devices() - - _LOGGER.debug("Ecobot devices: %s", ecovacs_devices) - devices: list[VacBot] = [] - for device in ecovacs_devices: - _LOGGER.debug( - "Discovered Ecovacs device on account: %s with nickname %s", - device.get("did"), - device.get("nick"), - ) - vacbot = VacBot( - ecovacs_api.uid, - ecovacs_api.REALM, - ecovacs_api.resource, - ecovacs_api.user_access_token, - device, - entry.data[CONF_CONTINENT], - monitor=True, - ) - - devices.append(vacbot) - return devices - - hass.data.setdefault(DOMAIN, {})[ - entry.entry_id - ] = await hass.async_add_executor_job(get_devices) - - async def async_stop(event: object) -> None: - """Shut down open connections to Ecovacs XMPP server.""" - devices: list[VacBot] = hass.data[DOMAIN][entry.entry_id] - for device in devices: - _LOGGER.info( - "Shutting down connection to Ecovacs device %s", - device.vacuum.get("did"), - ) - await hass.async_add_executor_job(device.disconnect) - - # Listen for HA stop to disconnect. - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) - - if hass.data[DOMAIN][entry.entry_id]: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await hass.data[DOMAIN][entry.entry_id].teardown() + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 05232dddb53..75a0d28ae91 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -2,18 +2,23 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast -from sucks import EcoVacsAPI +from aiohttp import ClientError +from deebot_client.authentication import Authenticator +from deebot_client.exceptions import InvalidAuthenticationError +from deebot_client.models import Configuration +from deebot_client.util import md5 +from deebot_client.util.continents import COUNTRIES_TO_CONTINENTS, get_continent import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import AbortFlow, FlowResult -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, selector from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.loader import async_get_issue_tracker from .const import CONF_CONTINENT, DOMAIN from .util import get_client_device_id @@ -21,21 +26,34 @@ from .util import get_client_device_id _LOGGER = logging.getLogger(__name__) -def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +async def _validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, str]: """Validate user input.""" errors: dict[str, str] = {} + + deebot_config = Configuration( + aiohttp_client.async_get_clientsession(hass), + device_id=get_client_device_id(), + country=user_input[CONF_COUNTRY], + continent=user_input.get(CONF_CONTINENT), + ) + + authenticator = Authenticator( + deebot_config, + user_input[CONF_USERNAME], + md5(user_input[CONF_PASSWORD]), + ) + try: - EcoVacsAPI( - get_client_device_id(), - user_input[CONF_USERNAME], - EcoVacsAPI.md5(user_input[CONF_PASSWORD]), - user_input[CONF_COUNTRY], - user_input[CONF_CONTINENT], - ) - except ValueError: + await authenticator.authenticate() + except ClientError: + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuthenticationError: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected exception during login") errors["base"] = "unknown" return errors @@ -55,7 +73,7 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + errors = await _validate_input(self.hass, user_input) if not errors: return self.async_create_entry( @@ -65,7 +83,7 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - vol.Schema( + data_schema=vol.Schema( { vol.Required(CONF_USERNAME): selector.TextSelector( selector.TextSelectorConfig( @@ -77,11 +95,13 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): type=selector.TextSelectorType.PASSWORD ) ), - vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string), - vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string), + vol.Required(CONF_COUNTRY): selector.CountrySelector(), } ), - user_input, + suggested_values=user_input + or { + CONF_COUNTRY: self.hass.config.country, + }, ), errors=errors, ) @@ -89,7 +109,11 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import configuration from yaml.""" - def create_repair(error: str | None = None) -> None: + def create_repair( + error: str | None = None, placeholders: dict[str, Any] | None = None + ) -> None: + if placeholders is None: + placeholders = {} if error: async_create_issue( self.hass, @@ -100,9 +124,8 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key=f"deprecated_yaml_import_issue_{error}", - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=ecovacs" - }, + translation_placeholders=placeholders + | {"url": "/config/integrations/dashboard/add?domain=ecovacs"}, ) else: async_create_issue( @@ -114,12 +137,51 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", - translation_placeholders={ + translation_placeholders=placeholders + | { "domain": DOMAIN, "integration_title": "Ecovacs", }, ) + # We need to validate the imported country and continent + # as the YAML configuration allows any string for them. + # The config flow allows only valid alpha-2 country codes + # through the CountrySelector. + # The continent will be calculated with the function get_continent + # from the country code and there is no need to specify the continent anymore. + # As the YAML configuration includes the continent, + # we check if both the entered continent and the calculated continent match. + # If not we will inform the user about the mismatch. + error = None + placeholders = None + if len(user_input[CONF_COUNTRY]) != 2: + error = "invalid_country_length" + placeholders = {"countries_url": "https://www.iso.org/obp/ui/#search/code/"} + elif len(user_input[CONF_CONTINENT]) != 2: + error = "invalid_continent_length" + placeholders = { + "continent_list": ",".join( + sorted(set(COUNTRIES_TO_CONTINENTS.values())) + ) + } + elif user_input[CONF_CONTINENT].lower() != ( + continent := get_continent(user_input[CONF_COUNTRY]) + ): + error = "continent_not_match" + placeholders = { + "continent": continent, + "github_issue_url": cast( + str, async_get_issue_tracker(self.hass, integration_domain=DOMAIN) + ), + } + + if error: + create_repair(error, placeholders) + return self.async_abort(reason=error) + + # Remove the continent from the user input as it is not needed anymore + user_input.pop(CONF_CONTINENT) try: result = await self.async_step_user(user_input) except AbortFlow as ex: diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py new file mode 100644 index 00000000000..645c5b9bc19 --- /dev/null +++ b/homeassistant/components/ecovacs/controller.py @@ -0,0 +1,96 @@ +"""Controller module.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from deebot_client.api_client import ApiClient +from deebot_client.authentication import Authenticator +from deebot_client.device import Device +from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +from deebot_client.models import Configuration, DeviceInfo +from deebot_client.mqtt_client import MqttClient, MqttConfiguration +from deebot_client.util import md5 +from sucks import EcoVacsAPI, VacBot + +from homeassistant.const import ( + CONF_COUNTRY, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client + +from .util import get_client_device_id + +_LOGGER = logging.getLogger(__name__) + + +class EcovacsController: + """Ecovacs controller.""" + + def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: + """Initialize controller.""" + self._hass = hass + self.devices: list[Device] = [] + self.legacy_devices: list[VacBot] = [] + verify_ssl = config.get(CONF_VERIFY_SSL, True) + device_id = get_client_device_id() + + self._config = Configuration( + aiohttp_client.async_get_clientsession(self._hass, verify_ssl=verify_ssl), + device_id=device_id, + country=config[CONF_COUNTRY], + verify_ssl=verify_ssl, + ) + + self._authenticator = Authenticator( + self._config, + config[CONF_USERNAME], + md5(config[CONF_PASSWORD]), + ) + self._api_client = ApiClient(self._authenticator) + + mqtt_config = MqttConfiguration(config=self._config) + self._mqtt = MqttClient(mqtt_config, self._authenticator) + + async def initialize(self) -> None: + """Init controller.""" + try: + devices = await self._api_client.get_devices() + credentials = await self._authenticator.authenticate() + for device_config in devices: + if isinstance(device_config, DeviceInfo): + device = Device(device_config, self._authenticator) + await device.initialize(self._mqtt) + self.devices.append(device) + else: + # Legacy device + bot = VacBot( + credentials.user_id, + EcoVacsAPI.REALM, + self._config.device_id[0:8], + credentials.token, + device_config, + self._config.continent, + monitor=True, + ) + self.legacy_devices.append(bot) + except InvalidAuthenticationError as ex: + raise ConfigEntryError("Invalid credentials") from ex + except DeebotError as ex: + raise ConfigEntryNotReady("Error during setup") from ex + + _LOGGER.debug("Controller initialize complete") + + async def teardown(self) -> None: + """Disconnect controller.""" + for device in self.devices: + await device.teardown() + for legacy_device in self.legacy_devices: + await self._hass.async_add_executor_job(legacy_device.disconnect) + await self._mqtt.disconnect() + await self._authenticator.teardown() diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py new file mode 100644 index 00000000000..caaefef0956 --- /dev/null +++ b/homeassistant/components/ecovacs/entity.py @@ -0,0 +1,106 @@ +"""Ecovacs mqtt entity module.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from deebot_client.capabilities import Capabilities +from deebot_client.device import Device +from deebot_client.events import AvailabilityEvent +from deebot_client.events.base import Event + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import DOMAIN + +_EntityDescriptionT = TypeVar("_EntityDescriptionT", bound=EntityDescription) +CapabilityT = TypeVar("CapabilityT") +EventT = TypeVar("EventT", bound=Event) + + +@dataclass(kw_only=True, frozen=True) +class EcovacsEntityDescription( + EntityDescription, + Generic[CapabilityT], +): + """Ecovacs entity description.""" + + capability_fn: Callable[[Capabilities], CapabilityT | None] + + +class EcovacsEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]): + """Ecovacs entity.""" + + entity_description: _EntityDescriptionT + + _attr_should_poll = False + _attr_has_entity_name = True + _always_available: bool = False + + def __init__( + self, + device: Device, + capability: CapabilityT, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(**kwargs) + self._attr_unique_id = f"{device.device_info.did}_{self.entity_description.key}" + + self._device = device + self._capability = capability + self._subscribed_events: set[type[Event]] = set() + + @property + def device_info(self) -> DeviceInfo | None: + """Return device specific attributes.""" + device_info = self._device.device_info + info = DeviceInfo( + identifiers={(DOMAIN, device_info.did)}, + manufacturer="Ecovacs", + sw_version=self._device.fw_version, + serial_number=device_info.name, + ) + + if nick := device_info.api_device_info.get("nick"): + info["name"] = nick + + if model := device_info.api_device_info.get("deviceName"): + info["model"] = model + + if mac := self._device.mac: + info["connections"] = {(dr.CONNECTION_NETWORK_MAC, mac)} + + return info + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + if not self._always_available: + + async def on_available(event: AvailabilityEvent) -> None: + self._attr_available = event.available + self.async_write_ha_state() + + self._subscribe(AvailabilityEvent, on_available) + + def _subscribe( + self, + event_type: type[EventT], + callback: Callable[[EventT], Coroutine[Any, Any, None]], + ) -> None: + """Subscribe to events.""" + self._subscribed_events.add(event_type) + self.async_on_remove(self._device.events.subscribe(event_type, callback)) + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + for event_type in self._subscribed_events: + self._device.events.request_refresh(event_type) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 286a7ce5583..d08602bbba8 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", - "loggers": ["sleekxmppfs", "sucks"], - "requirements": ["py-sucks==0.9.8"] + "loggers": ["sleekxmppfs", "sucks", "deebot_client"], + "requirements": ["py-sucks==0.9.8", "deebot-client==4.3.0"] } diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 86bdef89b3b..2ae12c244a1 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -4,25 +4,52 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { "user": { "data": { - "continent": "Continent", "country": "Country", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" - }, - "data_description": { - "continent": "Your two-letter continent code (na, eu, etc)", - "country": "Your two-letter country code (us, uk, etc)" } } } }, + "entity": { + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "max": "Max", + "max_plus": "Max+", + "normal": "Normal", + "quiet": "Quiet" + } + }, + "rooms": { + "name": "Rooms" + } + } + } + } + }, + "exceptions": { + "vacuum_send_command_params_dict": { + "message": "Params must be a dictionary and not a list" + }, + "vacuum_send_command_params_required": { + "message": "Params are required for the command: {command}" + } + }, "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there was a connection error when trying to import the YAML configuration.\n\nPlease verify that you have a stable internet connection and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, "deprecated_yaml_import_issue_invalid_auth": { "title": "The Ecovacs YAML configuration import failed", "description": "Configuring Ecovacs using YAML is being removed but there was an authentication error when trying to import the YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." @@ -30,6 +57,18 @@ "deprecated_yaml_import_issue_unknown": { "title": "The Ecovacs YAML configuration import failed", "description": "Configuring Ecovacs using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_country_length": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an invalid country specified in the YAML configuration.\n\nPlease change the country to the [Alpha-2 code of your country]({countries_url}) and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_continent_length": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an invalid continent specified in the YAML configuration.\n\nPlease correct the continent to be one of {continent_list} and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_continent_not_match": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an unexpected continent specified in the YAML configuration.\n\nFrom the given country, the continent '{continent}' is expected. Change the continent and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually.\n\nIf the contintent '{continent}' is not applicable, please open an issue on [GitHub]({github_issue_url})." } } } diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 3b4f86920b6..a4927ab1e9f 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -1,9 +1,14 @@ """Support for Ecovacs Ecovacs Vacuums.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any +from deebot_client.capabilities import Capabilities +from deebot_client.device import Device +from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent +from deebot_client.models import CleanAction, CleanMode, Room, State import sucks from homeassistant.components.vacuum import ( @@ -11,16 +16,22 @@ from homeassistant.components.vacuum import ( STATE_DOCKED, STATE_ERROR, STATE_IDLE, + STATE_PAUSED, STATE_RETURNING, StateVacuumEntity, + StateVacuumEntityDescription, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util import slugify from .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsEntity _LOGGER = logging.getLogger(__name__) @@ -34,17 +45,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecovacs vacuums.""" - vacuums = [] - devices: list[sucks.VacBot] = hass.data[DOMAIN][config_entry.entry_id] - for device in devices: + vacuums: list[EcovacsVacuum | EcovacsLegacyVacuum] = [] + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + for device in controller.legacy_devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) + vacuums.append(EcovacsLegacyVacuum(device)) + for device in controller.devices: vacuums.append(EcovacsVacuum(device)) _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) async_add_entities(vacuums) -class EcovacsVacuum(StateVacuumEntity): - """Ecovacs Vacuums such as Deebot.""" +class EcovacsLegacyVacuum(StateVacuumEntity): + """Legacy Ecovacs vacuums.""" _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_should_poll = False @@ -65,7 +78,7 @@ class EcovacsVacuum(StateVacuumEntity): self.device = device vacuum = self.device.vacuum - self.error = None + self.error: str | None = None self._attr_unique_id = vacuum["did"] self._attr_name = vacuum.get("nick", vacuum["did"]) @@ -76,7 +89,7 @@ class EcovacsVacuum(StateVacuumEntity): self.device.lifespanEvents.subscribe(lambda _: self.schedule_update_ha_state()) self.device.errorEvents.subscribe(self.on_error) - def on_error(self, error): + def on_error(self, error: str) -> None: """Handle an error event from the robot. This will not change the entity's state. If the error caused the state @@ -116,7 +129,7 @@ class EcovacsVacuum(StateVacuumEntity): def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" if self.device.battery_status is not None: - return self.device.battery_status * 100 + return self.device.battery_status * 100 # type: ignore[no-any-return] return None @@ -130,7 +143,7 @@ class EcovacsVacuum(StateVacuumEntity): @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self.device.fan_speed + return self.device.fan_speed # type: ignore[no-any-return] @property def extra_state_attributes(self) -> dict[str, Any]: @@ -182,3 +195,178 @@ class EcovacsVacuum(StateVacuumEntity): ) -> None: """Send a command to a vacuum cleaner.""" self.device.run(sucks.VacBotCommand(command, params)) + + +_STATE_TO_VACUUM_STATE = { + State.IDLE: STATE_IDLE, + State.CLEANING: STATE_CLEANING, + State.RETURNING: STATE_RETURNING, + State.DOCKED: STATE_DOCKED, + State.ERROR: STATE_ERROR, + State.PAUSED: STATE_PAUSED, +} + +_ATTR_ROOMS = "rooms" + + +class EcovacsVacuum( + EcovacsEntity[Capabilities, StateVacuumEntityDescription], + StateVacuumEntity, +): + """Ecovacs vacuum.""" + + _unrecorded_attributes = frozenset({_ATTR_ROOMS}) + + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + + entity_description = StateVacuumEntityDescription( + key="vacuum", translation_key="vacuum", name=None + ) + + def __init__(self, device: Device) -> None: + """Initialize the vacuum.""" + capabilities = device.capabilities + super().__init__(device, capabilities) + + self._rooms: list[Room] = [] + + self._attr_fan_speed_list = [ + level.display_name for level in capabilities.fan_speed.types + ] + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_battery(event: BatteryEvent) -> None: + self._attr_battery_level = event.value + self.async_write_ha_state() + + async def on_fan_speed(event: FanSpeedEvent) -> None: + self._attr_fan_speed = event.speed.display_name + self.async_write_ha_state() + + async def on_rooms(event: RoomsEvent) -> None: + self._rooms = event.rooms + self.async_write_ha_state() + + async def on_status(event: StateEvent) -> None: + self._attr_state = _STATE_TO_VACUUM_STATE[event.state] + self.async_write_ha_state() + + self._subscribe(self._capability.battery.event, on_battery) + self._subscribe(self._capability.fan_speed.event, on_fan_speed) + self._subscribe(self._capability.state.event, on_status) + + if map_caps := self._capability.map: + self._subscribe(map_caps.rooms.event, on_rooms) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes. + + Implemented by platform classes. Convention for attribute names + is lowercase snake_case. + """ + rooms: dict[str, Any] = {} + for room in self._rooms: + # convert room name to snake_case to meet the convention + room_name = slugify(room.name) + room_values = rooms.get(room_name) + if room_values is None: + rooms[room_name] = room.id + elif isinstance(room_values, list): + room_values.append(room.id) + else: + # Convert from int to list + rooms[room_name] = [room_values, room.id] + + return { + _ATTR_ROOMS: rooms, + } + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self._device.execute_command(self._capability.fan_speed.set(fan_speed)) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + await self._device.execute_command(self._capability.charge.execute()) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner.""" + await self._clean_command(CleanAction.STOP) + + async def async_pause(self) -> None: + """Pause the vacuum cleaner.""" + await self._clean_command(CleanAction.PAUSE) + + async def async_start(self) -> None: + """Start the vacuum cleaner.""" + await self._clean_command(CleanAction.START) + + async def _clean_command(self, action: CleanAction) -> None: + await self._device.execute_command( + self._capability.clean.action.command(action) + ) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the vacuum cleaner.""" + await self._device.execute_command(self._capability.play_sound.execute()) + + 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.""" + _LOGGER.debug("async_send_command %s with %s", command, params) + if params is None: + params = {} + elif isinstance(params, list): + raise ServiceValidationError( + "Params must be a dict!", + translation_domain=DOMAIN, + translation_key="vacuum_send_command_params_dict", + ) + + if command in ["spot_area", "custom_area"]: + if params is None: + raise ServiceValidationError( + f"Params are required for {command}!", + translation_domain=DOMAIN, + translation_key="vacuum_send_command_params_required", + translation_placeholders={"command": command}, + ) + + if command in "spot_area": + await self._device.execute_command( + self._capability.clean.action.area( + CleanMode.SPOT_AREA, + str(params["rooms"]), + params.get("cleanings", 1), + ) + ) + elif command == "custom_area": + await self._device.execute_command( + self._capability.clean.action.area( + CleanMode.CUSTOM_AREA, + str(params["coordinates"]), + params.get("cleanings", 1), + ) + ) + else: + await self._device.execute_command( + self._capability.custom.set(command, params) + ) diff --git a/mypy.ini b/mypy.ini index ab823020c04..f3e3df193d3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1301,6 +1301,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ecovacs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ecowitt.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 22b84402904..7915b5ae557 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -677,6 +677,9 @@ debugpy==1.8.0 # homeassistant.components.decora # decora==0.6 +# homeassistant.components.ecovacs +deebot-client==4.3.0 + # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d822a01b45..4ba1300638b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,6 +552,9 @@ dbus-fast==2.21.1 # homeassistant.components.debugpy debugpy==1.8.0 +# homeassistant.components.ecovacs +deebot-client==4.3.0 + # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 5c1cf7adae0..9ba28857cbe 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -1,9 +1,18 @@ """Common fixtures for the Ecovacs tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from deebot_client.api_client import ApiClient +from deebot_client.authentication import Authenticator +from deebot_client.models import Credentials import pytest +from homeassistant.components.ecovacs.const import DOMAIN + +from .const import VALID_ENTRY_DATA + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -12,3 +21,44 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.ecovacs.async_setup_entry", return_value=True ) as async_setup_entry: yield async_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="username", + domain=DOMAIN, + data=VALID_ENTRY_DATA, + ) + + +@pytest.fixture +def mock_authenticator() -> Generator[Mock, None, None]: + """Mock the authenticator.""" + mock_authenticator = Mock(spec_set=Authenticator) + mock_authenticator.authenticate.return_value = Credentials("token", "user_id", 0) + with patch( + "homeassistant.components.ecovacs.controller.Authenticator", + return_value=mock_authenticator, + ), patch( + "homeassistant.components.ecovacs.config_flow.Authenticator", + return_value=mock_authenticator, + ): + yield mock_authenticator + + +@pytest.fixture +def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock: + """Mock authenticator.authenticate.""" + return mock_authenticator.authenticate + + +@pytest.fixture +def mock_api_client(mock_authenticator: Mock) -> Mock: + """Mock the API client.""" + with patch( + "homeassistant.components.ecovacs.controller.ApiClient", + return_value=Mock(spec_set=ApiClient), + ) as mock_api_client: + yield mock_api_client.return_value diff --git a/tests/components/ecovacs/const.py b/tests/components/ecovacs/const.py new file mode 100644 index 00000000000..f5100e69ee2 --- /dev/null +++ b/tests/components/ecovacs/const.py @@ -0,0 +1,13 @@ +"""Test ecovacs constants.""" + + +from homeassistant.components.ecovacs.const import CONF_CONTINENT +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME + +VALID_ENTRY_DATA = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_COUNTRY: "IT", +} + +IMPORT_DATA = VALID_ENTRY_DATA | {CONF_CONTINENT: "EU"} diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py index 9688634bec4..64f0758dc1f 100644 --- a/tests/components/ecovacs/test_config_flow.py +++ b/tests/components/ecovacs/test_config_flow.py @@ -1,25 +1,21 @@ """Test Ecovacs config flow.""" from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock +from aiohttp import ClientError +from deebot_client.exceptions import InvalidAuthenticationError import pytest -from sucks import EcoVacsAPI -from homeassistant.components.ecovacs.const import CONF_CONTINENT, DOMAIN +from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir -from tests.common import MockConfigEntry +from .const import IMPORT_DATA, VALID_ENTRY_DATA -_USER_INPUT = { - CONF_USERNAME: "username", - CONF_PASSWORD: "password", - CONF_COUNTRY: "it", - CONF_CONTINENT: "eu", -} +from tests.common import MockConfigEntry async def _test_user_flow(hass: HomeAssistant) -> dict[str, Any]: @@ -31,28 +27,29 @@ async def _test_user_flow(hass: HomeAssistant) -> dict[str, Any]: return await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=_USER_INPUT, + user_input=VALID_ENTRY_DATA, ) -async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, +) -> None: """Test the user config flow.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - result = await _test_user_flow(hass) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == _USER_INPUT[CONF_USERNAME] - assert result["data"] == _USER_INPUT - mock_setup_entry.assert_called() - mock_ecovacs.assert_called() + result = await _test_user_flow(hass) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() @pytest.mark.parametrize( ("side_effect", "reason"), [ - (ValueError, "invalid_auth"), + (ClientError, "cannot_connect"), + (InvalidAuthenticationError, "invalid_auth"), (Exception, "unknown"), ], ) @@ -61,50 +58,48 @@ async def test_user_flow_error( side_effect: Exception, reason: str, mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, ) -> None: """Test handling invalid connection.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - mock_ecovacs.side_effect = side_effect - result = await _test_user_flow(hass) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": reason} - mock_ecovacs.assert_called() - mock_setup_entry.assert_not_called() + mock_authenticator_authenticate.side_effect = side_effect - mock_ecovacs.reset_mock(side_effect=True) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=_USER_INPUT, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == _USER_INPUT[CONF_USERNAME] - assert result["data"] == _USER_INPUT - mock_setup_entry.assert_called() + result = await _test_user_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": reason} + mock_authenticator_authenticate.assert_called() + mock_setup_entry.assert_not_called() + + mock_authenticator_authenticate.reset_mock(side_effect=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_ENTRY_DATA, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() async def test_import_flow( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry: AsyncMock + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, ) -> None: """Test importing yaml config.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=_USER_INPUT, - ) - mock_ecovacs.assert_called() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=IMPORT_DATA.copy(), + ) + mock_authenticator_authenticate.assert_called() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == _USER_INPUT[CONF_USERNAME] - assert result["data"] == _USER_INPUT + assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues mock_setup_entry.assert_called() @@ -113,13 +108,13 @@ async def test_import_flow_already_configured( hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test importing yaml config where entry already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=_USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_ENTRY_DATA) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data=_USER_INPUT, + data=IMPORT_DATA.copy(), ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -129,7 +124,8 @@ async def test_import_flow_already_configured( @pytest.mark.parametrize( ("side_effect", "reason"), [ - (ValueError, "invalid_auth"), + (ClientError, "cannot_connect"), + (InvalidAuthenticationError, "invalid_auth"), (Exception, "unknown"), ], ) @@ -138,23 +134,20 @@ async def test_import_flow_error( side_effect: Exception, reason: str, issue_registry: ir.IssueRegistry, + mock_authenticator_authenticate: AsyncMock, ) -> None: """Test handling invalid connection.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - mock_ecovacs.side_effect = side_effect + mock_authenticator_authenticate.side_effect = side_effect - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=_USER_INPUT, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == reason - assert ( - DOMAIN, - f"deprecated_yaml_import_issue_{reason}", - ) in issue_registry.issues - mock_ecovacs.assert_called() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=IMPORT_DATA.copy(), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert ( + DOMAIN, + f"deprecated_yaml_import_issue_{reason}", + ) in issue_registry.issues + mock_authenticator_authenticate.assert_called() diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py new file mode 100644 index 00000000000..e6be4e22233 --- /dev/null +++ b/tests/components/ecovacs/test_init.py @@ -0,0 +1,85 @@ +"""Test init of ecovacs.""" +from typing import Any +from unittest.mock import AsyncMock, Mock + +from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +import pytest + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import IMPORT_DATA + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_api_client") +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: Mock, +) -> None: + """Test the Ecovacs configuration entry not ready.""" + mock_api_client.get_devices.side_effect = DeebotError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: Mock, +) -> None: + """Test auth error during setup.""" + mock_api_client.get_devices.side_effect = InvalidAuthenticationError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + ("config", "config_entries_expected"), + [ + ({}, 0), + ({DOMAIN: IMPORT_DATA.copy()}, 1), + ], +) +async def test_async_setup_import( + hass: HomeAssistant, + config: dict[str, Any], + config_entries_expected: int, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, +) -> None: + """Test async_setup config import.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected + assert mock_setup_entry.call_count == config_entries_expected + assert mock_authenticator_authenticate.call_count == config_entries_expected