diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 6f94ce993e4..bd2ffe6b012 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -248,6 +248,9 @@ "switch": { "enable_schedule": { "name": "Enable schedule" + }, + "stay_out_zones": { + "name": "Avoid {stay_out_zone}" } } } diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index 01d66a22a28..9e7dab80533 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -1,15 +1,23 @@ """Creates a switch entity for the mower.""" +import asyncio import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aioautomower.exceptions import ApiException -from aioautomower.model import MowerActivities, MowerStates, RestrictedReasons +from aioautomower.model import ( + MowerActivities, + MowerStates, + RestrictedReasons, + StayOutZones, + Zone, +) from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -32,6 +40,7 @@ ERROR_STATES = [ MowerStates.STOPPED, MowerStates.OFF, ] +EXECUTION_TIME = 5 async def async_setup_entry( @@ -39,13 +48,27 @@ async def async_setup_entry( ) -> 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 + entities: list[SwitchEntity] = [] + entities.extend( + AutomowerScheduleSwitchEntity(mower_id, coordinator) + for mower_id in coordinator.data ) + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.stay_out_zones: + _stay_out_zones = coordinator.data[mower_id].stay_out_zones + if _stay_out_zones is not None: + entities.extend( + AutomowerStayOutZoneSwitchEntity( + coordinator, mower_id, stay_out_zone_uid + ) + for stay_out_zone_uid in _stay_out_zones.zones + ) + async_remove_entities(hass, coordinator, entry, mower_id) + async_add_entities(entities) -class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): - """Defining the Automower switch.""" +class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity): + """Defining the Automower schedule switch.""" _attr_translation_key = "enable_schedule" @@ -92,3 +115,104 @@ class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): raise HomeAssistantError( f"Command couldn't be sent to the command queue: {exception}" ) from exception + + +class AutomowerStayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity): + """Defining the Automower stay out zone switch.""" + + _attr_translation_key = "stay_out_zones" + + def __init__( + self, + coordinator: AutomowerDataUpdateCoordinator, + mower_id: str, + stay_out_zone_uid: str, + ) -> None: + """Set up Automower switch.""" + super().__init__(mower_id, coordinator) + self.coordinator = coordinator + self.stay_out_zone_uid = stay_out_zone_uid + self._attr_unique_id = ( + f"{self.mower_id}_{stay_out_zone_uid}_{self._attr_translation_key}" + ) + self._attr_translation_placeholders = {"stay_out_zone": self.stay_out_zone.name} + + @property + def stay_out_zones(self) -> StayOutZones: + """Return all stay out zones.""" + if TYPE_CHECKING: + assert self.mower_attributes.stay_out_zones is not None + return self.mower_attributes.stay_out_zones + + @property + def stay_out_zone(self) -> Zone: + """Return the specific stay out zone.""" + return self.stay_out_zones.zones[self.stay_out_zone_uid] + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.stay_out_zone.enabled + + @property + def available(self) -> bool: + """Return True if the device is available and the zones are not `dirty`.""" + return super().available and not self.stay_out_zones.dirty + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.coordinator.api.commands.switch_stay_out_zone( + self.mower_id, self.stay_out_zone_uid, False + ) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + else: + # As there are no updates from the websocket regarding stay out zone changes, + # we need to wait until the command is executed and then poll the API. + await asyncio.sleep(EXECUTION_TIME) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.coordinator.api.commands.switch_stay_out_zone( + self.mower_id, self.stay_out_zone_uid, True + ) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + else: + # As there are no updates from the websocket regarding stay out zone changes, + # we need to wait until the command is executed and then poll the API. + await asyncio.sleep(EXECUTION_TIME) + await self.coordinator.async_request_refresh() + + +@callback +def async_remove_entities( + hass: HomeAssistant, + coordinator: AutomowerDataUpdateCoordinator, + config_entry: ConfigEntry, + mower_id: str, +) -> None: + """Remove deleted stay-out-zones from Home Assistant.""" + entity_reg = er.async_get(hass) + active_zones = set() + _zones = coordinator.data[mower_id].stay_out_zones + if _zones is not None: + for zones_uid in _zones.zones: + uid = f"{mower_id}_{zones_uid}_stay_out_zones" + active_zones.add(uid) + for entity_entry in er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ): + if ( + (split := entity_entry.unique_id.split("_"))[0] == mower_id + and split[-1] == "zones" + and entity_entry.unique_id not in active_zones + ): + entity_reg.async_remove(entity_entry.entity_id) diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 4df505dfc69..f2be7bfdcb9 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -154,6 +154,11 @@ "id": "81C6EEA2-D139-4FEA-B134-F22A6B3EA403", "name": "Springflowers", "enabled": true + }, + { + "id": "AAAAAAAA-BBBB-CCCC-DDDD-123456789101", + "name": "Danger Zone", + "enabled": false } ] }, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 7e84097baf5..7d2ac04791e 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -99,6 +99,10 @@ 'enabled': True, 'name': 'Springflowers', }), + 'AAAAAAAA-BBBB-CCCC-DDDD-123456789101': dict({ + 'enabled': False, + 'name': 'Danger Zone', + }), }), }), 'system': dict({ diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr index c54997fcf06..214273ababe 100644 --- a/tests/components/husqvarna_automower/snapshots/test_switch.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -1,4 +1,96 @@ # serializer version: 1 +# name: test_switch[switch.test_mower_1_avoid_danger_zone-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_avoid_danger_zone', + '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': 'Avoid Danger Zone', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stay_out_zones', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_AAAAAAAA-BBBB-CCCC-DDDD-123456789101_stay_out_zones', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_mower_1_avoid_danger_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Avoid Danger Zone', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_avoid_danger_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.test_mower_1_avoid_springflowers-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_avoid_springflowers', + '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': 'Avoid Springflowers', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stay_out_zones', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_81C6EEA2-D139-4FEA-B134-F22A6B3EA403_stay_out_zones', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_mower_1_avoid_springflowers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Avoid Springflowers', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_avoid_springflowers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch[switch.test_mower_1_enable_schedule-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 1356b802857..f8875ae2716 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -26,6 +26,8 @@ from tests.common import ( snapshot_platform, ) +TEST_ZONE_ID = "AAAAAAAA-BBBB-CCCC-DDDD-123456789101" + async def test_switch_states( hass: HomeAssistant, @@ -94,6 +96,82 @@ async def test_switch_commands( assert len(mocked_method.mock_calls) == 2 +@pytest.mark.parametrize( + ("service", "boolean", "excepted_state"), + [ + ("turn_off", False, "off"), + ("turn_on", True, "on"), + ("toggle", True, "on"), + ], +) +async def test_stay_out_zone_switch_commands( + hass: HomeAssistant, + service: str, + boolean: bool, + excepted_state: str, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch commands.""" + entity_id = "switch.test_mower_1_avoid_danger_zone" + await setup_integration(hass, mock_config_entry) + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + values[TEST_MOWER_ID].stay_out_zones.zones[TEST_ZONE_ID].enabled = boolean + mock_automower_client.get_status.return_value = values + mocked_method = AsyncMock() + setattr(mock_automower_client.commands, "switch_stay_out_zone", mocked_method) + await hass.services.async_call( + domain="switch", + service=service, + service_data={"entity_id": entity_id}, + blocking=True, + ) + mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, boolean) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == excepted_state + + mocked_method.side_effect = ApiException("Test error") + with pytest.raises( + HomeAssistantError, + match="Command couldn't be sent to the command queue: Test error", + ): + await hass.services.async_call( + domain="switch", + service=service, + service_data={"entity_id": entity_id}, + blocking=True, + ) + assert len(mocked_method.mock_calls) == 2 + + +async def test_zones_deleted( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test if stay-out-zone is deleted after removed.""" + + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + current_entries = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + del values[TEST_MOWER_ID].stay_out_zones.zones[TEST_ZONE_ID] + mock_automower_client.get_status.return_value = values + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) == (current_entries - 1) + + async def test_switch( hass: HomeAssistant, entity_registry: er.EntityRegistry,