Add service for Husqvarna Automower (#117269)

This commit is contained in:
Thomas55555 2024-06-22 18:40:13 +02:00 committed by GitHub
parent bd65afa207
commit 1bd95d3596
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 208 additions and 26 deletions

View file

@ -32,5 +32,8 @@
"default": "mdi:tooltip-question" "default": "mdi:tooltip-question"
} }
} }
},
"services": {
"override_schedule": "mdi:debug-step-over"
} }
} }

View file

@ -1,9 +1,14 @@
"""Husqvarna Automower lawn mower entity.""" """Husqvarna Automower lawn mower entity."""
from collections.abc import Awaitable, Callable, Coroutine
from datetime import timedelta
import functools
import logging import logging
from typing import Any
from aioautomower.exceptions import ApiException from aioautomower.exceptions import ApiException
from aioautomower.model import MowerActivities, MowerStates from aioautomower.model import MowerActivities, MowerStates
import voluptuous as vol
from homeassistant.components.lawn_mower import ( from homeassistant.components.lawn_mower import (
LawnMowerActivity, LawnMowerActivity,
@ -12,18 +17,14 @@ from homeassistant.components.lawn_mower import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AutomowerConfigEntry from . import AutomowerConfigEntry
from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerControlEntity from .entity import AutomowerControlEntity
SUPPORT_STATE_SERVICES = (
LawnMowerEntityFeature.DOCK
| LawnMowerEntityFeature.PAUSE
| LawnMowerEntityFeature.START_MOWING
)
DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING)
MOWING_ACTIVITIES = ( MOWING_ACTIVITIES = (
MowerActivities.MOWING, MowerActivities.MOWING,
@ -35,11 +36,38 @@ PAUSED_STATES = [
MowerStates.WAIT_UPDATING, MowerStates.WAIT_UPDATING,
MowerStates.WAIT_POWER_UP, 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__) _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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: AutomowerConfigEntry, entry: AutomowerConfigEntry,
@ -51,6 +79,20 @@ async def async_setup_entry(
AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data 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): class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity):
"""Defining each mower Entity.""" """Defining each mower Entity."""
@ -81,29 +123,27 @@ class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity):
return LawnMowerActivity.DOCKED return LawnMowerActivity.DOCKED
return LawnMowerActivity.ERROR return LawnMowerActivity.ERROR
@handle_sending_exception
async def async_start_mowing(self) -> None: async def async_start_mowing(self) -> None:
"""Resume schedule.""" """Resume schedule."""
try: await self.coordinator.api.commands.resume_schedule(self.mower_id)
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
@handle_sending_exception
async def async_pause(self) -> None: async def async_pause(self) -> None:
"""Pauses the mower.""" """Pauses the mower."""
try: await self.coordinator.api.commands.pause_mowing(self.mower_id)
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
@handle_sending_exception
async def async_dock(self) -> None: async def async_dock(self) -> None:
"""Parks the mower until next schedule.""" """Parks the mower until next schedule."""
try: await self.coordinator.api.commands.park_until_next_schedule(self.mower_id)
await self.coordinator.api.commands.park_until_next_schedule(self.mower_id)
except ApiException as exception: @handle_sending_exception
raise HomeAssistantError( async def async_override_schedule(
f"Command couldn't be sent to the command queue: {exception}" self, override_mode: str, duration: timedelta
) from exception ) -> 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)

View file

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

View file

@ -269,5 +269,29 @@
"command_send_failed": { "command_send_failed": {
"message": "Failed to send command: {exception}" "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."
}
}
}
} }
} }

View file

@ -1,11 +1,13 @@
"""Tests for lawn_mower module.""" """Tests for lawn_mower module."""
from datetime import timedelta
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from aioautomower.exceptions import ApiException from aioautomower.exceptions import ApiException
from aioautomower.utils import mower_list_to_dictionary_dataclass from aioautomower.utils import mower_list_to_dictionary_dataclass
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from voluptuous.error import MultipleInvalid
from homeassistant.components.husqvarna_automower.const import DOMAIN from homeassistant.components.husqvarna_automower.const import DOMAIN
from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL
@ -84,11 +86,103 @@ async def test_lawn_mower_commands(
).side_effect = ApiException("Test error") ).side_effect = ApiException("Test error")
with pytest.raises( with pytest.raises(
HomeAssistantError, 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( await hass.services.async_call(
domain="lawn_mower", domain="lawn_mower",
service=service, 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, blocking=True,
) )