diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 0adfc4bebfe..0cdacc8d2e4 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -10,6 +10,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CALENDAR, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py new file mode 100644 index 00000000000..2a08a90a1b2 --- /dev/null +++ b/homeassistant/components/lamarzocco/calendar.py @@ -0,0 +1,113 @@ +"""Calendar platform for La Marzocco espresso machines.""" + +from collections.abc import Iterator +from datetime import datetime, timedelta + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .entity import LaMarzoccoBaseEntity + +CALENDAR_KEY = "auto_on_off_schedule" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch entities and services.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY)]) + + +class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): + """Class representing a La Marzocco calendar.""" + + _attr_translation_key = CALENDAR_KEY + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + now = dt_util.now() + + events = self._get_events( + start_date=now, + end_date=now + timedelta(days=7), # only need to check a week ahead + ) + return next(iter(events), None) + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + + return self._get_events( + start_date=start_date, + end_date=end_date, + ) + + def _get_events( + self, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + """Get calendar events within a datetime range.""" + + events: list[CalendarEvent] = [] + for date in self._get_date_range(start_date, end_date): + if scheduled := self._async_get_calendar_event(date): + if scheduled.end < start_date: + continue + if scheduled.start > end_date: + continue + events.append(scheduled) + return events + + def _get_date_range( + self, start_date: datetime, end_date: datetime + ) -> Iterator[datetime]: + current_date = start_date + while current_date.date() < end_date.date(): + yield current_date + current_date += timedelta(days=1) + yield end_date + + def _async_get_calendar_event(self, date: datetime) -> CalendarEvent | None: + """Return calendar event for a given weekday.""" + + # check first if auto/on off is turned on in general + # because could still be on for that day but disabled + if self.coordinator.lm.current_status["global_auto"] != "Enabled": + return None + + # parse the schedule for the day + schedule_day = self.coordinator.lm.schedule[date.weekday()] + if schedule_day["enable"] == "Disabled": + return None + hour_on, minute_on = schedule_day["on"].split(":") + hour_off, minute_off = schedule_day["off"].split(":") + return CalendarEvent( + start=date.replace( + hour=int(hour_on), + minute=int(minute_on), + second=0, + microsecond=0, + ), + end=date.replace( + hour=int(hour_off), + minute=int(minute_off), + second=0, + microsecond=0, + ), + summary=f"Machine {self.coordinator.config_entry.title} on", + description="Machine is scheduled to turn on at the start time and off at the end time", + ) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 6918741f1d3..4cb9d4a580a 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -21,29 +21,20 @@ class LaMarzoccoEntityDescription(EntityDescription): supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True -class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): +class LaMarzoccoBaseEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): """Common elements for all entities.""" - entity_description: LaMarzoccoEntityDescription _attr_has_entity_name = True - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self.entity_description.available_fn( - self.coordinator.lm - ) - def __init__( self, coordinator: LaMarzoccoUpdateCoordinator, - entity_description: LaMarzoccoEntityDescription, + key: str, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self.entity_description = entity_description lm = coordinator.lm - self._attr_unique_id = f"{lm.serial_number}_{entity_description.key}" + self._attr_unique_id = f"{lm.serial_number}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, lm.serial_number)}, name=lm.machine_name, @@ -52,3 +43,26 @@ class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): serial_number=lm.serial_number, sw_version=lm.firmware_version, ) + + +class LaMarzoccoEntity(LaMarzoccoBaseEntity): + """Common elements for all entities.""" + + entity_description: LaMarzoccoEntityDescription + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + entity_description: LaMarzoccoEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.lm + ) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 4a375e0a17b..57421dfee83 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -56,6 +56,11 @@ "name": "Start backflush" } }, + "calendar": { + "auto_on_off_schedule": { + "name": "Auto on/off schedule" + } + }, "number": { "coffee_temp": { "name": "Coffee target temperature" diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 68f781407d3..17d605a0dde 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -101,6 +101,7 @@ def mock_lamarzocco( ) lamarzocco.config = load_json_object_fixture("config.json", DOMAIN) lamarzocco.statistics = load_json_array_fixture("statistics.json", DOMAIN) + lamarzocco.schedule = load_json_array_fixture("schedule.json", DOMAIN) lamarzocco.get_all_machines.return_value = [ (serial_number, model_name), diff --git a/tests/components/lamarzocco/fixtures/schedule.json b/tests/components/lamarzocco/fixtures/schedule.json new file mode 100644 index 00000000000..62550caaa0b --- /dev/null +++ b/tests/components/lamarzocco/fixtures/schedule.json @@ -0,0 +1,44 @@ +[ + { + "day": "MONDAY", + "enable": "Disabled", + "on": "00:00", + "off": "00:00" + }, + { + "day": "TUESDAY", + "enable": "Disabled", + "on": "00:00", + "off": "00:00" + }, + { + "day": "WEDNESDAY", + "enable": "Enabled", + "on": "08:00", + "off": "13:00" + }, + { + "day": "THURSDAY", + "enable": "Disabled", + "on": "00:00", + "off": "00:00" + }, + { + "day": "FRIDAY", + "enable": "Enabled", + "on": "06:00", + "off": "09:00" + }, + { + "day": "SATURDAY", + "enable": "Enabled", + "on": "10:00", + "off": "23:00" + }, + { + "day": "SUNDAY", + "enable": "Disabled", + "on": "00:00", + "off": "00:00" + } +] diff --git a/tests/components/lamarzocco/snapshots/test_calendar.ambr b/tests/components/lamarzocco/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..ee318a7fc67 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_calendar.ambr @@ -0,0 +1,212 @@ +# serializer version: 1 +# name: test_calendar_edge_cases[start_date0-end_date0] + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-11T07:30:00-08:00', + 'start': '2024-02-11T07:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + ]), + }), + }) +# --- +# name: test_calendar_edge_cases[start_date1-end_date1] + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-11T07:30:00-08:00', + 'start': '2024-02-11T07:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + ]), + }), + }) +# --- +# name: test_calendar_edge_cases[start_date2-end_date2] + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-18T07:30:00-08:00', + 'start': '2024-02-18T07:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + ]), + }), + }) +# --- +# name: test_calendar_edge_cases[start_date3-end_date3] + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-18T07:30:00-08:00', + 'start': '2024-02-18T07:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + ]), + }), + }) +# --- +# name: test_calendar_edge_cases[start_date4-end_date4] + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + ]), + }), + }) +# --- +# name: test_calendar_edge_cases[start_date5-end_date5] + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-11T07:30:00-08:00', + 'start': '2024-02-11T07:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-18T07:30:00-08:00', + 'start': '2024-02-18T07:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + ]), + }), + }) +# --- +# name: test_calendar_events + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end_time': '2024-01-13 23:00:00', + 'friendly_name': 'GS01234 Auto on/off schedule', + 'location': '', + 'message': 'Machine My LaMarzocco on', + 'start_time': '2024-01-13 10:00:00', + }), + 'context': , + 'entity_id': 'calendar.gs01234_auto_on_off_schedule', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_calendar_events.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.gs01234_auto_on_off_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto on/off schedule', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off_schedule', + 'unique_id': 'GS01234_auto_on_off_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_events.2 + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-13T23:00:00-08:00', + 'start': '2024-01-13T10:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-17T13:00:00-08:00', + 'start': '2024-01-17T08:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-19T09:00:00-08:00', + 'start': '2024-01-19T06:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-20T23:00:00-08:00', + 'start': '2024-01-20T10:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-24T13:00:00-08:00', + 'start': '2024-01-24T08:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-26T09:00:00-08:00', + 'start': '2024-01-26T06:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-27T23:00:00-08:00', + 'start': '2024-01-27T10:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-31T13:00:00-08:00', + 'start': '2024-01-31T08:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-02T09:00:00-08:00', + 'start': '2024-02-02T06:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-03T23:00:00-08:00', + 'start': '2024-02-03T10:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + ]), + }), + }) +# --- +# name: test_no_calendar_events_global_disable + dict({ + 'calendar.gs01234_auto_on_off_schedule': dict({ + 'events': list([ + ]), + }), + }) +# --- diff --git a/tests/components/lamarzocco/test_calendar.py b/tests/components/lamarzocco/test_calendar.py new file mode 100644 index 00000000000..8cc529c226f --- /dev/null +++ b/tests/components/lamarzocco/test_calendar.py @@ -0,0 +1,148 @@ +"""Tests for La Marzocco calendar.""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +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.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import async_init_integration + +from tests.common import MockConfigEntry + + +async def test_calendar_events( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the calendar.""" + + test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.DEFAULT_TIME_ZONE) + freezer.move_to(test_time) + + await async_init_integration(hass, mock_config_entry) + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"calendar.{serial_number}_auto_on_off_schedule") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule", + EVENT_START_DATETIME: test_time, + EVENT_END_DATETIME: test_time + timedelta(days=23), + }, + blocking=True, + return_response=True, + ) + + assert events == snapshot + + +@pytest.mark.parametrize( + ( + "start_date", + "end_date", + ), + [ + (datetime(2024, 2, 11, 6, 0), datetime(2024, 2, 18, 6, 0)), + (datetime(2024, 2, 11, 7, 15), datetime(2024, 2, 18, 6, 0)), + (datetime(2024, 2, 11, 9, 0), datetime(2024, 2, 18, 7, 15)), + (datetime(2024, 2, 11, 9, 0), datetime(2024, 2, 18, 8, 0)), + (datetime(2024, 2, 11, 9, 0), datetime(2024, 2, 18, 6, 0)), + (datetime(2024, 2, 11, 6, 0), datetime(2024, 2, 18, 8, 0)), + ], +) +async def test_calendar_edge_cases( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + start_date: datetime, + end_date: datetime, +) -> None: + """Test edge cases.""" + start_date = start_date.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + end_date = end_date.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + + # set schedule to be only on Sunday, 07:00 - 07:30 + mock_lamarzocco.schedule[2]["enable"] = "Disabled" + mock_lamarzocco.schedule[4]["enable"] = "Disabled" + mock_lamarzocco.schedule[5]["enable"] = "Disabled" + mock_lamarzocco.schedule[6]["enable"] = "Enabled" + mock_lamarzocco.schedule[6]["on"] = "07:00" + mock_lamarzocco.schedule[6]["off"] = "07:30" + + await async_init_integration(hass, mock_config_entry) + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + ATTR_ENTITY_ID: f"calendar.{mock_lamarzocco.serial_number}_auto_on_off_schedule", + EVENT_START_DATETIME: start_date, + EVENT_END_DATETIME: end_date, + }, + blocking=True, + return_response=True, + ) + + assert events == snapshot + + +async def test_no_calendar_events_global_disable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Assert no events when global auto on/off is disabled.""" + + mock_lamarzocco.current_status["global_auto"] = "Disabled" + test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.DEFAULT_TIME_ZONE) + freezer.move_to(test_time) + + await async_init_integration(hass, mock_config_entry) + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"calendar.{serial_number}_auto_on_off_schedule") + assert state + + events = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule", + EVENT_START_DATETIME: test_time, + EVENT_END_DATETIME: test_time + timedelta(days=23), + }, + blocking=True, + return_response=True, + ) + assert events == snapshot