Add sync clock button for Husqvarna Automower (#125689)

* Sync Clock

* optimize add entitites

* fix?

* test

* simplify command

* 1 generic entity

* docstrings

* tweaks

* tests

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* suggestions from review

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Thomas55555 2024-09-17 16:12:09 +02:00 committed by GitHub
parent 2190054abf
commit ca59805907
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 180 additions and 28 deletions

View File

@ -1,22 +1,68 @@
"""Creates a button entity for Husqvarna Automower integration."""
"""Creates button entities for the Husqvarna Automower integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import Any
from aioautomower.exceptions import ApiException
from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession
from homeassistant.components.button import ButtonEntity
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AutomowerConfigEntry
from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerAvailableEntity
from .entity import (
AutomowerAvailableEntity,
_check_error_free,
handle_sending_exception,
)
_LOGGER = logging.getLogger(__name__)
async def _async_set_time(
session: AutomowerSession,
mower_id: str,
) -> None:
"""Set datetime for the mower."""
# dt_util returns the current (aware) local datetime, set in the frontend.
# We assume it's the timezone in which the mower is.
await session.commands.set_datetime(
mower_id,
dt_util.now(),
)
@dataclass(frozen=True, kw_only=True)
class AutomowerButtonEntityDescription(ButtonEntityDescription):
"""Describes Automower button entities."""
available_fn: Callable[[MowerAttributes], bool] = lambda _: True
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
press_fn: Callable[[AutomowerSession, str], Awaitable[Any]]
BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
AutomowerButtonEntityDescription(
key="confirm_error",
translation_key="confirm_error",
available_fn=lambda data: data.mower.is_error_confirmable,
exists_fn=lambda data: data.capabilities.can_confirm_error,
press_fn=lambda session, mower_id: session.commands.error_confirm(mower_id),
),
AutomowerButtonEntityDescription(
key="sync_clock",
translation_key="sync_clock",
available_fn=_check_error_free,
press_fn=_async_set_time,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
@ -25,38 +71,35 @@ async def async_setup_entry(
"""Set up button platform."""
coordinator = entry.runtime_data
async_add_entities(
AutomowerButtonEntity(mower_id, coordinator)
AutomowerButtonEntity(mower_id, coordinator, description)
for mower_id in coordinator.data
if coordinator.data[mower_id].capabilities.can_confirm_error
for description in BUTTON_TYPES
if description.exists_fn(coordinator.data[mower_id])
)
class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
"""Defining the AutomowerButtonEntity."""
_attr_translation_key = "confirm_error"
entity_description: AutomowerButtonEntityDescription
def __init__(
self,
mower_id: str,
coordinator: AutomowerDataUpdateCoordinator,
description: AutomowerButtonEntityDescription,
) -> None:
"""Set up button platform."""
"""Set up AutomowerButtonEntity."""
super().__init__(mower_id, coordinator)
self._attr_unique_id = f"{mower_id}_confirm_error"
self.entity_description = description
self._attr_unique_id = f"{mower_id}_{description.key}"
@property
def available(self) -> bool:
"""Return True if the device and entity is available."""
return super().available and self.mower_attributes.mower.is_error_confirmable
"""Return the available attribute of the entity."""
return self.entity_description.available_fn(self.mower_attributes)
@handle_sending_exception()
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.coordinator.api.commands.error_confirm(self.mower_id)
except ApiException as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_send_failed",
translation_placeholders={"exception": str(exception)},
) from exception
"""Send a command to the mower."""
await self.entity_description.press_fn(self.coordinator.api, self.mower_id)

View File

@ -9,6 +9,7 @@ from typing import Any
from aioautomower.exceptions import ApiException
from aioautomower.model import MowerActivities, MowerAttributes, MowerStates
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -34,6 +35,15 @@ ERROR_STATES = [
]
@callback
def _check_error_free(mower_attributes: MowerAttributes) -> bool:
"""Check if the mower has any errors."""
return (
mower_attributes.mower.state not in ERROR_STATES
or mower_attributes.mower.activity not in ERROR_ACTIVITIES
)
def handle_sending_exception(
poll_after_sending: bool = False,
) -> Callable[
@ -109,7 +119,4 @@ class AutomowerControlEntity(AutomowerAvailableEntity):
@property
def available(self) -> bool:
"""Return True if the device is available."""
return super().available and (
self.mower_attributes.mower.state not in ERROR_STATES
or self.mower_attributes.mower.activity not in ERROR_ACTIVITIES
)
return super().available and _check_error_free(self.mower_attributes)

View File

@ -8,6 +8,11 @@
"default": "mdi:debug-step-into"
}
},
"button": {
"sync_clock": {
"default": "mdi:clock-check-outline"
}
},
"number": {
"cutting_height": {
"default": "mdi:grass"

View File

@ -45,6 +45,9 @@
"button": {
"confirm_error": {
"name": "Confirm error"
},
"sync_clock": {
"name": "Sync clock"
}
},
"number": {

View File

@ -45,3 +45,49 @@
'state': 'unavailable',
})
# ---
# name: test_button_snapshot[button.test_mower_1_sync_clock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_mower_1_sync_clock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Sync clock',
'platform': 'husqvarna_automower',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'sync_clock',
'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_sync_clock',
'unit_of_measurement': None,
})
# ---
# name: test_button_snapshot[button.test_mower_1_sync_clock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Mower 1 Sync clock',
}),
'context': <ANY>,
'entity_id': 'button.test_mower_1_sync_clock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -2,6 +2,7 @@
import datetime
from unittest.mock import AsyncMock, patch
import zoneinfo
from aioautomower.exceptions import ApiException
from aioautomower.utils import mower_list_to_dictionary_dataclass
@ -9,7 +10,7 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.button import SERVICE_PRESS
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.husqvarna_automower.const import DOMAIN
from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL
from homeassistant.const import (
@ -40,7 +41,7 @@ async def test_button_states_and_commands(
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test button commands."""
"""Test error confirm button command."""
entity_id = "button.test_mower_1_confirm_error"
await setup_integration(hass, mock_config_entry)
state = hass.states.get(entity_id)
@ -92,6 +93,53 @@ async def test_button_states_and_commands(
)
@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC))
async def test_sync_clock(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sync clock button command."""
entity_id = "button.test_mower_1_sync_clock"
await setup_integration(hass, mock_config_entry)
state = hass.states.get(entity_id)
assert state.name == "Test Mower 1 Sync clock"
values = mower_list_to_dictionary_dataclass(
load_json_value_fixture("mower.json", DOMAIN)
)
mock_automower_client.get_status.return_value = values
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_method = mock_automower_client.commands.set_datetime
# datetime(2024, 2, 29, 11, tzinfo=datetime.UTC) is in local time of the tests
# datetime(2024, 2, 29, 12, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin'))
mocked_method.assert_called_once_with(
TEST_MOWER_ID,
datetime.datetime(2024, 2, 29, 12, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")),
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == "2024-02-29T11:00:00+00:00"
mock_automower_client.commands.set_datetime.side_effect = ApiException("Test error")
with pytest.raises(
HomeAssistantError,
match="Failed to send command: Test error",
):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_button_snapshot(
hass: HomeAssistant,