Add support for MQTT based ecovacs vacuums (#108167)

* Add support for MQTT based ecovacs vacuums

* renames

* Add init import test

* bump deebot-client

* Translate continent options

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Remove continent

* use ServiceValidationError

* Small refactoring

* Simplify

* Fix tests

* Enable strict typing for ecovacs

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Implement suggestions

* improve test_async_setup_import

* Implement suggestions

* Update homeassistant/components/ecovacs/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Robert Resch 2024-01-19 16:52:30 +01:00 committed by GitHub
parent c1d6f740af
commit ed449a5abd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 786 additions and 185 deletions

View File

@ -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

View File

@ -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.*

View File

@ -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

View File

@ -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:

View File

@ -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()

View File

@ -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)

View File

@ -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"]
}

View File

@ -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})."
}
}
}

View File

@ -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)
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"}

View File

@ -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()

View File

@ -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