From 33a23ad1c6ae42bdb815fd3fd5af1d0cadcbb1d2 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Mon, 27 Jan 2025 08:43:30 +0100 Subject: [PATCH] Add diagnostic sensors for the active subscription of Cookidoo (#136485) * add diagnostics for the active subcription * fix mapping between api and ha states for subscription * multiline lambda --- homeassistant/components/cookidoo/__init__.py | 2 +- homeassistant/components/cookidoo/const.py | 6 + .../components/cookidoo/coordinator.py | 7 +- homeassistant/components/cookidoo/icons.json | 13 ++ homeassistant/components/cookidoo/sensor.py | 111 ++++++++++++++++++ .../components/cookidoo/strings.json | 13 ++ tests/components/cookidoo/conftest.py | 5 + .../cookidoo/fixtures/subscriptions.json | 12 ++ .../cookidoo/snapshots/test_sensor.ambr | 106 +++++++++++++++++ tests/components/cookidoo/test_sensor.py | 44 +++++++ 10 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/cookidoo/sensor.py create mode 100644 tests/components/cookidoo/fixtures/subscriptions.json create mode 100644 tests/components/cookidoo/snapshots/test_sensor.ambr create mode 100644 tests/components/cookidoo/test_sensor.py diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 67095422e65..bff4c8123d6 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator from .helpers import cookidoo_from_config_entry -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cookidoo/const.py b/homeassistant/components/cookidoo/const.py index 37c584404a0..0381e18725d 100644 --- a/homeassistant/components/cookidoo/const.py +++ b/homeassistant/components/cookidoo/const.py @@ -1,3 +1,9 @@ """Constants for the Cookidoo integration.""" DOMAIN = "cookidoo" + +SUBSCRIPTION_MAP = { + "NONE": "free", + "TRIAL": "trial", + "REGULAR": "premium", +} diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index ad86d1fb9f1..f99f58c2dd6 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -13,6 +13,7 @@ from cookidoo_api import ( CookidooException, CookidooIngredientItem, CookidooRequestException, + CookidooSubscription, ) from homeassistant.config_entries import ConfigEntry @@ -34,6 +35,7 @@ class CookidooData: ingredient_items: list[CookidooIngredientItem] additional_items: list[CookidooAdditionalItem] + subscription: CookidooSubscription | None class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): @@ -75,6 +77,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): try: ingredient_items = await self.cookidoo.get_ingredient_items() additional_items = await self.cookidoo.get_additional_items() + subscription = await self.cookidoo.get_active_subscription() except CookidooAuthException: try: await self.cookidoo.refresh_token() @@ -97,5 +100,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): ) from e return CookidooData( - ingredient_items=ingredient_items, additional_items=additional_items + ingredient_items=ingredient_items, + additional_items=additional_items, + subscription=subscription, ) diff --git a/homeassistant/components/cookidoo/icons.json b/homeassistant/components/cookidoo/icons.json index 0e411a70fc2..cf4d9dc2858 100644 --- a/homeassistant/components/cookidoo/icons.json +++ b/homeassistant/components/cookidoo/icons.json @@ -1,5 +1,18 @@ { "entity": { + "sensor": { + "subscription": { + "default": "mdi:account", + "state": { + "free": "mdi:account", + "trial": "mdi:account-question", + "regular": "mdi:account-star" + } + }, + "expiration": { + "default": "mdi:account-reactivate" + } + }, "button": { "todo_clear": { "default": "mdi:cart-off" diff --git a/homeassistant/components/cookidoo/sensor.py b/homeassistant/components/cookidoo/sensor.py new file mode 100644 index 00000000000..7fbacea18bc --- /dev/null +++ b/homeassistant/components/cookidoo/sensor.py @@ -0,0 +1,111 @@ +"""Sensor platform for the Cookidoo integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .const import SUBSCRIPTION_MAP +from .coordinator import ( + CookidooConfigEntry, + CookidooData, + CookidooDataUpdateCoordinator, +) +from .entity import CookidooBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class CookidooSensorEntityDescription(SensorEntityDescription): + """Cookidoo Sensor Description.""" + + value_fn: Callable[[CookidooData], StateType | datetime] + + +class CookidooSensor(StrEnum): + """Cookidoo sensors.""" + + SUBSCRIPTION = "subscription" + EXPIRES = "expires" + + +SENSOR_DESCRIPTIONS: tuple[CookidooSensorEntityDescription, ...] = ( + CookidooSensorEntityDescription( + key=CookidooSensor.SUBSCRIPTION, + translation_key=CookidooSensor.SUBSCRIPTION, + value_fn=( + lambda data: SUBSCRIPTION_MAP[data.subscription.type] + if data.subscription + else SUBSCRIPTION_MAP["NONE"] + ), + entity_category=EntityCategory.DIAGNOSTIC, + options=list(SUBSCRIPTION_MAP.values()), + device_class=SensorDeviceClass.ENUM, + ), + CookidooSensorEntityDescription( + key=CookidooSensor.EXPIRES, + translation_key=CookidooSensor.EXPIRES, + value_fn=( + lambda data: dt_util.parse_datetime(data.subscription.expires) + if data.subscription + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + CookidooSensorEntity( + coordinator, + description, + ) + for description in SENSOR_DESCRIPTIONS + ) + + +class CookidooSensorEntity(CookidooBaseEntity, SensorEntity): + """A sensor entity.""" + + entity_description: CookidooSensorEntityDescription + + def __init__( + self, + coordinator: CookidooDataUpdateCoordinator, + entity_description: CookidooSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{self.entity_description.key}" + ) + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index 8a2a288d11b..ae384fb6635 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -49,6 +49,19 @@ } }, "entity": { + "sensor": { + "subscription": { + "name": "Subscription", + "state": { + "free": "Free", + "trial": "Trial", + "premium": "Premium" + } + }, + "expires": { + "name": "Subscription expiration date" + } + }, "button": { "todo_clear": { "name": "Clear shopping list and additional purchases" diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index a14bc285379..096b2abf958 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -8,6 +8,7 @@ from cookidoo_api import ( CookidooAdditionalItem, CookidooAuthResponse, CookidooIngredientItem, + CookidooSubscription, ) import pytest @@ -54,6 +55,10 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: "data" ] ] + client.get_active_subscription.return_value = CookidooSubscription( + **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + ) + client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) diff --git a/tests/components/cookidoo/fixtures/subscriptions.json b/tests/components/cookidoo/fixtures/subscriptions.json new file mode 100644 index 00000000000..12b74b3af08 --- /dev/null +++ b/tests/components/cookidoo/fixtures/subscriptions.json @@ -0,0 +1,12 @@ +{ + "data": { + "active": true, + "start_date": "2024-12-16T00:00:00Z", + "expires": "2025-12-16T23:59:00Z", + "type": "REGULAR", + "extended_type": "REGULAR", + "subscription_level": "FULL", + "subscription_source": "COMMERCE", + "status": "ACTIVE" + } +} diff --git a/tests/components/cookidoo/snapshots/test_sensor.ambr b/tests/components/cookidoo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..568b0baf688 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_sensor.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[sensor.cookidoo_subscription-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cookidoo_subscription', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Subscription', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_subscription', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Cookidoo Subscription', + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'premium', + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Subscription expiration date', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Cookidoo Subscription expiration date', + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-16T23:59:00+00:00', + }) +# --- diff --git a/tests/components/cookidoo/test_sensor.py b/tests/components/cookidoo/test_sensor.py new file mode 100644 index 00000000000..d2ef88f2857 --- /dev/null +++ b/tests/components/cookidoo/test_sensor.py @@ -0,0 +1,44 @@ +"""Test for sensor platform of the Cookidoo integration.""" + +from collections.abc import Generator +from unittest.mock import 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 tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_setup( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + )