Add config flow for Ecovacs (#108111)

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-16 13:31:42 +01:00 committed by GitHub
parent 3e72c346b7
commit 7fe6fc987b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 412 additions and 39 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
"""Ecovacs constants."""
DOMAIN = "ecovacs"
CONF_CONTINENT = "continent"

View file

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

View file

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

View file

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

View file

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

View file

@ -126,6 +126,7 @@ FLOWS = {
"ecobee",
"ecoforest",
"econet",
"ecovacs",
"ecowitt",
"edl21",
"efergy",

View file

@ -1381,7 +1381,7 @@
"ecovacs": {
"name": "Ecovacs",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_push"
},
"ecowitt": {

View file

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

View file

@ -0,0 +1 @@
"""Tests for the Ecovacs integration."""

View file

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

View file

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