Add calendar to Husqvarna Automower (#120775)

* Add Calendar

* update

* change timezone for tests

* fix requirements

* bump aioautomower to 2024.6.3b0

* bump aioautomower to 2024.6.4b0

* fix req

* align dates

* adjust

* nnbw

* better

* improvements

* req

* update requirements

* tests

* tweaks

* shift functions to library

* tests

* bump to aioautomower==2024.9.0b1

* tests

* remove ZoneInfo wrapper

* use timetzone from start_date object

* Update requirements_all.txt

* Fix names in ProgramEvent
This commit is contained in:
Thomas55555 2024-09-16 07:07:40 +02:00 committed by GitHub
parent 089c942233
commit fccbaa0fbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 412 additions and 11 deletions

View File

@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CALENDAR,
Platform.DEVICE_TRACKER,
Platform.LAWN_MOWER,
Platform.NUMBER,

View File

@ -0,0 +1,86 @@
"""Creates a calendar entity for the mower."""
from datetime import datetime
import logging
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up lawn mower platform."""
coordinator = entry.runtime_data
async_add_entities(
AutomowerCalendarEntity(mower_id, coordinator) for mower_id in coordinator.data
)
class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
"""Representation of the Automower Calendar element."""
_attr_name: str | None = None
def __init__(
self,
mower_id: str,
coordinator: AutomowerDataUpdateCoordinator,
) -> None:
"""Set up AutomowerCalendarEntity."""
super().__init__(mower_id, coordinator)
self._attr_unique_id = mower_id
self._event: CalendarEvent | None = None
@property
def event(self) -> CalendarEvent | None:
"""Return the current or next upcoming event."""
schedule = self.mower_attributes.calendar
if schedule.timeline is None:
return None
cursor = schedule.timeline.active_after(dt_util.now())
program_event = next(cursor, None)
_LOGGER.debug("program_event %s", program_event)
if not program_event:
return None
return CalendarEvent(
summary=program_event.schedule_name,
start=program_event.start.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE),
end=program_event.end.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE),
rrule=program_event.rrule_str,
)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Return calendar events within a datetime range.
This is only called when opening the calendar in the UI.
"""
schedule = self.mower_attributes.calendar
if schedule.timeline is None:
raise HomeAssistantError("Unable to get events: No schedule set")
cursor = schedule.timeline.overlapping(
start_date,
end_date,
)
return [
CalendarEvent(
summary=program_event.schedule_name,
start=program_event.start.replace(tzinfo=start_date.tzinfo),
end=program_event.end.replace(tzinfo=start_date.tzinfo),
rrule=program_event.rrule_str,
)
for program_event in cursor
]

View File

@ -40,7 +40,8 @@
"thursday": false,
"friday": true,
"saturday": false,
"sunday": false
"sunday": false,
"workAreaId": 123456
},
{
"start": 0,
@ -51,6 +52,42 @@
"thursday": true,
"friday": false,
"saturday": true,
"sunday": false,
"workAreaId": 123456
},
{
"start": 0,
"duration": 480,
"monday": false,
"tuesday": true,
"wednesday": false,
"thursday": true,
"friday": false,
"saturday": true,
"sunday": false,
"workAreaId": 654321
},
{
"start": 60,
"duration": 480,
"monday": true,
"tuesday": true,
"wednesday": false,
"thursday": true,
"friday": false,
"saturday": true,
"sunday": false,
"workAreaId": 654321
},
{
"start": 120,
"duration": 480,
"monday": true,
"tuesday": false,
"wednesday": false,
"thursday": true,
"friday": false,
"saturday": true,
"sunday": false
}
]
@ -64,7 +101,7 @@
},
"metadata": {
"connected": true,
"statusTimestamp": 1697669932683
"statusTimestamp": 1685923200000
},
"workAreas": [
{

View File

@ -0,0 +1,89 @@
# serializer version: 1
# name: test_calendar_snapshot[start_date0-end_date0]
dict({
'calendar.test_mower_1': dict({
'events': list([
dict({
'end': '2023-06-05T09:00:00+02:00',
'start': '2023-06-05T01:00:00+02:00',
'summary': 'Back lawn schedule 2',
}),
dict({
'end': '2023-06-05T10:00:00+02:00',
'start': '2023-06-05T02:00:00+02:00',
'summary': 'Schedule 1',
}),
dict({
'end': '2023-06-06T00:00:00+02:00',
'start': '2023-06-05T19:00:00+02:00',
'summary': 'Front lawn schedule 1',
}),
dict({
'end': '2023-06-06T08:00:00+02:00',
'start': '2023-06-06T00:00:00+02:00',
'summary': 'Back lawn schedule 1',
}),
dict({
'end': '2023-06-06T08:00:00+02:00',
'start': '2023-06-06T00:00:00+02:00',
'summary': 'Front lawn schedule 2',
}),
dict({
'end': '2023-06-06T09:00:00+02:00',
'start': '2023-06-06T01:00:00+02:00',
'summary': 'Back lawn schedule 2',
}),
dict({
'end': '2023-06-08T00:00:00+02:00',
'start': '2023-06-07T19:00:00+02:00',
'summary': 'Front lawn schedule 1',
}),
dict({
'end': '2023-06-08T08:00:00+02:00',
'start': '2023-06-08T00:00:00+02:00',
'summary': 'Back lawn schedule 1',
}),
dict({
'end': '2023-06-08T08:00:00+02:00',
'start': '2023-06-08T00:00:00+02:00',
'summary': 'Front lawn schedule 2',
}),
dict({
'end': '2023-06-08T09:00:00+02:00',
'start': '2023-06-08T01:00:00+02:00',
'summary': 'Back lawn schedule 2',
}),
dict({
'end': '2023-06-08T10:00:00+02:00',
'start': '2023-06-08T02:00:00+02:00',
'summary': 'Schedule 1',
}),
dict({
'end': '2023-06-10T00:00:00+02:00',
'start': '2023-06-09T19:00:00+02:00',
'summary': 'Front lawn schedule 1',
}),
dict({
'end': '2023-06-10T08:00:00+02:00',
'start': '2023-06-10T00:00:00+02:00',
'summary': 'Front lawn schedule 2',
}),
dict({
'end': '2023-06-10T08:00:00+02:00',
'start': '2023-06-10T00:00:00+02:00',
'summary': 'Back lawn schedule 1',
}),
dict({
'end': '2023-06-10T09:00:00+02:00',
'start': '2023-06-10T01:00:00+02:00',
'summary': 'Back lawn schedule 2',
}),
dict({
'end': '2023-06-10T10:00:00+02:00',
'start': '2023-06-10T02:00:00+02:00',
'summary': 'Schedule 1',
}),
]),
}),
})
# ---

View File

@ -16,8 +16,8 @@
'thursday': False,
'tuesday': False,
'wednesday': True,
'work_area_id': None,
'work_area_name': None,
'work_area_id': 123456,
'work_area_name': 'Front lawn',
}),
dict({
'duration': 480,
@ -29,6 +29,45 @@
'thursday': True,
'tuesday': True,
'wednesday': False,
'work_area_id': 123456,
'work_area_name': 'Front lawn',
}),
dict({
'duration': 480,
'friday': False,
'monday': False,
'saturday': True,
'start': 0,
'sunday': False,
'thursday': True,
'tuesday': True,
'wednesday': False,
'work_area_id': 654321,
'work_area_name': 'Back lawn',
}),
dict({
'duration': 480,
'friday': False,
'monday': True,
'saturday': True,
'start': 60,
'sunday': False,
'thursday': True,
'tuesday': True,
'wednesday': False,
'work_area_id': 654321,
'work_area_name': 'Back lawn',
}),
dict({
'duration': 480,
'friday': False,
'monday': True,
'saturday': True,
'start': 120,
'sunday': False,
'thursday': True,
'tuesday': False,
'wednesday': False,
'work_area_id': None,
'work_area_name': None,
}),
@ -43,7 +82,7 @@
}),
'metadata': dict({
'connected': True,
'status_dateteime': '2023-10-18T22:58:52.683000+00:00',
'status_dateteime': '2023-06-05T00:00:00+00:00',
}),
'mower': dict({
'activity': 'PARKED_IN_CS',
@ -143,7 +182,7 @@
'auth_implementation': 'husqvarna_automower',
'token': dict({
'access_token': '**REDACTED**',
'expires_at': 1709208000.0,
'expires_at': 1685926800.0,
'expires_in': 86399,
'provider': 'husqvarna',
'refresh_token': '**REDACTED**',

View File

@ -33,7 +33,7 @@ from tests.common import (
)
@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC))
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC))
async def test_button_states_and_commands(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
@ -76,7 +76,7 @@ async def test_button_states_and_commands(
mocked_method.assert_called_once_with(TEST_MOWER_ID)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == "2024-02-29T11:16:00+00:00"
assert state.state == "2023-06-05T00:16:00+00:00"
getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiException(
"Test error"
)

View File

@ -0,0 +1,149 @@
"""Tests for calendar platform."""
from collections.abc import Awaitable, Callable
import datetime
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock
import urllib
from aioautomower.utils import mower_list_to_dictionary_dataclass
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.calendar import (
DOMAIN as CALENDAR_DOMAIN,
EVENT_END_DATETIME,
EVENT_START_DATETIME,
SERVICE_GET_EVENTS,
)
from homeassistant.components.husqvarna_automower.const import DOMAIN
from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_json_value_fixture,
)
from tests.typing import ClientSessionGenerator
TEST_ENTITY = "calendar.test_mower_1"
type GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]]
@pytest.fixture(name="get_events")
def get_events_fixture(
hass_client: ClientSessionGenerator,
) -> GetEventsFn:
"""Fetch calendar events from the HTTP API."""
async def _fetch(start: str, end: str) -> list[dict[str, Any]]:
client = await hass_client()
response = await client.get(
f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}"
)
assert response.status == HTTPStatus.OK
results = await response.json()
return [{k: event[k] for k in ("summary", "start", "end")} for event in results]
return _fetch
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, 12))
async def test_calendar_state_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""State test of the calendar."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("calendar.test_mower_1")
assert state is not None
assert state.state == "off"
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, 19))
async def test_calendar_state_on(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""State test of the calendar."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("calendar.test_mower_1")
assert state is not None
assert state.state == "on"
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5))
async def test_empty_calendar(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
get_events: GetEventsFn,
) -> None:
"""State if there is no schedule set."""
await setup_integration(hass, mock_config_entry)
json_values = load_json_value_fixture("mower.json", DOMAIN)
json_values["data"][0]["attributes"]["calendar"]["tasks"] = []
values = mower_list_to_dictionary_dataclass(json_values)
mock_automower_client.get_status.return_value = values
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("calendar.test_mower_1")
assert state is not None
assert state.state == "off"
events = await get_events("2023-06-05T00:00:00", "2023-06-12T00:00:00")
assert events == []
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5))
@pytest.mark.parametrize(
(
"start_date",
"end_date",
),
[
(
datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC),
datetime.datetime(2023, 6, 12, tzinfo=datetime.UTC),
),
],
)
async def test_calendar_snapshot(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
start_date: datetime,
end_date: datetime,
) -> None:
"""Snapshot test of the calendar entity."""
await setup_integration(hass, mock_config_entry)
events = await hass.services.async_call(
CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
{
ATTR_ENTITY_ID: "calendar.test_mower_1",
EVENT_START_DATETIME: start_date,
EVENT_END_DATETIME: end_date,
},
blocking=True,
return_response=True,
)
assert events == snapshot

View File

@ -21,7 +21,7 @@ from tests.components.diagnostics import (
from tests.typing import ClientSessionGenerator
@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC))
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC))
async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@ -40,7 +40,7 @@ async def test_entry_diagnostics(
assert result == snapshot(exclude=props("created_at", "modified_at"))
@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC))
@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC))
async def test_device_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@ -49,7 +49,7 @@ async def test_device_diagnostics(
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test select platform."""
"""Test device diagnostics platform."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)