diff --git a/.coveragerc b/.coveragerc index 9c02fb7f677..38b74601214 100644 --- a/.coveragerc +++ b/.coveragerc @@ -272,7 +272,9 @@ omit = homeassistant/components/econet/climate.py homeassistant/components/econet/sensor.py homeassistant/components/econet/water_heater.py - homeassistant/components/ecovacs/* + homeassistant/components/ecovacs/__init__.py + homeassistant/components/ecovacs/util.py + homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py homeassistant/components/ecowitt/binary_sensor.py homeassistant/components/ecowitt/entity.py diff --git a/CODEOWNERS b/CODEOWNERS index d1aa09eb93c..ffad270b09f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -321,7 +321,8 @@ build.json @home-assistant/supervisor /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 /tests/components/econet/ @w1ll1am23 -/homeassistant/components/ecovacs/ @OverloadUT @mib1185 +/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus +/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli /homeassistant/components/efergy/ @tkdrob diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 9cb8a8c38d8..f8d6fc912e9 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -1,28 +1,26 @@ """Support for Ecovacs Deebot vacuums.""" import logging -import random -import string 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.core import HomeAssistant -from homeassistant.helpers import discovery 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__) -DOMAIN = "ecovacs" - -CONF_COUNTRY = "country" -CONF_CONTINENT = "continent" CONFIG_SCHEMA = vol.Schema( { @@ -38,32 +36,39 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -ECOVACS_DEVICES = "ecovacs_devices" - -# Generate a random device ID on each bootup -ECOVACS_API_DEVICEID = "".join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(8) -) +PLATFORMS = [ + Platform.VACUUM, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Ecovacs component.""" - _LOGGER.debug("Creating new Ecovacs component") + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" def get_devices() -> list[VacBot]: ecovacs_api = EcoVacsAPI( - ECOVACS_API_DEVICEID, - config[DOMAIN].get(CONF_USERNAME), - EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)), - config[DOMAIN].get(CONF_COUNTRY), - config[DOMAIN].get(CONF_CONTINENT), + 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) + _LOGGER.debug("Ecobot devices: %s", ecovacs_devices) devices: list[VacBot] = [] for device in ecovacs_devices: - _LOGGER.info( + _LOGGER.debug( "Discovered Ecovacs device on account: %s with nickname %s", device.get("did"), device.get("nick"), @@ -74,18 +79,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ecovacs_api.resource, ecovacs_api.user_access_token, device, - config[DOMAIN].get(CONF_CONTINENT).lower(), + entry.data[CONF_CONTINENT], monitor=True, ) devices.append(vacbot) return devices - hass.data[ECOVACS_DEVICES] = await hass.async_add_executor_job(get_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[ECOVACS_DEVICES] + devices: list[VacBot] = hass.data[DOMAIN][entry.entry_id] for device in devices: _LOGGER.info( "Shutting down connection to Ecovacs device %s", @@ -96,10 +103,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Listen for HA stop to disconnect. hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) - if hass.data[ECOVACS_DEVICES]: - _LOGGER.debug("Starting vacuum components") - hass.async_create_task( - discovery.async_load_platform(hass, Platform.VACUUM, DOMAIN, {}, config) - ) + if hass.data[DOMAIN][entry.entry_id]: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py new file mode 100644 index 00000000000..05232dddb53 --- /dev/null +++ b/homeassistant/components/ecovacs/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for Ecovacs mqtt integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from sucks import EcoVacsAPI +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.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import selector +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_CONTINENT, DOMAIN +from .util import get_client_device_id + +_LOGGER = logging.getLogger(__name__) + + +def validate_input(user_input: dict[str, Any]) -> dict[str, str]: + """Validate user input.""" + errors: dict[str, str] = {} + 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: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors + + +class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecovacs.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + 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) + + if not errors: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERNAME): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT + ) + ), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD + ) + ), + vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string), + vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string), + } + ), + user_input, + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import configuration from yaml.""" + + def create_repair(error: str | None = None) -> None: + if error: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{error}", + translation_placeholders={ + "url": "/config/integrations/dashboard/add?domain=ecovacs" + }, + ) + else: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ecovacs", + }, + ) + + try: + result = await self.async_step_user(user_input) + except AbortFlow as ex: + if ex.reason == "already_configured": + create_repair() + raise ex + + if errors := result.get("errors"): + error = errors["base"] + create_repair(error) + return self.async_abort(reason=error) + + create_repair() + return result diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py new file mode 100644 index 00000000000..ed33f90f191 --- /dev/null +++ b/homeassistant/components/ecovacs/const.py @@ -0,0 +1,5 @@ +"""Ecovacs constants.""" + +DOMAIN = "ecovacs" + +CONF_CONTINENT = "continent" diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index eb8afcf0878..286a7ce5583 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -1,7 +1,8 @@ { "domain": "ecovacs", "name": "Ecovacs", - "codeowners": ["@OverloadUT", "@mib1185"], + "codeowners": ["@OverloadUT", "@mib1185", "@edenhaus"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks"], diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json new file mode 100644 index 00000000000..86bdef89b3b --- /dev/null +++ b/homeassistant/components/ecovacs/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "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)" + } + } + } + }, + "issues": { + "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." + }, + "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." + } + } +} diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py new file mode 100644 index 00000000000..d16214346ab --- /dev/null +++ b/homeassistant/components/ecovacs/util.py @@ -0,0 +1,11 @@ +"""Ecovacs util functions.""" + +import random +import string + + +def get_client_device_id() -> str: + """Get client device id.""" + return "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(8) + ) diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 2ec9a1a3e4a..3b4f86920b6 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -15,12 +15,12 @@ from homeassistant.components.vacuum import ( StateVacuumEntity, VacuumEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ECOVACS_DEVICES +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,15 +28,14 @@ ATTR_ERROR = "error" ATTR_COMPONENT_PREFIX = "component_" -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Ecovacs vacuums.""" vacuums = [] - devices: list[sucks.VacBot] = hass.data[ECOVACS_DEVICES] + devices: list[sucks.VacBot] = hass.data[DOMAIN][config_entry.entry_id] for device in devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) vacuums.append(EcovacsVacuum(device)) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 752092c02c7..d14872aa29d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -126,6 +126,7 @@ FLOWS = { "ecobee", "ecoforest", "econet", + "ecovacs", "ecowitt", "edl21", "efergy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 373853681d7..07872e987ae 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1381,7 +1381,7 @@ "ecovacs": { "name": "Ecovacs", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_push" }, "ecowitt": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64c7c1fb47c..f9c2467629d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1230,6 +1230,9 @@ py-nextbusnext==1.0.2 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.ecovacs +py-sucks==0.9.8 + # homeassistant.components.synology_dsm py-synologydsm-api==2.1.4 diff --git a/tests/components/ecovacs/__init__.py b/tests/components/ecovacs/__init__.py new file mode 100644 index 00000000000..7305ba8c785 --- /dev/null +++ b/tests/components/ecovacs/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecovacs integration.""" diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py new file mode 100644 index 00000000000..5c1cf7adae0 --- /dev/null +++ b/tests/components/ecovacs/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Ecovacs tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ecovacs.async_setup_entry", return_value=True + ) as async_setup_entry: + yield async_setup_entry diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py new file mode 100644 index 00000000000..9688634bec4 --- /dev/null +++ b/tests/components/ecovacs/test_config_flow.py @@ -0,0 +1,160 @@ +"""Test Ecovacs config flow.""" +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from sucks import EcoVacsAPI + +from homeassistant.components.ecovacs.const import CONF_CONTINENT, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, 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 + +_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_COUNTRY: "it", + CONF_CONTINENT: "eu", +} + + +async def _test_user_flow(hass: HomeAssistant) -> dict[str, Any]: + """Test config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_USER_INPUT, + ) + + +async def test_user_flow(hass: HomeAssistant, mock_setup_entry: 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() + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (ValueError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_user_flow_error( + hass: HomeAssistant, + side_effect: Exception, + reason: str, + mock_setup_entry: 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_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() + + +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry: 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() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == _USER_INPUT[CONF_USERNAME] + assert result["data"] == _USER_INPUT + assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues + mock_setup_entry.assert_called() + + +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.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=_USER_INPUT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (ValueError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_import_flow_error( + hass: HomeAssistant, + side_effect: Exception, + reason: str, + issue_registry: ir.IssueRegistry, +) -> 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 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()