diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index 87b3e3988a2..393ef1e5ecd 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -22,11 +22,12 @@ from .coordinator import ( MealieData, MealieMealplanCoordinator, MealieShoppingListCoordinator, + MealieStatisticsCoordinator, ) from .services import setup_services from .utils import create_version -PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.TODO] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -75,12 +76,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo mealplan_coordinator = MealieMealplanCoordinator(hass, client) shoppinglist_coordinator = MealieShoppingListCoordinator(hass, client) + statistics_coordinator = MealieStatisticsCoordinator(hass, client) await mealplan_coordinator.async_config_entry_first_refresh() await shoppinglist_coordinator.async_config_entry_first_refresh() + await statistics_coordinator.async_config_entry_first_refresh() entry.runtime_data = MealieData( - client, mealplan_coordinator, shoppinglist_coordinator + client, mealplan_coordinator, shoppinglist_coordinator, statistics_coordinator ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index bb97b3c26a3..a4507c88985 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -13,6 +13,7 @@ from aiomealie import ( MealplanEntryType, ShoppingItem, ShoppingList, + Statistics, ) from homeassistant.config_entries import ConfigEntry @@ -33,6 +34,7 @@ class MealieData: client: MealieClient mealplan_coordinator: MealieMealplanCoordinator shoppinglist_coordinator: MealieShoppingListCoordinator + statistics_coordinator: MealieStatisticsCoordinator type MealieConfigEntry = ConfigEntry[MealieData] @@ -139,3 +141,26 @@ class MealieShoppingListCoordinator( except MealieConnectionError as error: raise UpdateFailed(error) from error return shopping_list_items + + +class MealieStatisticsCoordinator(MealieDataUpdateCoordinator[Statistics]): + """Class to manage fetching Mealie Statistics data.""" + + def __init__(self, hass: HomeAssistant, client: MealieClient) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + name="MealieStatistics", + client=client, + update_interval=timedelta(minutes=15), + ) + + async def _async_update_data( + self, + ) -> Statistics: + try: + return await self.client.get_statistics() + except MealieAuthenticationError as error: + raise ConfigEntryAuthFailed from error + except MealieConnectionError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index 87aefc3d91f..f509985eb72 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -4,6 +4,23 @@ "shopping_list": { "default": "mdi:basket" } + }, + "sensor": { + "recipes": { + "default": "mdi:food" + }, + "users": { + "default": "mdi:account-multiple" + }, + "categories": { + "default": "mdi:shape" + }, + "tags": { + "default": "mdi:tag-multiple" + }, + "tools": { + "default": "mdi:tools" + } } }, "services": { diff --git a/homeassistant/components/mealie/sensor.py b/homeassistant/components/mealie/sensor.py new file mode 100644 index 00000000000..b4baac34ebe --- /dev/null +++ b/homeassistant/components/mealie/sensor.py @@ -0,0 +1,94 @@ +"""Support for Mealie sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from aiomealie import Statistics + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import MealieConfigEntry, MealieStatisticsCoordinator +from .entity import MealieEntity + + +@dataclass(frozen=True, kw_only=True) +class MealieStatisticsSensorEntityDescription(SensorEntityDescription): + """Describes Mealie Statistics sensor entity.""" + + value_fn: Callable[[Statistics], StateType] + + +SENSOR_TYPES: tuple[MealieStatisticsSensorEntityDescription, ...] = ( + MealieStatisticsSensorEntityDescription( + key="recipes", + native_unit_of_measurement="recipes", + state_class=SensorStateClass.TOTAL, + value_fn=lambda statistics: statistics.total_recipes, + ), + MealieStatisticsSensorEntityDescription( + key="users", + native_unit_of_measurement="users", + state_class=SensorStateClass.TOTAL, + value_fn=lambda statistics: statistics.total_users, + ), + MealieStatisticsSensorEntityDescription( + key="categories", + native_unit_of_measurement="categories", + state_class=SensorStateClass.TOTAL, + value_fn=lambda statistics: statistics.total_categories, + ), + MealieStatisticsSensorEntityDescription( + key="tags", + native_unit_of_measurement="tags", + state_class=SensorStateClass.TOTAL, + value_fn=lambda statistics: statistics.total_tags, + ), + MealieStatisticsSensorEntityDescription( + key="tools", + native_unit_of_measurement="tools", + state_class=SensorStateClass.TOTAL, + value_fn=lambda statistics: statistics.total_tools, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MealieConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Mealie sensors based on a config entry.""" + coordinator = entry.runtime_data.statistics_coordinator + + async_add_entities( + MealieStatisticSensors(coordinator, description) for description in SENSOR_TYPES + ) + + +class MealieStatisticSensors(MealieEntity, SensorEntity): + """Defines a Mealie sensor.""" + + entity_description: MealieStatisticsSensorEntityDescription + coordinator: MealieStatisticsCoordinator + + def __init__( + self, + coordinator: MealieStatisticsCoordinator, + description: MealieStatisticsSensorEntityDescription, + ) -> None: + """Initialize Mealie sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_translation_key = description.key + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index a0b0dcbfc4f..7e1b307d18b 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -53,6 +53,23 @@ "side": { "name": "Side" } + }, + "sensor": { + "recipes": { + "name": "Recipes" + }, + "users": { + "name": "Users" + }, + "categories": { + "name": "Categories" + }, + "tags": { + "name": "Tags" + }, + "tools": { + "name": "Tools" + } } }, "exceptions": { diff --git a/tests/components/mealie/conftest.py b/tests/components/mealie/conftest.py index be9f939267a..2916159a799 100644 --- a/tests/components/mealie/conftest.py +++ b/tests/components/mealie/conftest.py @@ -10,6 +10,7 @@ from aiomealie import ( Recipe, ShoppingItemsResponse, ShoppingListsResponse, + Statistics, UserInfo, ) from mashumaro.codecs.orjson import ORJSONDecoder @@ -70,6 +71,9 @@ def mock_mealie_client() -> Generator[AsyncMock]: client.get_shopping_items.return_value = ShoppingItemsResponse.from_json( load_fixture("get_shopping_items.json", DOMAIN) ) + client.get_statistics.return_value = Statistics.from_json( + load_fixture("statistics.json", DOMAIN) + ) yield client diff --git a/tests/components/mealie/fixtures/statistics.json b/tests/components/mealie/fixtures/statistics.json new file mode 100644 index 00000000000..350bf1fd9ff --- /dev/null +++ b/tests/components/mealie/fixtures/statistics.json @@ -0,0 +1,7 @@ +{ + "totalRecipes": 765, + "totalUsers": 3, + "totalCategories": 24, + "totalTags": 454, + "totalTools": 11 +} diff --git a/tests/components/mealie/snapshots/test_sensor.ambr b/tests/components/mealie/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e645cf4c45f --- /dev/null +++ b/tests/components/mealie/snapshots/test_sensor.ambr @@ -0,0 +1,251 @@ +# serializer version: 1 +# name: test_entities[sensor.mealie_categories-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mealie_categories', + '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': 'Categories', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'categories', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_categories', + 'unit_of_measurement': 'categories', + }) +# --- +# name: test_entities[sensor.mealie_categories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mealie Categories', + 'state_class': , + 'unit_of_measurement': 'categories', + }), + 'context': , + 'entity_id': 'sensor.mealie_categories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_entities[sensor.mealie_recipes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mealie_recipes', + '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': 'Recipes', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'recipes', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_recipes', + 'unit_of_measurement': 'recipes', + }) +# --- +# name: test_entities[sensor.mealie_recipes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mealie Recipes', + 'state_class': , + 'unit_of_measurement': 'recipes', + }), + 'context': , + 'entity_id': 'sensor.mealie_recipes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '765', + }) +# --- +# name: test_entities[sensor.mealie_tags-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mealie_tags', + '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': 'Tags', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tags', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tags', + 'unit_of_measurement': 'tags', + }) +# --- +# name: test_entities[sensor.mealie_tags-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mealie Tags', + 'state_class': , + 'unit_of_measurement': 'tags', + }), + 'context': , + 'entity_id': 'sensor.mealie_tags', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '454', + }) +# --- +# name: test_entities[sensor.mealie_tools-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mealie_tools', + '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': 'Tools', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tools', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_tools', + 'unit_of_measurement': 'tools', + }) +# --- +# name: test_entities[sensor.mealie_tools-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mealie Tools', + 'state_class': , + 'unit_of_measurement': 'tools', + }), + 'context': , + 'entity_id': 'sensor.mealie_tools', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_entities[sensor.mealie_users-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mealie_users', + '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': 'Users', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'users', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_users', + 'unit_of_measurement': 'users', + }) +# --- +# name: test_entities[sensor.mealie_users-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mealie Users', + 'state_class': , + 'unit_of_measurement': 'users', + }), + 'context': , + 'entity_id': 'sensor.mealie_users', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/mealie/test_sensor.py b/tests/components/mealie/test_sensor.py new file mode 100644 index 00000000000..5a55b89ad21 --- /dev/null +++ b/tests/components/mealie/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Mealie sensors.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +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 + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.mealie.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)