diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 326a9a010ef..6e987b679ed 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -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, diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py new file mode 100644 index 00000000000..f0f5f9f4cd1 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -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 + ] diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 6430dd4a89a..1927f4f281b 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -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": [ { diff --git a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..55cf5e72cb9 --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr @@ -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', + }), + ]), + }), + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 5052531efd2..76f6fc08039 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -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**', diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 5cbb9b893a8..aee37864a3b 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -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" ) diff --git a/tests/components/husqvarna_automower/test_calendar.py b/tests/components/husqvarna_automower/test_calendar.py new file mode 100644 index 00000000000..39c273145ee --- /dev/null +++ b/tests/components/husqvarna_automower/test_calendar.py @@ -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 diff --git a/tests/components/husqvarna_automower/test_diagnostics.py b/tests/components/husqvarna_automower/test_diagnostics.py index 3166b09f1ee..f8dc89af6f0 100644 --- a/tests/components/husqvarna_automower/test_diagnostics.py +++ b/tests/components/husqvarna_automower/test_diagnostics.py @@ -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)