Add calendar platform to La Marzocco (#108237)

* add calendar

* rename function

* remove device from test

* requested changes

* extend range

* fix async_get_events

* catch and test edge cases

* remove commented code

* rebase snapshot
pull/111054/head
Josef Zweck 2024-02-21 04:15:47 +01:00 committed by GitHub
parent c690a9df83
commit db77e73a76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 550 additions and 12 deletions

View File

@ -10,6 +10,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.CALENDAR,
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,

View File

@ -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",
)

View File

@ -21,29 +21,20 @@ class LaMarzoccoEntityDescription(EntityDescription):
supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True
class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): class LaMarzoccoBaseEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]):
"""Common elements for all entities.""" """Common elements for all entities."""
entity_description: LaMarzoccoEntityDescription
_attr_has_entity_name = True _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__( def __init__(
self, self,
coordinator: LaMarzoccoUpdateCoordinator, coordinator: LaMarzoccoUpdateCoordinator,
entity_description: LaMarzoccoEntityDescription, key: str,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = entity_description
lm = coordinator.lm 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( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, lm.serial_number)}, identifiers={(DOMAIN, lm.serial_number)},
name=lm.machine_name, name=lm.machine_name,
@ -52,3 +43,26 @@ class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]):
serial_number=lm.serial_number, serial_number=lm.serial_number,
sw_version=lm.firmware_version, 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
)

View File

@ -56,6 +56,11 @@
"name": "Start backflush" "name": "Start backflush"
} }
}, },
"calendar": {
"auto_on_off_schedule": {
"name": "Auto on/off schedule"
}
},
"number": { "number": {
"coffee_temp": { "coffee_temp": {
"name": "Coffee target temperature" "name": "Coffee target temperature"

View File

@ -101,6 +101,7 @@ def mock_lamarzocco(
) )
lamarzocco.config = load_json_object_fixture("config.json", DOMAIN) lamarzocco.config = load_json_object_fixture("config.json", DOMAIN)
lamarzocco.statistics = load_json_array_fixture("statistics.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 = [ lamarzocco.get_all_machines.return_value = [
(serial_number, model_name), (serial_number, model_name),

View File

@ -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"
}
]

View File

@ -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': <ANY>,
'entity_id': 'calendar.gs01234_auto_on_off_schedule',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_calendar_events.1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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([
]),
}),
})
# ---

View File

@ -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