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:
Thomas55555 2024-02-07 09:27:04 +01:00 committed by GitHub
parent 6f3be3e505
commit 6d4ab6c758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 941 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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%]"
}
}
}

View File

@ -13,6 +13,7 @@ APPLICATION_CREDENTIALS = [
"google_sheets",
"google_tasks",
"home_connect",
"husqvarna_automower",
"lametric",
"lyric",
"myuplink",

View File

@ -228,6 +228,7 @@ FLOWS = {
"hue",
"huisbaasje",
"hunterdouglas_powerview",
"husqvarna_automower",
"huum",
"hvv_departures",
"hydrawise",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
"""Constants for Husqvarna Automower tests."""
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
USER_ID = "123"

View File

@ -0,0 +1 @@
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjVlZDU2ZDUzLTEyNWYtNDExZi04ZTFlLTNlNDRkMGVkOGJmOCJ9.eyJqdGkiOiI2MGYxNGQ1OS1iY2M4LTQwMzktYmMzOC0yNWRiMzc2MGQwNDciLCJpc3MiOiJodXNxdmFybmEiLCJyb2xlcyI6W10sImdyb3VwcyI6WyJhbWMiLCJkZXZlbG9wZXItcG9ydGFsIiwiZmQ3OGIzYTQtYTdmOS00Yzc2LWJlZjktYWE1YTUwNTgzMzgyIiwiZ2FyZGVuYS1teWFjY291bnQiLCJodXNxdmFybmEtY29ubmVjdCIsImh1c3F2YXJuYS1teXBhZ2VzIiwic21hcnRnYXJkZW4iXSwic2NvcGVzIjpbImlhbTpyZWFkIiwiYW1jOmFwaSJdLCJzY29wZSI6ImlhbTpyZWFkIGFtYzphcGkiLCJjbGllbnRfaWQiOiI0MzNlNWZkZi01MTI5LTQ1MmMteHh4eC1mYWRjZTMyMTMwNDIiLCJjdXN0b21lcl9pZCI6IjQ3NTU5OTc3MjA0NTh4eHh4IiwidXNlciI6eyJmaXJzdF9uYW1lIjoiSm9obiIsImxhc3RfbmFtZSI6IkRvZSIsImN1c3RvbV9hdHRyaWJ1dGVzIjp7ImhjX2NvdW50cnkiOiJERSJ9LCJjdXN0b21lcl9pZCI6IjQ3NTU5OTc3MjA0NTh4eHh4In0sImlhdCI6MTY5NzY2Njk0NywiZXhwIjoxNjk3NzUzMzQ3LCJzdWIiOiI1YTkzMTQxZS01NWE3LTQ3OWYtOTZlMi04YTYzMTg4YzA1NGYifQ.1O3FOoWHaWpo-PrW88097ai6nsUGlK2NWyqIDLkUl1BTatQoFhIA1nKmCthf6A9CAYeoPS4c8CBhqqLj-5VrJXfNc7pFZ1nAw69pT33Ku7_S9QqonPf_JRvWX8-A7sTCKXEkCTso6v_jbmiePK6C9_psClJx_PUgFFOoNaROZhSsAlq9Gftvzs9UTcd2UO9ohsku_Kpx480C0QCKRjm4LTrFTBpgijRPc3F0BnyfgW8rT3Trl290f3CyEzLk8k9bgGA0qDlAanKuNNKK1j7hwRsiq_28A7bWJzlLc6Wgrq8Pc2CnnMada_eXavkTu-VzB-q8_PGFkLyeG16CR-NXlox9mEB6NxTn5stYSMUkiTApAfgCwLuj4c_WCXnxUZn0VdnsswvaIZON3bTSOMATXLG8PFUyDOcDxHBV4LEDyTVspo-QblanTTBLFWMTfWIWApBmRO9OkiJrcq9g7T8hKNNImeN4skk2vIZVXkCq_cEOdVAG4099b1V8zXCBgtDc

View File

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

View File

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

View File

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

View File

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