Add service for Husqvarna Automower (#117269)
This commit is contained in:
parent
bd65afa207
commit
1bd95d3596
5 changed files with 208 additions and 26 deletions
|
@ -32,5 +32,8 @@
|
||||||
"default": "mdi:tooltip-question"
|
"default": "mdi:tooltip-question"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"override_schedule": "mdi:debug-step-over"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
21
homeassistant/components/husqvarna_automower/services.yaml
Normal file
21
homeassistant/components/husqvarna_automower/services.yaml
Normal 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"
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue