From 85be94e0a91e577cb6bd89555aa178314c2c7252 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 22 Feb 2024 12:34:24 +0100 Subject: [PATCH] Add switch platform for husqvarna_automower (#110139) * Add switch platform for husqvarna_automower * Use RestrictedReasons const * Typing * Add snapshot testing * Invert switch * Test sucessfull servie calls * Assert client mock calls * Use getattr * Update snapshot * Add available property * Add a new base class for control entities * Make switch unavailabe if mower in error state * Sort platforms --------- Co-authored-by: Martin Hjelmare --- .../husqvarna_automower/__init__.py | 2 +- .../components/husqvarna_automower/entity.py | 9 ++ .../husqvarna_automower/lawn_mower.py | 23 +--- .../husqvarna_automower/strings.json | 5 + .../components/husqvarna_automower/switch.py | 93 ++++++++++++++ .../snapshots/test_switch.ambr | 46 +++++++ .../husqvarna_automower/test_lawn_mower.py | 3 +- .../husqvarna_automower/test_switch.py | 117 ++++++++++++++++++ 8 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower/switch.py create mode 100644 tests/components/husqvarna_automower/snapshots/test_switch.ambr create mode 100644 tests/components/husqvarna_automower/test_switch.py diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index d0e2dd9a3e2..7ed8a6b23e8 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -18,7 +18,7 @@ from .coordinator import AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.LAWN_MOWER, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.LAWN_MOWER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 25951aad1e3..2edce942f0c 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -38,3 +38,12 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): def mower_attributes(self) -> MowerAttributes: """Get the mower attributes of the current mower.""" return self.coordinator.data[self.mower_id] + + +class AutomowerControlEntity(AutomowerBaseEntity): + """AutomowerControlEntity, for dynamic availability.""" + + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_attributes.metadata.connected diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index b14f9e5d72c..abf27af02f0 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerBaseEntity +from .entity import AutomowerControlEntity SUPPORT_STATE_SERVICES = ( LawnMowerEntityFeature.DOCK @@ -25,20 +25,6 @@ SUPPORT_STATE_SERVICES = ( ) 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, @@ -64,7 +50,7 @@ async def async_setup_entry( ) -class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity): +class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity): """Defining each mower Entity.""" _attr_name = None @@ -79,11 +65,6 @@ class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity): 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.""" diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index a4785d678d8..d6017de2bd7 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -24,6 +24,11 @@ } }, "entity": { + "switch": { + "enable_schedule": { + "name": "Enable schedule" + } + }, "sensor": { "number_of_charging_cycles": { "name": "Number of charging cycles" diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py new file mode 100644 index 00000000000..9ba760a90e9 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -0,0 +1,93 @@ +"""Creates a switch entity for the mower.""" +import logging +from typing import Any + +from aioautomower.exceptions import ApiException +from aioautomower.model import MowerActivities, MowerStates, RestrictedReasons + +from homeassistant.components.switch import SwitchEntity +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 AutomowerControlEntity + +_LOGGER = logging.getLogger(__name__) + +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, +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up switch platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerSwitchEntity(mower_id, coordinator) for mower_id in coordinator.data + ) + + +class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): + """Defining the Automower switch.""" + + _attr_translation_key = "enable_schedule" + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up Automower switch.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = f"{self.mower_id}_{self._attr_translation_key}" + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + attributes = self.mower_attributes + return not ( + attributes.mower.state == MowerStates.RESTRICTED + and attributes.planner.restricted_reason == RestrictedReasons.NOT_APPLICABLE + ) + + @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 + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + try: + await self.coordinator.api.park_until_further_notice(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_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + 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 diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b1629a4cf99 --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_switch[switch.test_mower_1_enable_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_mower_1_enable_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Enable schedule', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_schedule', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_enable_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_mower_1_enable_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Enable schedule', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_enable_schedule', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index 38b8f2901ce..8c444913641 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from . import setup_integration +from .const import TEST_MOWER_ID from tests.common import ( MockConfigEntry, @@ -20,8 +21,6 @@ from tests.common import ( load_json_value_fixture, ) -TEST_MOWER_ID = "c7233734-b219-4287-a173-08e3643f89f0" - async def test_lawn_mower_states( hass: HomeAssistant, diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py new file mode 100644 index 00000000000..c4a73fec641 --- /dev/null +++ b/tests/components/husqvarna_automower/test_switch.py @@ -0,0 +1,117 @@ +"""Tests for switch platform.""" +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from aioautomower.exceptions import ApiException +from aioautomower.model import MowerStates, RestrictedReasons +from aioautomower.utils import mower_list_to_dictionary_dataclass +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .const import TEST_MOWER_ID + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_value_fixture, +) + + +async def test_switch_states( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test switch state.""" + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + for state, restricted_reson, expected_state in [ + (MowerStates.RESTRICTED, RestrictedReasons.NOT_APPLICABLE, "off"), + (MowerStates.IN_OPERATION, RestrictedReasons.NONE, "on"), + ]: + values[TEST_MOWER_ID].mower.state = state + values[TEST_MOWER_ID].planner.restricted_reason = restricted_reson + 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("switch.test_mower_1_enable_schedule") + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service", "aioautomower_command"), + [ + ("turn_off", "park_until_further_notice"), + ("turn_on", "resume_schedule"), + ("toggle", "park_until_further_notice"), + ], +) +async def test_switch_commands( + hass: HomeAssistant, + aioautomower_command: str, + service: str, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch commands.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + domain="switch", + service=service, + service_data={"entity_id": "switch.test_mower_1_enable_schedule"}, + blocking=True, + ) + mocked_method = getattr(mock_automower_client, aioautomower_command) + assert len(mocked_method.mock_calls) == 1 + + mocked_method.side_effect = ApiException("Test error") + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + domain="switch", + service=service, + service_data={"entity_id": "switch.test_mower_1_enable_schedule"}, + blocking=True, + ) + assert ( + str(exc_info.value) + == "Command couldn't be sent to the command queue: Test error" + ) + assert len(mocked_method.mock_calls) == 2 + + +async def test_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test states of the switch.""" + with patch( + "homeassistant.components.husqvarna_automower.PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")