mirror of
https://github.com/home-assistant/core.git
synced 2025-07-14 16:57:10 +00:00
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:
parent
089c942233
commit
fccbaa0fbc
@ -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,
|
||||
|
86
homeassistant/components/husqvarna_automower/calendar.py
Normal file
86
homeassistant/components/husqvarna_automower/calendar.py
Normal 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
|
||||
]
|
@ -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": [
|
||||
{
|
||||
|
@ -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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
# ---
|
@ -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**',
|
||||
|
@ -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"
|
||||
)
|
||||
|
149
tests/components/husqvarna_automower/test_calendar.py
Normal file
149
tests/components/husqvarna_automower/test_calendar.py
Normal 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
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user