mirror of https://github.com/home-assistant/core
Add Husqvarna Automower integration (#109073)
* Add Husqvarna Automower * Update homeassistant/components/husqvarna_automower/__init__.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/husqvarna_automower/config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/husqvarna_automower/entity.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/husqvarna_automower/entity.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/husqvarna_automower/lawn_mower.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/husqvarna_automower/lawn_mower.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * 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 <joostlek@outlook.com> * Update homeassistant/components/husqvarna_automower/config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update homeassistant/components/husqvarna_automower/config_flow.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Update tests/components/husqvarna_automower/conftest.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * . * 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 <joostlek@outlook.com>
This commit is contained in:
parent
6f3be3e505
commit
6d4ab6c758
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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"]
|
|
@ -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,
|
||||
)
|
|
@ -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__)
|
|
@ -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"
|
|
@ -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)
|
|
@ -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]
|
|
@ -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
|
|
@ -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"]
|
||||
}
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ APPLICATION_CREDENTIALS = [
|
|||
"google_sheets",
|
||||
"google_tasks",
|
||||
"home_connect",
|
||||
"husqvarna_automower",
|
||||
"lametric",
|
||||
"lyric",
|
||||
"myuplink",
|
||||
|
|
|
@ -228,6 +228,7 @@ FLOWS = {
|
|||
"hue",
|
||||
"huisbaasje",
|
||||
"hunterdouglas_powerview",
|
||||
"husqvarna_automower",
|
||||
"huum",
|
||||
"hvv_departures",
|
||||
"hydrawise",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
"""Constants for Husqvarna Automower tests."""
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
USER_ID = "123"
|
|
@ -0,0 +1 @@
|
|||
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjVlZDU2ZDUzLTEyNWYtNDExZi04ZTFlLTNlNDRkMGVkOGJmOCJ9.eyJqdGkiOiI2MGYxNGQ1OS1iY2M4LTQwMzktYmMzOC0yNWRiMzc2MGQwNDciLCJpc3MiOiJodXNxdmFybmEiLCJyb2xlcyI6W10sImdyb3VwcyI6WyJhbWMiLCJkZXZlbG9wZXItcG9ydGFsIiwiZmQ3OGIzYTQtYTdmOS00Yzc2LWJlZjktYWE1YTUwNTgzMzgyIiwiZ2FyZGVuYS1teWFjY291bnQiLCJodXNxdmFybmEtY29ubmVjdCIsImh1c3F2YXJuYS1teXBhZ2VzIiwic21hcnRnYXJkZW4iXSwic2NvcGVzIjpbImlhbTpyZWFkIiwiYW1jOmFwaSJdLCJzY29wZSI6ImlhbTpyZWFkIGFtYzphcGkiLCJjbGllbnRfaWQiOiI0MzNlNWZkZi01MTI5LTQ1MmMteHh4eC1mYWRjZTMyMTMwNDIiLCJjdXN0b21lcl9pZCI6IjQ3NTU5OTc3MjA0NTh4eHh4IiwidXNlciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSIsImN1c3RvbV9hdHRyaWJ1dGVzIjp7ImhjX2NvdW50cnkiOiJERSJ9LCJjdXN0b21lcl9pZCI6IjQ3NTU5OTc3MjA0NTh4eHh4In0sImlhdCI6MTY5NzY2Njk0NywiZXhwIjoxNjk3NzUzMzQ3LCJzdWIiOiI1YTkzMTQxZS01NWE3LTQ3OWYtOTZlMi04YTYzMTg4YzA1NGYifQ.1O3FOoWHaWpo-PrW88097ai6nsUGlK2NWyqIDLkUl1BTatQoFhIA1nKmCthf6A9CAYeoPS4c8CBhqqLj-5VrJXfNc7pFZ1nAw69pT33Ku7_S9QqonPf_JRvWX8-A7sTCKXEkCTso6v_jbmiePK6C9_psClJx_PUgFFOoNaROZhSsAlq9Gftvzs9UTcd2UO9ohsku_Kpx480C0QCKRjm4LTrFTBpgijRPc3F0BnyfgW8rT3Trl290f3CyEzLk8k9bgGA0qDlAanKuNNKK1j7hwRsiq_28A7bWJzlLc6Wgrq8Pc2CnnMada_eXavkTu-VzB-q8_PGFkLyeG16CR-NXlox9mEB6NxTn5stYSMUkiTApAfgCwLuj4c_WCXnxUZn0VdnsswvaIZON3bTSOMATXLG8PFUyDOcDxHBV4LEDyTVspo-QblanTTBLFWMTfWIWApBmRO9OkiJrcq9g7T8hKNNImeN4skk2vIZVXkCq_cEOdVAG4099b1V8zXCBgtDc
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
|
@ -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
|
|
@ -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"
|
||||
)
|
Loading…
Reference in New Issue