mirror of
https://github.com/home-assistant/core.git
synced 2025-07-13 16:27:08 +00:00
Add override for work areas in Husqvarna Automower (#120427)
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
parent
a913587eb6
commit
cc1a6d60c0
@ -34,6 +34,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"override_schedule": "mdi:debug-step-over"
|
"override_schedule": "mdi:debug-step-over",
|
||||||
|
"override_schedule_work_area": "mdi:land-fields"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from aioautomower.model import MowerActivities, MowerStates
|
from aioautomower.model import MowerActivities, MowerStates, WorkArea
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.lawn_mower import (
|
from homeassistant.components.lawn_mower import (
|
||||||
@ -12,10 +13,12 @@ from homeassistant.components.lawn_mower import (
|
|||||||
LawnMowerEntityFeature,
|
LawnMowerEntityFeature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
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 AutomowerAvailableEntity, handle_sending_exception
|
from .entity import AutomowerAvailableEntity, handle_sending_exception
|
||||||
|
|
||||||
@ -67,6 +70,18 @@ async def async_setup_entry(
|
|||||||
},
|
},
|
||||||
"async_override_schedule",
|
"async_override_schedule",
|
||||||
)
|
)
|
||||||
|
platform.async_register_entity_service(
|
||||||
|
"override_schedule_work_area",
|
||||||
|
{
|
||||||
|
vol.Required("work_area_id"): vol.Coerce(int),
|
||||||
|
vol.Required("duration"): vol.All(
|
||||||
|
cv.time_period,
|
||||||
|
cv.positive_timedelta,
|
||||||
|
vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"async_override_schedule_work_area",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
|
class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
|
||||||
@ -98,6 +113,11 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
|
|||||||
return LawnMowerActivity.DOCKED
|
return LawnMowerActivity.DOCKED
|
||||||
return LawnMowerActivity.ERROR
|
return LawnMowerActivity.ERROR
|
||||||
|
|
||||||
|
@property
|
||||||
|
def work_areas(self) -> dict[int, WorkArea] | None:
|
||||||
|
"""Return the work areas of the mower."""
|
||||||
|
return self.mower_attributes.work_areas
|
||||||
|
|
||||||
@handle_sending_exception()
|
@handle_sending_exception()
|
||||||
async def async_start_mowing(self) -> None:
|
async def async_start_mowing(self) -> None:
|
||||||
"""Resume schedule."""
|
"""Resume schedule."""
|
||||||
@ -122,3 +142,22 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
|
|||||||
await self.coordinator.api.commands.start_for(self.mower_id, duration)
|
await self.coordinator.api.commands.start_for(self.mower_id, duration)
|
||||||
if override_mode == PARK:
|
if override_mode == PARK:
|
||||||
await self.coordinator.api.commands.park_for(self.mower_id, duration)
|
await self.coordinator.api.commands.park_for(self.mower_id, duration)
|
||||||
|
|
||||||
|
@handle_sending_exception()
|
||||||
|
async def async_override_schedule_work_area(
|
||||||
|
self, work_area_id: int, duration: timedelta
|
||||||
|
) -> None:
|
||||||
|
"""Override the schedule with a certain work area."""
|
||||||
|
if not self.mower_attributes.capabilities.work_areas:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN, translation_key="work_areas_not_supported"
|
||||||
|
)
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert self.work_areas is not None
|
||||||
|
if work_area_id not in self.work_areas:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN, translation_key="work_area_not_existing"
|
||||||
|
)
|
||||||
|
await self.coordinator.api.commands.start_in_workarea(
|
||||||
|
self.mower_id, work_area_id, duration
|
||||||
|
)
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
"""Creates the sensor entities for the mower."""
|
"""Creates the sensor entities for the mower."""
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons
|
from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons
|
||||||
@ -28,6 +28,8 @@ from .entity import AutomowerBaseEntity
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment"
|
||||||
|
|
||||||
ERROR_KEY_LIST = [
|
ERROR_KEY_LIST = [
|
||||||
"no_error",
|
"no_error",
|
||||||
"alarm_mower_in_motion",
|
"alarm_mower_in_motion",
|
||||||
@ -214,11 +216,23 @@ def _get_current_work_area_name(data: MowerAttributes) -> str:
|
|||||||
return data.work_areas[data.mower.work_area_id].name
|
return data.work_areas[data.mower.work_area_id].name
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _get_current_work_area_dict(data: MowerAttributes) -> Mapping[str, Any]:
|
||||||
|
"""Return the name of the current work area."""
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# Sensor does not get created if it is None
|
||||||
|
assert data.work_areas is not None
|
||||||
|
return {ATTR_WORK_AREA_ID_ASSIGNMENT: data.work_area_dict}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class AutomowerSensorEntityDescription(SensorEntityDescription):
|
class AutomowerSensorEntityDescription(SensorEntityDescription):
|
||||||
"""Describes Automower sensor entity."""
|
"""Describes Automower sensor entity."""
|
||||||
|
|
||||||
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
|
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
|
||||||
|
extra_state_attributes_fn: Callable[[MowerAttributes], Mapping[str, Any] | None] = (
|
||||||
|
lambda _: None
|
||||||
|
)
|
||||||
option_fn: Callable[[MowerAttributes], list[str] | None] = lambda _: None
|
option_fn: Callable[[MowerAttributes], list[str] | None] = lambda _: None
|
||||||
value_fn: Callable[[MowerAttributes], StateType | datetime]
|
value_fn: Callable[[MowerAttributes], StateType | datetime]
|
||||||
|
|
||||||
@ -353,6 +367,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
|||||||
translation_key="work_area",
|
translation_key="work_area",
|
||||||
device_class=SensorDeviceClass.ENUM,
|
device_class=SensorDeviceClass.ENUM,
|
||||||
exists_fn=lambda data: data.capabilities.work_areas,
|
exists_fn=lambda data: data.capabilities.work_areas,
|
||||||
|
extra_state_attributes_fn=_get_current_work_area_dict,
|
||||||
option_fn=_get_work_area_names,
|
option_fn=_get_work_area_names,
|
||||||
value_fn=_get_current_work_area_name,
|
value_fn=_get_current_work_area_name,
|
||||||
),
|
),
|
||||||
@ -378,6 +393,7 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity):
|
|||||||
"""Defining the Automower Sensors with AutomowerSensorEntityDescription."""
|
"""Defining the Automower Sensors with AutomowerSensorEntityDescription."""
|
||||||
|
|
||||||
entity_description: AutomowerSensorEntityDescription
|
entity_description: AutomowerSensorEntityDescription
|
||||||
|
_unrecorded_attributes = frozenset({ATTR_WORK_AREA_ID_ASSIGNMENT})
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -399,3 +415,8 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity):
|
|||||||
def options(self) -> list[str] | None:
|
def options(self) -> list[str] | None:
|
||||||
"""Return the option of the sensor."""
|
"""Return the option of the sensor."""
|
||||||
return self.entity_description.option_fn(self.mower_attributes)
|
return self.entity_description.option_fn(self.mower_attributes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||||
|
"""Return the state attributes."""
|
||||||
|
return self.entity_description.extra_state_attributes_fn(self.mower_attributes)
|
||||||
|
@ -19,3 +19,22 @@ override_schedule:
|
|||||||
options:
|
options:
|
||||||
- "mow"
|
- "mow"
|
||||||
- "park"
|
- "park"
|
||||||
|
|
||||||
|
override_schedule_work_area:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: "husqvarna_automower"
|
||||||
|
domain: "lawn_mower"
|
||||||
|
fields:
|
||||||
|
duration:
|
||||||
|
required: true
|
||||||
|
example: "{'days': 1, 'hours': 12, 'minutes': 30}"
|
||||||
|
selector:
|
||||||
|
duration:
|
||||||
|
enable_day: true
|
||||||
|
work_area_id:
|
||||||
|
required: true
|
||||||
|
example: "123"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
type: number
|
||||||
|
@ -254,6 +254,11 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"my_lawn": "My lawn",
|
"my_lawn": "My lawn",
|
||||||
"no_work_area_active": "No work area active"
|
"no_work_area_active": "No work area active"
|
||||||
|
},
|
||||||
|
"state_attributes": {
|
||||||
|
"work_area_id_assignment": {
|
||||||
|
"name": "Work area ID assignment"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -269,6 +274,12 @@
|
|||||||
"exceptions": {
|
"exceptions": {
|
||||||
"command_send_failed": {
|
"command_send_failed": {
|
||||||
"message": "Failed to send command: {exception}"
|
"message": "Failed to send command: {exception}"
|
||||||
|
},
|
||||||
|
"work_areas_not_supported": {
|
||||||
|
"message": "This mower does not support work areas."
|
||||||
|
},
|
||||||
|
"work_area_not_existing": {
|
||||||
|
"message": "The selected work area does not exist."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
@ -293,6 +304,20 @@
|
|||||||
"description": "With which action the schedule should be overridden."
|
"description": "With which action the schedule should be overridden."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"override_schedule_work_area": {
|
||||||
|
"name": "Override schedule work area",
|
||||||
|
"description": "Override the schedule of the mower for a duration of time in the selected work area.",
|
||||||
|
"fields": {
|
||||||
|
"duration": {
|
||||||
|
"name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]",
|
||||||
|
"description": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::description%]"
|
||||||
|
},
|
||||||
|
"work_area_id": {
|
||||||
|
"name": "Work area ID",
|
||||||
|
"description": "In which work area the mower should mow."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1100,6 +1100,11 @@
|
|||||||
'my_lawn',
|
'my_lawn',
|
||||||
'no_work_area_active',
|
'no_work_area_active',
|
||||||
]),
|
]),
|
||||||
|
'work_area_id_assignment': dict({
|
||||||
|
0: 'my_lawn',
|
||||||
|
123456: 'Front lawn',
|
||||||
|
654321: 'Back lawn',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
'context': <ANY>,
|
'context': <ANY>,
|
||||||
'entity_id': 'sensor.test_mower_1_work_area',
|
'entity_id': 'sensor.test_mower_1_work_area',
|
||||||
|
@ -13,7 +13,7 @@ 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
|
||||||
from homeassistant.components.lawn_mower import LawnMowerActivity
|
from homeassistant.components.lawn_mower import LawnMowerActivity
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
|
|
||||||
from . import setup_integration
|
from . import setup_integration
|
||||||
from .const import TEST_MOWER_ID
|
from .const import TEST_MOWER_ID
|
||||||
@ -122,7 +122,7 @@ async def test_lawn_mower_commands(
|
|||||||
async def test_lawn_mower_service_commands(
|
async def test_lawn_mower_service_commands(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
aioautomower_command: str,
|
aioautomower_command: str,
|
||||||
extra_data: int | None,
|
extra_data: timedelta,
|
||||||
service: str,
|
service: str,
|
||||||
service_data: dict[str, int] | None,
|
service_data: dict[str, int] | None,
|
||||||
mock_automower_client: AsyncMock,
|
mock_automower_client: AsyncMock,
|
||||||
@ -158,27 +158,112 @@ async def test_lawn_mower_service_commands(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("service", "service_data"),
|
("aioautomower_command", "extra_data1", "extra_data2", "service", "service_data"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"override_schedule",
|
"start_in_workarea",
|
||||||
|
123456,
|
||||||
|
timedelta(days=40),
|
||||||
|
"override_schedule_work_area",
|
||||||
{
|
{
|
||||||
"duration": {"days": 1, "hours": 12, "minutes": 30},
|
"work_area_id": 123456,
|
||||||
"override_mode": "fly_to_moon",
|
"duration": {"days": 40},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_lawn_mower_wrong_service_commands(
|
async def test_lawn_mower_override_work_area_command(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
aioautomower_command: str,
|
||||||
|
extra_data1: int,
|
||||||
|
extra_data2: timedelta,
|
||||||
service: str,
|
service: str,
|
||||||
service_data: dict[str, int] | None,
|
service_data: dict[str, int] | None,
|
||||||
mock_automower_client: AsyncMock,
|
mock_automower_client: AsyncMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test lawn_mower commands."""
|
"""Test lawn_mower work area override commands."""
|
||||||
await setup_integration(hass, mock_config_entry)
|
await setup_integration(hass, mock_config_entry)
|
||||||
with pytest.raises(MultipleInvalid):
|
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_data1, extra_data2)
|
||||||
|
|
||||||
|
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", "mower_support_wa", "exception"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"override_schedule",
|
||||||
|
{
|
||||||
|
"duration": {"days": 1, "hours": 12, "minutes": 30},
|
||||||
|
"override_mode": "fly_to_moon",
|
||||||
|
},
|
||||||
|
False,
|
||||||
|
MultipleInvalid,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"override_schedule_work_area",
|
||||||
|
{
|
||||||
|
"work_area_id": 123456,
|
||||||
|
"duration": {"days": 40},
|
||||||
|
},
|
||||||
|
False,
|
||||||
|
ServiceValidationError,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"override_schedule_work_area",
|
||||||
|
{
|
||||||
|
"work_area_id": 12345,
|
||||||
|
"duration": {"days": 40},
|
||||||
|
},
|
||||||
|
True,
|
||||||
|
ServiceValidationError,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_lawn_mower_wrong_service_commands(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
service: str,
|
||||||
|
service_data: dict[str, int] | None,
|
||||||
|
mower_support_wa: bool,
|
||||||
|
exception,
|
||||||
|
mock_automower_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test lawn_mower commands."""
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
values = mower_list_to_dictionary_dataclass(
|
||||||
|
load_json_value_fixture("mower.json", DOMAIN)
|
||||||
|
)
|
||||||
|
values[TEST_MOWER_ID].capabilities.work_areas = mower_support_wa
|
||||||
|
mock_automower_client.get_status.return_value = values
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
with pytest.raises(exception):
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
service=service,
|
service=service,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user