From 2ee955627ad05e93dca84d1eb799d0037264af15 Mon Sep 17 00:00:00 2001 From: surfingbytes Date: Fri, 19 Dec 2025 16:23:41 +0000 Subject: [PATCH 1/4] Added Cookidoo planned meals calendar --- homeassistant/components/cookidoo/__init__.py | 7 +- homeassistant/components/cookidoo/calendar.py | 92 +++++++++++++++++++ .../components/cookidoo/strings.json | 5 + tests/components/cookidoo/conftest.py | 16 ++++ .../cookidoo/fixtures/calendar_week.json | 26 ++++++ .../cookidoo/snapshots/test_calendar.ambr | 69 ++++++++++++++ tests/components/cookidoo/test_calendar.py | 83 +++++++++++++++++ 7 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/cookidoo/calendar.py create mode 100644 tests/components/cookidoo/fixtures/calendar_week.json create mode 100644 tests/components/cookidoo/snapshots/test_calendar.ambr create mode 100644 tests/components/cookidoo/test_calendar.py diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index bff4c8123d6da9..2129d1d8ed5ed5 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -14,7 +14,12 @@ from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator from .helpers import cookidoo_from_config_entry -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CALENDAR, + Platform.SENSOR, + Platform.TODO, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py new file mode 100644 index 00000000000000..c274cb7fb21d66 --- /dev/null +++ b/homeassistant/components/cookidoo/calendar.py @@ -0,0 +1,92 @@ +"""Calendar platform for the Cookidoo integration.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +import logging + +from cookidoo_api import ( + CookidooAuthException, + CookidooException, + CookidooRequestException, +) + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .entity import CookidooBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the calendar platform for entity.""" + coordinator = config_entry.runtime_data + + async_add_entities([CookidooCalendarEntity(coordinator)]) + + +class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity): + """A calendar entity.""" + + _attr_translation_key = "meal_plan" + + def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_meal_plan" + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return None + + async def _fetch_week_plan(self, week_day: date) -> list: + """Fetch a single Cookidoo week plan, retrying once on auth failure.""" + try: + return await self.coordinator.cookidoo.get_recipes_in_calendar_week( + week_day + ) + except CookidooAuthException: + await self.coordinator.cookidoo.refresh_token() + return await self.coordinator.cookidoo.get_recipes_in_calendar_week( + week_day + ) + except CookidooRequestException as e: + _LOGGER.error("Failed to fetch Cookidoo week plan: %s", e) + return [] + except CookidooException as e: + _LOGGER.error("Unknown Cookidoo error: %s", e) + return [] + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + events: list[CalendarEvent] = [] + current_day = start_date.date() + while current_day <= end_date.date(): + week_plan = await self._fetch_week_plan(current_day) + for day_data in week_plan: + day_date = date.fromisoformat(day_data.id) + if start_date.date() <= day_date <= end_date.date(): + events.extend( + CalendarEvent( + start=day_date, + end=day_date + timedelta(days=1), # All-day event + summary=recipe.name, + description=f"Total Time: {recipe.total_time}", + ) + for recipe in day_data.recipes + ) + current_day += timedelta(days=7) # Move to the next week + return events diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index b05cafcbc710ec..a288f6482c3c21 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -54,6 +54,11 @@ "name": "Clear shopping list and additional purchases" } }, + "calendar": { + "meal_plan": { + "name": "Meal plan" + } + }, "sensor": { "expires": { "name": "Subscription expiration date" diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 7d84e7ac83e291..a349fbd5c05ec3 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -11,6 +11,7 @@ CookidooSubscription, CookidooUserInfo, ) +from cookidoo_api.types import CookidooCalendarDay, CookidooCalendarDayRecipe import pytest from homeassistant.components.cookidoo.const import DOMAIN @@ -65,6 +66,21 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) + client.get_recipes_in_calendar_week.return_value = [ + CookidooCalendarDay( + id=day["id"], + title=day["title"], + recipes=[ + CookidooCalendarDayRecipe( + id=recipe["id"], + name=recipe["name"], + total_time=recipe["total_time"], + ) + for recipe in day["recipes"] + ], + ) + for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"] + ] yield client diff --git a/tests/components/cookidoo/fixtures/calendar_week.json b/tests/components/cookidoo/fixtures/calendar_week.json new file mode 100644 index 00000000000000..b8b1d5b6aaa6fd --- /dev/null +++ b/tests/components/cookidoo/fixtures/calendar_week.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": "2025-03-04", + "title": "2025-03-04", + "recipes": [ + { + "id": "r1", + "name": "Waffles", + "total_time": 1500 + } + ] + }, + { + "id": "2025-03-05", + "title": "2025-03-05", + "recipes": [ + { + "id": "r2", + "name": "Mint Tea", + "total_time": 1500 + } + ] + } + ] +} diff --git a/tests/components/cookidoo/snapshots/test_calendar.ambr b/tests/components/cookidoo/snapshots/test_calendar.ambr new file mode 100644 index 00000000000000..0b6c3b9f72f4a3 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_calendar.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_calendar[calendar.cookidoo_meal_plan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.cookidoo_meal_plan', + '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': 'Meal plan', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meal_plan', + 'unique_id': 'sub_uuid_meal_plan', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar[calendar.cookidoo_meal_plan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cookidoo Meal plan', + }), + 'context': , + 'entity_id': 'calendar.cookidoo_meal_plan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_get_events + dict({ + 'calendar.cookidoo_meal_plan': dict({ + 'events': list([ + dict({ + 'description': 'Total Time: 1500', + 'end': '2025-03-05', + 'start': '2025-03-04', + 'summary': 'Waffles', + }), + dict({ + 'description': 'Total Time: 1500', + 'end': '2025-03-06', + 'start': '2025-03-05', + 'summary': 'Mint Tea', + }), + ]), + }), + }) +# --- diff --git a/tests/components/cookidoo/test_calendar.py b/tests/components/cookidoo/test_calendar.py new file mode 100644 index 00000000000000..b86ed56e88b4e7 --- /dev/null +++ b/tests/components/cookidoo/test_calendar.py @@ -0,0 +1,83 @@ +"""Test for calendar platform of the Cookidoo integration.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def calendar_only() -> Generator[None]: + """Enable only the calendar platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.CALENDAR], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_calendar( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of calendar platform.""" + + with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.CALENDAR]): + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_get_events( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test fetching events from Cookidoo calendar.""" + + with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.CALENDAR]): + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + entities = er.async_entries_for_config_entry( + entity_registry, cookidoo_config_entry.entry_id + ) + assert len(entities) == 1 + entity_id = entities[0].entity_id + + resp = await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": datetime(2025, 3, 4, tzinfo=UTC), + "end_date_time": datetime(2025, 3, 6, tzinfo=UTC), + }, + target={"entity_id": entity_id}, + blocking=True, + return_response=True, + ) + + assert resp == snapshot From 33bd4d9ee5265952b3ece250e28ad7c771eb5af0 Mon Sep 17 00:00:00 2001 From: surfingbytes Date: Sun, 21 Dec 2025 10:02:01 +0000 Subject: [PATCH 2/4] Exception instead of logs --- homeassistant/components/cookidoo/calendar.py | 17 +++++++---------- homeassistant/components/cookidoo/strings.json | 3 +++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py index c274cb7fb21d66..b31b771c0ea8ee 100644 --- a/homeassistant/components/cookidoo/calendar.py +++ b/homeassistant/components/cookidoo/calendar.py @@ -5,16 +5,14 @@ from datetime import date, datetime, timedelta import logging -from cookidoo_api import ( - CookidooAuthException, - CookidooException, - CookidooRequestException, -) +from cookidoo_api import CookidooAuthException, CookidooException from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator from .entity import CookidooBaseEntity @@ -61,12 +59,11 @@ async def _fetch_week_plan(self, week_day: date) -> list: return await self.coordinator.cookidoo.get_recipes_in_calendar_week( week_day ) - except CookidooRequestException as e: - _LOGGER.error("Failed to fetch Cookidoo week plan: %s", e) - return [] except CookidooException as e: - _LOGGER.error("Unknown Cookidoo error: %s", e) - return [] + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="calendar_fetch_failed", + ) from e async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index a288f6482c3c21..5de0703dd235e6 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -85,6 +85,9 @@ "button_clear_todo_failed": { "message": "Failed to clear all items from the Cookidoo shopping list" }, + "calendar_fetch_failed": { + "message": "Failed to fetch Cookidoo meal plan" + }, "setup_authentication_exception": { "message": "Authentication failed for {email}, check your email and password" }, From 057317eb118ca95cc4a3a511e60a8651333458e4 Mon Sep 17 00:00:00 2001 From: surfingbytes Date: Sun, 28 Dec 2025 16:13:08 +0000 Subject: [PATCH 3/4] Return next event, fix unique id --- homeassistant/components/cookidoo/calendar.py | 16 +++++++++++++++- homeassistant/components/cookidoo/coordinator.py | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py index b31b771c0ea8ee..8aba8afbe37767 100644 --- a/homeassistant/components/cookidoo/calendar.py +++ b/homeassistant/components/cookidoo/calendar.py @@ -41,11 +41,25 @@ def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) assert coordinator.config_entry.unique_id - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_meal_plan" + self._attr_unique_id = coordinator.config_entry.unique_id @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" + if not self.coordinator.data or not self.coordinator.data.week_plan: + return None + + today = date.today() + for day_data in self.coordinator.data.week_plan: + day_date = date.fromisoformat(day_data.id) + if day_date >= today and day_data.recipes: + recipe = day_data.recipes[0] + return CalendarEvent( + start=day_date, + end=day_date + timedelta(days=1), # All-day event + summary=recipe.name, + description=f"Total Time: {recipe.total_time}", + ) return None async def _fetch_week_plan(self, week_day: date) -> list: diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index 2ce61306afe36f..940c6e36f713c4 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta +from datetime import date, timedelta import logging from cookidoo_api import ( @@ -16,6 +16,7 @@ CookidooSubscription, CookidooUserInfo, ) +from cookidoo_api.types import CookidooCalendarDay from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL @@ -37,6 +38,7 @@ class CookidooData: ingredient_items: list[CookidooIngredientItem] additional_items: list[CookidooAdditionalItem] subscription: CookidooSubscription | None + week_plan: list[CookidooCalendarDay] class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): @@ -81,6 +83,7 @@ async def _async_update_data(self) -> CookidooData: ingredient_items = await self.cookidoo.get_ingredient_items() additional_items = await self.cookidoo.get_additional_items() subscription = await self.cookidoo.get_active_subscription() + week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today()) except CookidooAuthException: try: await self.cookidoo.refresh_token() @@ -106,4 +109,5 @@ async def _async_update_data(self) -> CookidooData: ingredient_items=ingredient_items, additional_items=additional_items, subscription=subscription, + week_plan=week_plan, ) From a6dfe1f68e3f5f0244b9b00b2f8e4b822ed3387a Mon Sep 17 00:00:00 2001 From: Joostlek Date: Mon, 29 Dec 2025 13:45:31 +0100 Subject: [PATCH 4/4] Fix --- homeassistant/components/cookidoo/calendar.py | 28 +++++++++---------- .../cookidoo/snapshots/test_calendar.ambr | 2 +- .../cookidoo/snapshots/test_diagnostics.ambr | 24 ++++++++++++++++ 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py index 8aba8afbe37767..0035e225e8fe00 100644 --- a/homeassistant/components/cookidoo/calendar.py +++ b/homeassistant/components/cookidoo/calendar.py @@ -6,6 +6,7 @@ import logging from cookidoo_api import CookidooAuthException, CookidooException +from cookidoo_api.types import CookidooCalendarDayRecipe from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -32,6 +33,16 @@ async def async_setup_entry( async_add_entities([CookidooCalendarEntity(coordinator)]) +def recipe_to_event(day_date: date, recipe: CookidooCalendarDayRecipe) -> CalendarEvent: + """Convert a Cookidoo recipe to a CalendarEvent.""" + return CalendarEvent( + start=day_date, + end=day_date + timedelta(days=1), # All-day event + summary=recipe.name, + description=f"Total Time: {recipe.total_time}", + ) + + class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity): """A calendar entity.""" @@ -46,7 +57,7 @@ def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - if not self.coordinator.data or not self.coordinator.data.week_plan: + if not self.coordinator.data.week_plan: return None today = date.today() @@ -54,12 +65,7 @@ def event(self) -> CalendarEvent | None: day_date = date.fromisoformat(day_data.id) if day_date >= today and day_data.recipes: recipe = day_data.recipes[0] - return CalendarEvent( - start=day_date, - end=day_date + timedelta(days=1), # All-day event - summary=recipe.name, - description=f"Total Time: {recipe.total_time}", - ) + return recipe_to_event(day_date, recipe) return None async def _fetch_week_plan(self, week_day: date) -> list: @@ -91,13 +97,7 @@ async def async_get_events( day_date = date.fromisoformat(day_data.id) if start_date.date() <= day_date <= end_date.date(): events.extend( - CalendarEvent( - start=day_date, - end=day_date + timedelta(days=1), # All-day event - summary=recipe.name, - description=f"Total Time: {recipe.total_time}", - ) - for recipe in day_data.recipes + recipe_to_event(day_date, recipe) for recipe in day_data.recipes ) current_day += timedelta(days=7) # Move to the next week return events diff --git a/tests/components/cookidoo/snapshots/test_calendar.ambr b/tests/components/cookidoo/snapshots/test_calendar.ambr index 0b6c3b9f72f4a3..6d1dd2bb70bc64 100644 --- a/tests/components/cookidoo/snapshots/test_calendar.ambr +++ b/tests/components/cookidoo/snapshots/test_calendar.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'meal_plan', - 'unique_id': 'sub_uuid_meal_plan', + 'unique_id': 'sub_uuid', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr index 3dc799c110830d..dbe1197475ba32 100644 --- a/tests/components/cookidoo/snapshots/test_diagnostics.ambr +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -27,6 +27,30 @@ 'subscription_source': 'COMMERCE', 'type': 'REGULAR', }), + 'week_plan': list([ + dict({ + 'id': '2025-03-04', + 'recipes': list([ + dict({ + 'id': 'r1', + 'name': 'Waffles', + 'total_time': 1500, + }), + ]), + 'title': '2025-03-04', + }), + dict({ + 'id': '2025-03-05', + 'recipes': list([ + dict({ + 'id': 'r2', + 'name': 'Mint Tea', + 'total_time': 1500, + }), + ]), + 'title': '2025-03-05', + }), + ]), }), 'entry_data': dict({ 'country': 'CH',