mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 09:17:53 +00:00
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:
parent
c1d6f740af
commit
ed449a5abd
@ -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
|
||||
|
@ -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.*
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
96
homeassistant/components/ecovacs/controller.py
Normal file
96
homeassistant/components/ecovacs/controller.py
Normal 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()
|
106
homeassistant/components/ecovacs/entity.py
Normal file
106
homeassistant/components/ecovacs/entity.py
Normal 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)
|
@ -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"]
|
||||
}
|
||||
|
@ -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})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
)
|
||||
|
10
mypy.ini
10
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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
13
tests/components/ecovacs/const.py
Normal file
13
tests/components/ecovacs/const.py
Normal 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"}
|
@ -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()
|
||||
|
85
tests/components/ecovacs/test_init.py
Normal file
85
tests/components/ecovacs/test_init.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user