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"
}
}
},
"services": {
"override_schedule": "mdi:debug-step-over"
}
}

View File

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

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": {
"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."""
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,
)