From 6d4ab6c758f268cd12edbe4652c4193a12f78784 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 7 Feb 2024 09:27:04 +0100 Subject: [PATCH] Add Husqvarna Automower integration (#109073) * Add Husqvarna Automower * Update homeassistant/components/husqvarna_automower/__init__.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/husqvarna_automower/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/husqvarna_automower/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/husqvarna_automower/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/husqvarna_automower/lawn_mower.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/husqvarna_automower/lawn_mower.py Co-authored-by: Joost Lekkerkerker * address review * add test_config_non_unique_profile * add missing const * WIP tests * tests * tests * Update homeassistant/components/husqvarna_automower/api.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/husqvarna_automower/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/husqvarna_automower/config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/husqvarna_automower/conftest.py Co-authored-by: Joost Lekkerkerker * . * loop through test * Update homeassistant/components/husqvarna_automower/entity.py * Update homeassistant/components/husqvarna_automower/coordinator.py * Update homeassistant/components/husqvarna_automower/coordinator.py * Apply suggestions from code review * ruff --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../husqvarna_automower/__init__.py | 62 ++++++++ .../components/husqvarna_automower/api.py | 29 ++++ .../application_credentials.py | 14 ++ .../husqvarna_automower/config_flow.py | 43 ++++++ .../components/husqvarna_automower/const.py | 7 + .../husqvarna_automower/coordinator.py | 47 ++++++ .../components/husqvarna_automower/entity.py | 41 ++++++ .../husqvarna_automower/lawn_mower.py | 126 ++++++++++++++++ .../husqvarna_automower/manifest.json | 10 ++ .../husqvarna_automower/strings.json | 21 +++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../husqvarna_automower/__init__.py | 11 ++ .../husqvarna_automower/conftest.py | 85 +++++++++++ tests/components/husqvarna_automower/const.py | 4 + .../husqvarna_automower/fixtures/jwt | 1 + .../husqvarna_automower/fixtures/mower.json | 139 ++++++++++++++++++ .../husqvarna_automower/test_config_flow.py | 129 ++++++++++++++++ .../husqvarna_automower/test_init.py | 68 +++++++++ .../husqvarna_automower/test_lawn_mower.py | 88 +++++++++++ 24 files changed, 941 insertions(+) create mode 100644 homeassistant/components/husqvarna_automower/__init__.py create mode 100644 homeassistant/components/husqvarna_automower/api.py create mode 100644 homeassistant/components/husqvarna_automower/application_credentials.py create mode 100644 homeassistant/components/husqvarna_automower/config_flow.py create mode 100644 homeassistant/components/husqvarna_automower/const.py create mode 100644 homeassistant/components/husqvarna_automower/coordinator.py create mode 100644 homeassistant/components/husqvarna_automower/entity.py create mode 100644 homeassistant/components/husqvarna_automower/lawn_mower.py create mode 100644 homeassistant/components/husqvarna_automower/manifest.json create mode 100644 homeassistant/components/husqvarna_automower/strings.json create mode 100644 tests/components/husqvarna_automower/__init__.py create mode 100644 tests/components/husqvarna_automower/conftest.py create mode 100644 tests/components/husqvarna_automower/const.py create mode 100644 tests/components/husqvarna_automower/fixtures/jwt create mode 100644 tests/components/husqvarna_automower/fixtures/mower.json create mode 100644 tests/components/husqvarna_automower/test_config_flow.py create mode 100644 tests/components/husqvarna_automower/test_init.py create mode 100644 tests/components/husqvarna_automower/test_lawn_mower.py diff --git a/CODEOWNERS b/CODEOWNERS index 144883db68f..7e53ae3058b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -584,6 +584,8 @@ build.json @home-assistant/supervisor /tests/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/homeassistant/components/husqvarna_automower/ @Thomas55555 +/tests/components/husqvarna_automower/ @Thomas55555 /homeassistant/components/huum/ @frwickst /tests/components/huum/ @frwickst /homeassistant/components/hvv_departures/ @vigonotion diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py new file mode 100644 index 00000000000..057c1fcc617 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -0,0 +1,62 @@ +"""The Husqvarna Automower integration.""" + +import logging + +from aioautomower.session import AutomowerSession +from aiohttp import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from . import api +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [ + Platform.LAWN_MOWER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + api_api = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), + session, + ) + automower_api = AutomowerSession(api_api) + try: + await api_api.async_get_access_token() + except ClientError as err: + raise ConfigEntryNotReady from err + coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle unload of an entry.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + await coordinator.shutdown() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py new file mode 100644 index 00000000000..e5dc00ad7cb --- /dev/null +++ b/homeassistant/components/husqvarna_automower/api.py @@ -0,0 +1,29 @@ +"""API for Husqvarna Automower bound to Home Assistant OAuth.""" + +import logging + +from aioautomower.auth import AbstractAuth +from aioautomower.const import API_BASE_URL +from aiohttp import ClientSession + +from homeassistant.helpers import config_entry_oauth2_flow + +_LOGGER = logging.getLogger(__name__) + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Husqvarna Automower authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Husqvarna Automower auth.""" + super().__init__(websession, API_BASE_URL) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/husqvarna_automower/application_credentials.py b/homeassistant/components/husqvarna_automower/application_credentials.py new file mode 100644 index 00000000000..f201130ab22 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for Husqvarna Automower.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py new file mode 100644 index 00000000000..cafe942a894 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -0,0 +1,43 @@ +"""Config flow to add the integration via the UI.""" +import logging +from typing import Any + +from aioautomower.utils import async_structure_token + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, NAME + +_LOGGER = logging.getLogger(__name__) +CONF_USER_ID = "user_id" + + +class HusqvarnaConfigFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, + domain=DOMAIN, +): + """Handle a config flow.""" + + VERSION = 1 + DOMAIN = DOMAIN + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow.""" + token = data[CONF_TOKEN] + user_id = token[CONF_USER_ID] + structured_token = await async_structure_token(token[CONF_ACCESS_TOKEN]) + first_name = structured_token.user.first_name + last_name = structured_token.user.last_name + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{NAME} of {first_name} {last_name}", + data=data, + ) + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py new file mode 100644 index 00000000000..ab30bae45f2 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/const.py @@ -0,0 +1,7 @@ +"""The constants for the Husqvarna Automower integration.""" + +DOMAIN = "husqvarna_automower" +NAME = "Husqvarna Automower" +HUSQVARNA_URL = "https://developer.husqvarnagroup.cloud/login" +OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" +OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py new file mode 100644 index 00000000000..8409643ee7c --- /dev/null +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -0,0 +1,47 @@ +"""Data UpdateCoordinator for the Husqvarna Automower integration.""" +from datetime import timedelta +import logging +from typing import Any + +from aioautomower.model import MowerAttributes, MowerList + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): + """Class to manage fetching Husqvarna data.""" + + def __init__(self, hass: HomeAssistant, api: AsyncConfigEntryAuth) -> None: + """Initialize data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + self.api = api + + self.ws_connected: bool = False + + async def _async_update_data(self) -> dict[str, MowerAttributes]: + """Subscribe for websocket and poll data from the API.""" + if not self.ws_connected: + await self.api.connect() + self.api.register_data_callback(self.callback) + self.ws_connected = True + return await self.api.get_status() + + async def shutdown(self, *_: Any) -> None: + """Close resources.""" + await self.api.close() + + @callback + def callback(self, ws_data: MowerList) -> None: + """Process websocket callbacks and write them to the DataUpdateCoordinator.""" + self.async_set_updated_data(ws_data) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py new file mode 100644 index 00000000000..e91e3c89ab2 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -0,0 +1,41 @@ +"""Platform for Husqvarna Automower base entity.""" + +import logging + +from aioautomower.model import MowerAttributes + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AutomowerDataUpdateCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): + """Defining the Automower base Entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Initialize AutomowerEntity.""" + super().__init__(coordinator) + self.mower_id = mower_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mower_id)}, + name=self.mower_attributes.system.name, + manufacturer="Husqvarna", + model=self.mower_attributes.system.model, + suggested_area="Garden", + ) + + @property + def mower_attributes(self) -> MowerAttributes: + """Get the mower attributes of the current mower.""" + return self.coordinator.data[self.mower_id] diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py new file mode 100644 index 00000000000..e44f8b98c47 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -0,0 +1,126 @@ +"""Husqvarna Automower lawn mower entity.""" +import logging + +from aioautomower.exceptions import ApiException +from aioautomower.model import MowerActivities, MowerStates + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +SUPPORT_STATE_SERVICES = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING +) + +DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) +ERROR_ACTIVITIES = ( + MowerActivities.STOPPED_IN_GARDEN, + MowerActivities.UNKNOWN, + MowerActivities.NOT_APPLICABLE, +) +ERROR_STATES = [ + MowerStates.FATAL_ERROR, + MowerStates.ERROR, + MowerStates.ERROR_AT_POWER_UP, + MowerStates.NOT_APPLICABLE, + MowerStates.UNKNOWN, + MowerStates.STOPPED, + MowerStates.OFF, +] +MOWING_ACTIVITIES = ( + MowerActivities.MOWING, + MowerActivities.LEAVING, + MowerActivities.GOING_HOME, +) +PAUSED_STATES = [ + MowerStates.PAUSED, + MowerStates.WAIT_UPDATING, + MowerStates.WAIT_POWER_UP, +] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up lawn mower platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data + ) + + +class AutomowerLawnMowerEntity(LawnMowerEntity, AutomowerBaseEntity): + """Defining each mower Entity.""" + + _attr_name = None + _attr_supported_features = SUPPORT_STATE_SERVICES + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up HusqvarnaAutomowerEntity.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = mower_id + + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_attributes.metadata.connected + + @property + def activity(self) -> LawnMowerActivity: + """Return the state of the mower.""" + mower_attributes = self.mower_attributes + if mower_attributes.mower.state in PAUSED_STATES: + return LawnMowerActivity.PAUSED + if mower_attributes.mower.activity in MOWING_ACTIVITIES: + return LawnMowerActivity.MOWING + if (mower_attributes.mower.state == "RESTRICTED") or ( + mower_attributes.mower.activity in DOCKED_ACTIVITIES + ): + return LawnMowerActivity.DOCKED + return LawnMowerActivity.ERROR + + async def async_start_mowing(self) -> None: + """Resume schedule.""" + try: + await self.coordinator.api.resume_schedule(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + async def async_pause(self) -> None: + """Pauses the mower.""" + try: + await self.coordinator.api.pause_mowing(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + async def async_dock(self) -> None: + """Parks the mower until next schedule.""" + try: + await self.coordinator.api.park_until_next_schedule(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json new file mode 100644 index 00000000000..b5c40e7cf5a --- /dev/null +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "husqvarna_automower", + "name": "Husqvarna Automower", + "codeowners": ["@Thomas55555"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", + "iot_class": "cloud_push", + "requirements": ["aioautomower==2024.1.5"] +} diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json new file mode 100644 index 00000000000..569e148a5a3 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 586aa64ce18..851474d8481 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -13,6 +13,7 @@ APPLICATION_CREDENTIALS = [ "google_sheets", "google_tasks", "home_connect", + "husqvarna_automower", "lametric", "lyric", "myuplink", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa3efde99bc..0a4683d724a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -228,6 +228,7 @@ FLOWS = { "hue", "huisbaasje", "hunterdouglas_powerview", + "husqvarna_automower", "huum", "hvv_departures", "hydrawise", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ccf21e36a12..38f1dfe070b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2618,6 +2618,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "husqvarna_automower": { + "name": "Husqvarna Automower", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "huum": { "name": "Huum", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 4b6cb439a7e..109de7320b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -205,6 +205,9 @@ aioaseko==0.0.2 # homeassistant.components.asuswrt aioasuswrt==1.4.0 +# homeassistant.components.husqvarna_automower +aioautomower==2024.1.5 + # homeassistant.components.azure_devops aioazuredevops==1.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da3cd705b50..ed62d2c1b02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -184,6 +184,9 @@ aioaseko==0.0.2 # homeassistant.components.asuswrt aioasuswrt==1.4.0 +# homeassistant.components.husqvarna_automower +aioautomower==2024.1.5 + # homeassistant.components.azure_devops aioazuredevops==1.3.5 diff --git a/tests/components/husqvarna_automower/__init__.py b/tests/components/husqvarna_automower/__init__.py new file mode 100644 index 00000000000..069fa0d7372 --- /dev/null +++ b/tests/components/husqvarna_automower/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Husqvarna Automower integration.""" +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py new file mode 100644 index 00000000000..89c0133cd0b --- /dev/null +++ b/tests/components/husqvarna_automower/conftest.py @@ -0,0 +1,85 @@ +"""Test helpers for Husqvarna Automower.""" +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch + +from aioautomower.utils import mower_list_to_dictionary_dataclass +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CLIENT_ID, CLIENT_SECRET, USER_ID + +from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture + + +@pytest.fixture(name="jwt") +def load_jwt_fixture(): + """Load Fixture data.""" + return load_fixture("jwt", DOMAIN) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> float: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + version=1, + domain=DOMAIN, + title="Husqvarna Automower of Erika Mustermann", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": jwt, + "scope": "iam:read amc:api", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "provider": "husqvarna", + "user_id": USER_ID, + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + unique_id=USER_ID, + entry_id="automower_test", + ) + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + CLIENT_ID, + CLIENT_SECRET, + ), + DOMAIN, + ) + + +@pytest.fixture +def mock_automower_client() -> Generator[AsyncMock, None, None]: + """Mock a Husqvarna Automower client.""" + with patch( + "homeassistant.components.husqvarna_automower.AutomowerSession", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.get_status.return_value = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + yield client diff --git a/tests/components/husqvarna_automower/const.py b/tests/components/husqvarna_automower/const.py new file mode 100644 index 00000000000..7a00937291a --- /dev/null +++ b/tests/components/husqvarna_automower/const.py @@ -0,0 +1,4 @@ +"""Constants for Husqvarna Automower tests.""" +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +USER_ID = "123" diff --git a/tests/components/husqvarna_automower/fixtures/jwt b/tests/components/husqvarna_automower/fixtures/jwt new file mode 100644 index 00000000000..b30ec36082e --- /dev/null +++ b/tests/components/husqvarna_automower/fixtures/jwt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjVlZDU2ZDUzLTEyNWYtNDExZi04ZTFlLTNlNDRkMGVkOGJmOCJ9.eyJqdGkiOiI2MGYxNGQ1OS1iY2M4LTQwMzktYmMzOC0yNWRiMzc2MGQwNDciLCJpc3MiOiJodXNxdmFybmEiLCJyb2xlcyI6W10sImdyb3VwcyI6WyJhbWMiLCJkZXZlbG9wZXItcG9ydGFsIiwiZmQ3OGIzYTQtYTdmOS00Yzc2LWJlZjktYWE1YTUwNTgzMzgyIiwiZ2FyZGVuYS1teWFjY291bnQiLCJodXNxdmFybmEtY29ubmVjdCIsImh1c3F2YXJuYS1teXBhZ2VzIiwic21hcnRnYXJkZW4iXSwic2NvcGVzIjpbImlhbTpyZWFkIiwiYW1jOmFwaSJdLCJzY29wZSI6ImlhbTpyZWFkIGFtYzphcGkiLCJjbGllbnRfaWQiOiI0MzNlNWZkZi01MTI5LTQ1MmMteHh4eC1mYWRjZTMyMTMwNDIiLCJjdXN0b21lcl9pZCI6IjQ3NTU5OTc3MjA0NTh4eHh4IiwidXNlciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSIsImN1c3RvbV9hdHRyaWJ1dGVzIjp7ImhjX2NvdW50cnkiOiJERSJ9LCJjdXN0b21lcl9pZCI6IjQ3NTU5OTc3MjA0NTh4eHh4In0sImlhdCI6MTY5NzY2Njk0NywiZXhwIjoxNjk3NzUzMzQ3LCJzdWIiOiI1YTkzMTQxZS01NWE3LTQ3OWYtOTZlMi04YTYzMTg4YzA1NGYifQ.1O3FOoWHaWpo-PrW88097ai6nsUGlK2NWyqIDLkUl1BTatQoFhIA1nKmCthf6A9CAYeoPS4c8CBhqqLj-5VrJXfNc7pFZ1nAw69pT33Ku7_S9QqonPf_JRvWX8-A7sTCKXEkCTso6v_jbmiePK6C9_psClJx_PUgFFOoNaROZhSsAlq9Gftvzs9UTcd2UO9ohsku_Kpx480C0QCKRjm4LTrFTBpgijRPc3F0BnyfgW8rT3Trl290f3CyEzLk8k9bgGA0qDlAanKuNNKK1j7hwRsiq_28A7bWJzlLc6Wgrq8Pc2CnnMada_eXavkTu-VzB-q8_PGFkLyeG16CR-NXlox9mEB6NxTn5stYSMUkiTApAfgCwLuj4c_WCXnxUZn0VdnsswvaIZON3bTSOMATXLG8PFUyDOcDxHBV4LEDyTVspo-QblanTTBLFWMTfWIWApBmRO9OkiJrcq9g7T8hKNNImeN4skk2vIZVXkCq_cEOdVAG4099b1V8zXCBgtDc \ No newline at end of file diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json new file mode 100644 index 00000000000..eec43698bf0 --- /dev/null +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -0,0 +1,139 @@ +{ + "data": [ + { + "type": "mower", + "id": "c7233734-b219-4287-a173-08e3643f89f0", + "attributes": { + "system": { + "name": "Test Mower 1", + "model": "450XH-TEST", + "serialNumber": 123 + }, + "battery": { + "batteryPercent": 100 + }, + "capabilities": { + "headlights": true, + "workAreas": false, + "position": true, + "stayOutZones": false + }, + "mower": { + "mode": "MAIN_AREA", + "activity": "PARKED_IN_CS", + "state": "RESTRICTED", + "errorCode": 0, + "errorCodeTimestamp": 0 + }, + "calendar": { + "tasks": [ + { + "start": 1140, + "duration": 300, + "monday": true, + "tuesday": false, + "wednesday": true, + "thursday": false, + "friday": true, + "saturday": false, + "sunday": false + }, + { + "start": 0, + "duration": 480, + "monday": false, + "tuesday": true, + "wednesday": false, + "thursday": true, + "friday": false, + "saturday": true, + "sunday": false + } + ] + }, + "planner": { + "nextStartTimestamp": 1685991600000, + "override": { + "action": "NOT_ACTIVE" + }, + "restrictedReason": "WEEK_SCHEDULE" + }, + "metadata": { + "connected": true, + "statusTimestamp": 1697669932683 + }, + "positions": [ + { + "latitude": 35.5402913, + "longitude": -82.5527055 + }, + { + "latitude": 35.5407693, + "longitude": -82.5521503 + }, + { + "latitude": 35.5403241, + "longitude": -82.5522924 + }, + { + "latitude": 35.5406973, + "longitude": -82.5518579 + }, + { + "latitude": 35.5404659, + "longitude": -82.5516567 + }, + { + "latitude": 35.5406318, + "longitude": -82.5515709 + }, + { + "latitude": 35.5402477, + "longitude": -82.5519437 + }, + { + "latitude": 35.5403503, + "longitude": -82.5516889 + }, + { + "latitude": 35.5401429, + "longitude": -82.551536 + }, + { + "latitude": 35.5405489, + "longitude": -82.5512195 + }, + { + "latitude": 35.5404005, + "longitude": -82.5512115 + }, + { + "latitude": 35.5405969, + "longitude": -82.551418 + }, + { + "latitude": 35.5403437, + "longitude": -82.5523917 + }, + { + "latitude": 35.5403481, + "longitude": -82.5520054 + } + ], + "cuttingHeight": 4, + "headlight": { + "mode": "EVENING_ONLY" + }, + "statistics": { + "numberOfChargingCycles": 1380, + "numberOfCollisions": 11396, + "totalChargingTime": 4334400, + "totalCuttingTime": 4194000, + "totalDriveDistance": 1780272, + "totalRunningTime": 4564800, + "totalSearchingTime": 370800 + } + } + } + ] +} diff --git a/tests/components/husqvarna_automower/test_config_flow.py b/tests/components/husqvarna_automower/test_config_flow.py new file mode 100644 index 00000000000..fcf9fbffa0c --- /dev/null +++ b/tests/components/husqvarna_automower/test_config_flow.py @@ -0,0 +1,129 @@ +"""Test the Husqvarna Automower config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.husqvarna_automower.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .const import CLIENT_ID, USER_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock: AiohttpClientMocker, + current_request_with_host, + jwt, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "husqvarna_automower", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": jwt, + "scope": "iam:read amc:api", + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "provider": "husqvarna", + "user_id": "mock-user-id", + "token_type": "Bearer", + "expires_at": 1697753347, + }, + ) + + with patch( + "homeassistant.components.husqvarna_automower.async_setup_entry", + return_value=True, + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +async def test_config_non_unique_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_automower_client: AsyncMock, + jwt, +) -> None: + """Test setup a non-unique profile.""" + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": jwt, + "scope": "iam:read amc:api", + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "provider": "husqvarna", + "user_id": USER_ID, + "token_type": "Bearer", + "expires_at": 1697753347, + }, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py new file mode 100644 index 00000000000..14460ad5d21 --- /dev/null +++ b/tests/components/husqvarna_automower/test_init.py @@ -0,0 +1,68 @@ +"""Tests for init module.""" +import http +import time +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.husqvarna_automower.const import DOMAIN, OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_RETRY, # Will trigger reauth in the future + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py new file mode 100644 index 00000000000..38b8f2901ce --- /dev/null +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -0,0 +1,88 @@ +"""Tests for lawn_mower module.""" +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioautomower.exceptions import ApiException +from aioautomower.utils import mower_list_to_dictionary_dataclass +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.components.lawn_mower import LawnMowerActivity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_value_fixture, +) + +TEST_MOWER_ID = "c7233734-b219-4287-a173-08e3643f89f0" + + +async def test_lawn_mower_states( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test lawn_mower state.""" + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + state = hass.states.get("lawn_mower.test_mower_1") + assert state is not None + assert state.state == LawnMowerActivity.DOCKED + + for activity, state, expected_state in [ + ("UNKNOWN", "PAUSED", LawnMowerActivity.PAUSED), + ("MOWING", "NOT_APPLICABLE", LawnMowerActivity.MOWING), + ("NOT_APPLICABLE", "ERROR", LawnMowerActivity.ERROR), + ]: + values[TEST_MOWER_ID].mower.activity = activity + values[TEST_MOWER_ID].mower.state = state + mock_automower_client.get_status.return_value = values + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("lawn_mower.test_mower_1") + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("aioautomower_command", "service"), + [ + ("resume_schedule", "start_mowing"), + ("pause_mowing", "pause"), + ("park_until_next_schedule", "dock"), + ], +) +async def test_lawn_mower_commands( + hass: HomeAssistant, + aioautomower_command: str, + service: str, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lawn_mower commands.""" + await setup_integration(hass, mock_config_entry) + + getattr(mock_automower_client, aioautomower_command).side_effect = ApiException( + "Test error" + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + domain="lawn_mower", + service=service, + service_data={"entity_id": "lawn_mower.test_mower_1"}, + blocking=True, + ) + assert ( + str(exc_info.value) + == "Command couldn't be sent to the command queue: Test error" + )