From 1bd95d359640f37745433d9acd7148ef64abaa6c Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 22 Jun 2024 18:40:13 +0200 Subject: [PATCH] Add service for Husqvarna Automower (#117269) --- .../components/husqvarna_automower/icons.json | 3 + .../husqvarna_automower/lawn_mower.py | 88 ++++++++++++----- .../husqvarna_automower/services.yaml | 21 ++++ .../husqvarna_automower/strings.json | 24 +++++ .../husqvarna_automower/test_lawn_mower.py | 98 ++++++++++++++++++- 5 files changed, 208 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower/services.yaml diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index 2ecbf9c198a..a9002c5b44a 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -32,5 +32,8 @@ "default": "mdi:tooltip-question" } } + }, + "services": { + "override_schedule": "mdi:debug-step-over" } } diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index 50333076308..c0b566a7f66 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -1,9 +1,14 @@ """Husqvarna Automower lawn mower entity.""" +from collections.abc import Awaitable, Callable, Coroutine +from datetime import timedelta +import functools import logging +from typing import Any from aioautomower.exceptions import ApiException from aioautomower.model import MowerActivities, MowerStates +import voluptuous as vol from homeassistant.components.lawn_mower import ( LawnMowerActivity, @@ -12,18 +17,14 @@ from homeassistant.components.lawn_mower import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AutomowerConfigEntry +from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerControlEntity -SUPPORT_STATE_SERVICES = ( - LawnMowerEntityFeature.DOCK - | LawnMowerEntityFeature.PAUSE - | LawnMowerEntityFeature.START_MOWING -) - DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) MOWING_ACTIVITIES = ( MowerActivities.MOWING, @@ -35,11 +36,38 @@ PAUSED_STATES = [ MowerStates.WAIT_UPDATING, MowerStates.WAIT_POWER_UP, ] +SUPPORT_STATE_SERVICES = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING +) +MOW = "mow" +PARK = "park" +OVERRIDE_MODES = [MOW, PARK] _LOGGER = logging.getLogger(__name__) +def handle_sending_exception( + func: Callable[..., Awaitable[Any]], +) -> Callable[..., Coroutine[Any, Any, None]]: + """Handle exceptions while sending a command.""" + + @functools.wraps(func) + async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + try: + return await func(self, *args, **kwargs) + except ApiException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_send_failed", + translation_placeholders={"exception": str(exception)}, + ) from exception + + return wrapper + + async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, @@ -51,6 +79,20 @@ async def async_setup_entry( AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + "override_schedule", + { + vol.Required("override_mode"): vol.In(OVERRIDE_MODES), + vol.Required("duration"): vol.All( + cv.time_period, + cv.positive_timedelta, + vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)), + ), + }, + "async_override_schedule", + ) + class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity): """Defining each mower Entity.""" @@ -81,29 +123,27 @@ class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity): return LawnMowerActivity.DOCKED return LawnMowerActivity.ERROR + @handle_sending_exception async def async_start_mowing(self) -> None: """Resume schedule.""" - try: - await self.coordinator.api.commands.resume_schedule(self.mower_id) - except ApiException as exception: - raise HomeAssistantError( - f"Command couldn't be sent to the command queue: {exception}" - ) from exception + await self.coordinator.api.commands.resume_schedule(self.mower_id) + @handle_sending_exception async def async_pause(self) -> None: """Pauses the mower.""" - try: - await self.coordinator.api.commands.pause_mowing(self.mower_id) - except ApiException as exception: - raise HomeAssistantError( - f"Command couldn't be sent to the command queue: {exception}" - ) from exception + await self.coordinator.api.commands.pause_mowing(self.mower_id) + @handle_sending_exception async def async_dock(self) -> None: """Parks the mower until next schedule.""" - try: - await self.coordinator.api.commands.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 + await self.coordinator.api.commands.park_until_next_schedule(self.mower_id) + + @handle_sending_exception + async def async_override_schedule( + self, override_mode: str, duration: timedelta + ) -> None: + """Override the schedule with mowing or parking.""" + if override_mode == MOW: + await self.coordinator.api.commands.start_for(self.mower_id, duration) + if override_mode == PARK: + await self.coordinator.api.commands.park_for(self.mower_id, duration) diff --git a/homeassistant/components/husqvarna_automower/services.yaml b/homeassistant/components/husqvarna_automower/services.yaml new file mode 100644 index 00000000000..94687a2ebfa --- /dev/null +++ b/homeassistant/components/husqvarna_automower/services.yaml @@ -0,0 +1,21 @@ +override_schedule: + target: + entity: + integration: "husqvarna_automower" + domain: "lawn_mower" + fields: + duration: + required: true + example: "{'days': 1, 'hours': 12, 'minutes': 30}" + selector: + duration: + enable_day: true + override_mode: + required: true + example: "mow" + selector: + select: + translation_key: override_modes + options: + - "mow" + - "park" diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index a403a56cc5e..6cb1c17421a 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -269,5 +269,29 @@ "command_send_failed": { "message": "Failed to send command: {exception}" } + }, + "selector": { + "override_modes": { + "options": { + "mow": "Mow", + "park": "Park" + } + } + }, + "services": { + "override_schedule": { + "name": "Override schedule", + "description": "Override the schedule to either mow or park for a duration of time.", + "fields": { + "duration": { + "name": "Duration", + "description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored." + }, + "override_mode": { + "name": "Override mode", + "description": "With which action the schedule should be overridden." + } + } + } } } diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index ff5a67971be..5d5cacfc6bf 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -1,11 +1,13 @@ """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 voluptuous.error import MultipleInvalid from homeassistant.components.husqvarna_automower.const import DOMAIN from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL @@ -84,11 +86,103 @@ async def test_lawn_mower_commands( ).side_effect = ApiException("Test error") with pytest.raises( HomeAssistantError, - match="Command couldn't be sent to the command queue: Test error", + match="Failed to send command: Test error", ): await hass.services.async_call( domain="lawn_mower", service=service, - service_data={"entity_id": "lawn_mower.test_mower_1"}, + target={"entity_id": "lawn_mower.test_mower_1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("aioautomower_command", "extra_data", "service", "service_data"), + [ + ( + "start_for", + timedelta(hours=3), + "override_schedule", + { + "duration": {"days": 0, "hours": 3, "minutes": 0}, + "override_mode": "mow", + }, + ), + ( + "park_for", + timedelta(days=1, hours=12, minutes=30), + "override_schedule", + { + "duration": {"days": 1, "hours": 12, "minutes": 30}, + "override_mode": "park", + }, + ), + ], +) +async def test_lawn_mower_service_commands( + hass: HomeAssistant, + aioautomower_command: str, + extra_data: int | None, + service: str, + service_data: dict[str, int] | None, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lawn_mower commands.""" + await setup_integration(hass, mock_config_entry) + mocked_method = AsyncMock() + setattr(mock_automower_client.commands, aioautomower_command, mocked_method) + await hass.services.async_call( + domain=DOMAIN, + service=service, + target={"entity_id": "lawn_mower.test_mower_1"}, + service_data=service_data, + blocking=True, + ) + mocked_method.assert_called_once_with(TEST_MOWER_ID, extra_data) + + getattr( + mock_automower_client.commands, aioautomower_command + ).side_effect = ApiException("Test error") + with pytest.raises( + HomeAssistantError, + match="Failed to send command: Test error", + ): + await hass.services.async_call( + domain=DOMAIN, + service=service, + target={"entity_id": "lawn_mower.test_mower_1"}, + service_data=service_data, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service", "service_data"), + [ + ( + "override_schedule", + { + "duration": {"days": 1, "hours": 12, "minutes": 30}, + "override_mode": "fly_to_moon", + }, + ), + ], +) +async def test_lawn_mower_wrong_service_commands( + hass: HomeAssistant, + service: str, + service_data: dict[str, int] | None, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lawn_mower commands.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + domain=DOMAIN, + service=service, + target={"entity_id": "lawn_mower.test_mower_1"}, + service_data=service_data, blocking=True, )