From e10c9bf29aea0864f967198f8927d6044c606919 Mon Sep 17 00:00:00 2001 From: Detobel Date: Sun, 10 May 2026 09:58:46 +0200 Subject: [PATCH 1/2] Add recipes available sensor with parameter indicating stock status - Added `sensor.grocy_recipes` displaying the count of available recipes and their details. - Added `all_ingredients_in_stock` boolean parameter to each recipe under sensor attributes using a custom helper `RecipeWrapper` that combines recipe details with their fulfillment status from the Grocy API. - Included base64 proxy picture url support for recipes. - Added comprehensive unit tests and documented the new sensor in `FEATURES.md`. Co-authored-by: detobel36 <5373615+detobel36@users.noreply.github.com> --- custom_components/grocy/__init__.py | 2 ++ custom_components/grocy/const.py | 2 ++ custom_components/grocy/coordinator.py | 3 +- custom_components/grocy/grocy_data.py | 23 +++++++++++- custom_components/grocy/helpers.py | 34 ++++++++++++++++++ custom_components/grocy/sensor.py | 14 ++++++++ docs/FEATURES.md | 3 ++ docs/test-feature-map.yaml | 2 ++ tests/test_entities.py | 14 ++++++++ tests/test_grocy_data.py | 49 +++++++++++++++++++++++++- tests/test_init.py | 4 ++- 11 files changed, 146 insertions(+), 4 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 5cb7b32..567868a 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -26,6 +26,7 @@ ATTR_OVERDUE_CHORES, ATTR_OVERDUE_PRODUCTS, ATTR_OVERDUE_TASKS, + ATTR_RECIPES, ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, @@ -110,6 +111,7 @@ async def _async_get_available_entities(grocy_data: GrocyData) -> list[str]: if "FEATURE_FLAG_RECIPES" in grocy_config.enabled_features: available_entities.append(ATTR_MEAL_PLAN) + available_entities.append(ATTR_RECIPES) if "FEATURE_FLAG_BATTERIES" in grocy_config.enabled_features: available_entities.append(ATTR_BATTERIES) diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index a73053a..defdeb1 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -37,6 +37,7 @@ PRODUCTS: Final = "Product(s)" TASKS: Final = "Task(s)" ITEMS: Final = "Item(s)" +RECIPES: Final = "Recipe(s)" ATTR_BATTERIES: Final = "batteries" ATTR_CHORES: Final = "chores" @@ -51,3 +52,4 @@ ATTR_SHOPPING_LIST: Final = "shopping_list" ATTR_STOCK: Final = "stock" ATTR_TASKS: Final = "tasks" +ATTR_RECIPES: Final = "recipes" diff --git a/custom_components/grocy/coordinator.py b/custom_components/grocy/coordinator.py index d4327bc..aec8113 100644 --- a/custom_components/grocy/coordinator.py +++ b/custom_components/grocy/coordinator.py @@ -25,7 +25,7 @@ SCAN_INTERVAL, ) from .grocy_data import GrocyData -from .helpers import MealPlanItemWrapper, extract_base_url_and_path +from .helpers import MealPlanItemWrapper, RecipeWrapper, extract_base_url_and_path _LOGGER = logging.getLogger(__name__) @@ -42,6 +42,7 @@ class GrocyCoordinatorData: overdue_chores: list[Chore] | None = None overdue_products: list[Product] | None = None overdue_tasks: list[Task] | None = None + recipes: list[RecipeWrapper] | None = None shopping_list: list[ShoppingListProduct] | None = None stock: list[Product] | None = None tasks: list[Task] | None = None diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index 14ba62d..2ae8d35 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -14,6 +14,7 @@ from grocy import Grocy from grocy.data_models.battery import Battery from grocy.data_models.chore import Chore +from grocy.data_models.generic import EntityType from grocy.data_models.product import Product from grocy.grocy_api_client import CurrentVolatileStockResponse @@ -28,6 +29,7 @@ ATTR_OVERDUE_CHORES, ATTR_OVERDUE_PRODUCTS, ATTR_OVERDUE_TASKS, + ATTR_RECIPES, ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, @@ -35,7 +37,7 @@ CONF_PORT, CONF_URL, ) -from .helpers import MealPlanItemWrapper, extract_base_url_and_path +from .helpers import MealPlanItemWrapper, RecipeWrapper, extract_base_url_and_path _LOGGER = logging.getLogger(__name__) @@ -62,6 +64,7 @@ def __init__(self, hass: HomeAssistant, api: Grocy) -> None: ATTR_OVERDUE_TASKS: self.async_update_overdue_tasks, ATTR_BATTERIES: self.async_update_batteries, ATTR_OVERDUE_BATTERIES: self.async_update_overdue_batteries, + ATTR_RECIPES: self.async_update_recipes, } async def async_update_data(self, entity_key): @@ -255,6 +258,24 @@ def wrapper(): return await self.hass.async_add_executor_job(wrapper) + async def async_update_recipes(self) -> list[RecipeWrapper]: + """Update recipes data.""" + + def wrapper() -> list[RecipeWrapper]: + recipes = self.api.generic.list(EntityType.RECIPES) or [] + fulfillment = self.api.recipes.all_fulfillment() or [] + + fulfillment_map = {f.recipe_id: f.need_fulfilled for f in fulfillment} + + wrapped_recipes = [] + for r in recipes: + recipe_id = r.get("id") + need_fulfilled = fulfillment_map.get(recipe_id, False) + wrapped_recipes.append(RecipeWrapper(r, need_fulfilled)) + return wrapped_recipes + + return await self.hass.async_add_executor_job(wrapper) + async def async_setup_endpoint_for_image_proxy( hass: HomeAssistant, config_entry: ConfigEntry diff --git a/custom_components/grocy/helpers.py b/custom_components/grocy/helpers.py index 9b5c133..e7d7364 100644 --- a/custom_components/grocy/helpers.py +++ b/custom_components/grocy/helpers.py @@ -16,6 +16,40 @@ def extract_base_url_and_path(url: str) -> tuple[str, str]: return (f"{parsed_url.scheme}://{parsed_url.netloc}", parsed_url.path.strip("/")) +class RecipeWrapper: + """Wrapper around a grocy Recipe dictionary with fulfillment information.""" + + def __init__(self, recipe: dict[str, Any], need_fulfilled: bool) -> None: + self._recipe = recipe + self._need_fulfilled = need_fulfilled + + @property + def recipe(self) -> dict[str, Any]: + """Return the wrapped recipe dict.""" + return self._recipe + + @property + def all_ingredients_in_stock(self) -> bool: + """Return whether all ingredients are in stock.""" + return self._need_fulfilled + + @property + def picture_url(self) -> str | None: + """Proxy URL to the picture.""" + picture_file_name = self._recipe.get("picture_file_name") + if picture_file_name: + b64name = base64.b64encode(picture_file_name.encode("ascii")) + return f"/api/grocy/recipepictures/{str(b64name, 'utf-8')}" + return None + + def as_dict(self) -> dict[str, Any]: + """Return serialized attributes including the proxy picture URL and in stock status.""" + props = dict(self._recipe) + props["all_ingredients_in_stock"] = self.all_ingredients_in_stock + props["picture_url"] = self.picture_url + return props + + class MealPlanItemWrapper: """Wrapper around a grocy MealPlanItem.""" diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 7965fe0..53bb4bf 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -20,6 +20,7 @@ ATTR_BATTERIES, ATTR_CHORES, ATTR_MEAL_PLAN, + ATTR_RECIPES, ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, @@ -28,6 +29,7 @@ ITEMS, MEAL_PLANS, PRODUCTS, + RECIPES, TASKS, ) from .coordinator import GrocyCoordinatorData, GrocyDataUpdateCoordinator @@ -141,6 +143,18 @@ class GrocySensorEntityDescription(SensorEntityDescription): "count": len(data), }, ), + GrocySensorEntityDescription( + key=ATTR_RECIPES, + name="Grocy recipes", + native_unit_of_measurement=RECIPES, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:book-open-variant", + exists_fn=lambda entities: ATTR_RECIPES in entities, + attributes_fn=lambda data: { + "recipes": [model_to_dict(x) for x in data], + "count": len(data), + }, + ), ) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c68cd31..86103f4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -379,6 +379,7 @@ Requires Grocy feature flag: `FEATURE_FLAG_RECIPES` | Entity ID | State | Attributes | Icon | |-----------|-------|------------|------| | `sensor.grocy_meal_plan` | Number of upcoming meals | `count`, `meals` (list) | mdi:silverware-variant | +| `sensor.grocy_recipes` | Number of available recipes | `count`, `recipes` (list) | mdi:book-open-variant | #### Todo Lists @@ -409,8 +410,10 @@ Consume a recipe (deduct all ingredients from stock). | tests/test_todo.py | `test_todo_item_from_meal_plan_item_wrapper` | MealPlanItemWrapper converts to todo | | tests/test_todo.py | `test_async_update_todo_item_complete_meal_plan` | Completing meal plan consumes recipe and deletes entry | | tests/test_todo.py | `test_async_update_todo_item_meal_plan_needs_action_raises` | Uncompleting meal plan raises NotImplementedError | +| tests/test_entities.py | `test_sensor_recipes_counts` | Recipes sensor counts correctly | | tests/test_grocy_data.py | `test_async_update_meal_plan_sorts_by_day` | Meal plan sorted by date, filters from yesterday | | tests/test_grocy_data.py | `test_async_update_meal_plan_empty` | Empty meal plan handled | +| tests/test_grocy_data.py | `test_async_update_recipes` | Recipes data fetching and wrapping with fulfillment | | tests/test_helpers.py | `test_meal_plan_item_wrapper_generates_picture_url` | Wrapper generates correct picture URL | | tests/test_helpers.py | `test_meal_plan_item_wrapper_handles_missing_picture` | Wrapper handles None picture | diff --git a/docs/test-feature-map.yaml b/docs/test-feature-map.yaml index d5d0c90..27d9aea 100644 --- a/docs/test-feature-map.yaml +++ b/docs/test-feature-map.yaml @@ -173,6 +173,7 @@ features: - file: tests/test_entities.py functions: - test_sensor_meal_plan_counts + - test_sensor_recipes_counts - file: tests/test_services.py functions: - test_consume_recipe_service @@ -187,6 +188,7 @@ features: functions: - test_async_update_meal_plan_sorts_by_day - test_async_update_meal_plan_empty + - test_async_update_recipes - file: tests/test_helpers.py functions: - test_meal_plan_item_wrapper_generates_picture_url diff --git a/tests/test_entities.py b/tests/test_entities.py index 15b2407..258e096 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -33,6 +33,7 @@ ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, + ATTR_RECIPES, ) from custom_components.grocy.coordinator import GrocyCoordinatorData from custom_components.grocy.entity import GrocyEntity @@ -193,6 +194,19 @@ def test_sensor_meal_plan_counts() -> None: assert "meals" in attrs +@pytest.mark.feature("meal_planning") +def test_sensor_recipes_counts() -> None: + """Verify recipes sensor counts correctly.""" + from custom_components.grocy.helpers import RecipeWrapper + recipe_wrap = RecipeWrapper({"id": 1, "name": "Pizza", "picture_file_name": "pizza.jpg"}, True) + entity = _build_sensor(ATTR_RECIPES, [recipe_wrap]) + assert entity.native_value == 1 + attrs = entity.extra_state_attributes + assert attrs["count"] == 1 + assert "recipes" in attrs + assert attrs["recipes"][0]["all_ingredients_in_stock"] is True + + @pytest.mark.feature("shopping_list") def test_sensor_shopping_list_counts() -> None: """Verify shopping list sensor counts items.""" diff --git a/tests/test_grocy_data.py b/tests/test_grocy_data.py index 00eb803..29705b9 100644 --- a/tests/test_grocy_data.py +++ b/tests/test_grocy_data.py @@ -28,6 +28,7 @@ ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, + ATTR_RECIPES, CONF_API_KEY, CONF_PORT, CONF_URL, @@ -552,9 +553,54 @@ def test_picture_view_url_pattern() -> None: # ─── All entity keys are mapped ────────────────────────────────────────────── +# ─── async_update_recipes ─────────────────────────────────────────────── + + +@pytest.mark.feature("meal_planning") +@pytest.mark.asyncio +async def test_async_update_recipes(grocy_data) -> None: + """Verify recipe data fetching and wrapping.""" + from grocy.grocy_api_client import RecipeFulfillmentResponse + from grocy.data_models.generic import EntityType + + recipe_dict = { + "id": 1, + "name": "Pizza", + "description": "Tasty", + "picture_file_name": "pizza.jpg", + } + grocy_data.api.generic.list.return_value = [recipe_dict] + + fulfillment_response = RecipeFulfillmentResponse( + recipe_id=1, + need_fulfilled=True, + need_fulfilled_with_shopping_list=False, + ) + grocy_data.api.recipes.all_fulfillment.return_value = [fulfillment_response] + + result = await grocy_data.async_update_recipes() + + assert len(result) == 1 + wrapped = result[0] + assert wrapped.recipe == recipe_dict + assert wrapped.all_ingredients_in_stock is True + assert wrapped.picture_url == "/api/grocy/recipepictures/cGl6emEuanBn" + assert wrapped.as_dict() == { + "id": 1, + "name": "Pizza", + "description": "Tasty", + "picture_file_name": "pizza.jpg", + "all_ingredients_in_stock": True, + "picture_url": "/api/grocy/recipepictures/cGl6emEuanBn", + } + + grocy_data.api.generic.list.assert_called_once_with(EntityType.RECIPES) + grocy_data.api.recipes.all_fulfillment.assert_called_once() + + @pytest.mark.feature("cross_cutting") def test_all_entity_keys_have_update_methods(hass, mock_grocy) -> None: - """Verify all 13 entity keys mapped to update methods.""" + """Verify all 14 entity keys mapped to update methods.""" hass.async_add_executor_job = AsyncMock() data = GrocyData(hass, mock_grocy) expected_keys = { @@ -571,5 +617,6 @@ def test_all_entity_keys_have_update_methods(hass, mock_grocy) -> None: ATTR_OVERDUE_TASKS, ATTR_BATTERIES, ATTR_OVERDUE_BATTERIES, + ATTR_RECIPES, } assert set(data.entity_update_method.keys()) == expected_keys diff --git a/tests/test_init.py b/tests/test_init.py index 04fd2de..95706d8 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -33,6 +33,7 @@ ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, + ATTR_RECIPES, DOMAIN, PLATFORMS, ) @@ -190,6 +191,7 @@ async def test_available_entities_all_features() -> None: ATTR_CHORES, ATTR_OVERDUE_CHORES, ATTR_MEAL_PLAN, + ATTR_RECIPES, ATTR_BATTERIES, ATTR_OVERDUE_BATTERIES, } @@ -239,7 +241,7 @@ async def test_available_entities_recipes_only() -> None: grocy_data = _make_grocy_data({"FEATURE_FLAG_RECIPES"}) result = await _async_get_available_entities(grocy_data) - assert result == [ATTR_MEAL_PLAN] + assert set(result) == {ATTR_MEAL_PLAN, ATTR_RECIPES} @pytest.mark.asyncio From 7d86324d5c5dda9b630ad75f52c72d68b1f44c4a Mon Sep 17 00:00:00 2001 From: detobel36 Date: Sun, 19 Jul 2026 16:45:57 +0200 Subject: [PATCH 2/2] Add URL to receipe --- custom_components/grocy/coordinator.py | 2 +- custom_components/grocy/grocy_data.py | 7 +++++-- custom_components/grocy/helpers.py | 16 +++++++++++++++- tests/test_entities.py | 2 +- tests/test_grocy_data.py | 7 +++++-- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/custom_components/grocy/coordinator.py b/custom_components/grocy/coordinator.py index aec8113..2331d98 100644 --- a/custom_components/grocy/coordinator.py +++ b/custom_components/grocy/coordinator.py @@ -82,7 +82,7 @@ def __init__( self.grocy_api = Grocy( base_url, api_key, path=path, port=port, verify_ssl=verify_ssl ) - self.grocy_data = GrocyData(hass, self.grocy_api) + self.grocy_data = GrocyData(hass, self.grocy_api, url) self.available_entities: list[str] = [] self.entities: list[Entity] = [] diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index 2ae8d35..f98e913 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -45,7 +45,7 @@ class GrocyData: """Handles communication and gets the data.""" - def __init__(self, hass: HomeAssistant, api: Grocy) -> None: + def __init__(self, hass: HomeAssistant, api: Grocy, grocy_url: str) -> None: """Initialize Grocy data.""" self.hass = hass self.api = api @@ -66,6 +66,7 @@ def __init__(self, hass: HomeAssistant, api: Grocy) -> None: ATTR_OVERDUE_BATTERIES: self.async_update_overdue_batteries, ATTR_RECIPES: self.async_update_recipes, } + self._grocy_url = grocy_url async def async_update_data(self, entity_key): """Update data.""" @@ -271,7 +272,9 @@ def wrapper() -> list[RecipeWrapper]: for r in recipes: recipe_id = r.get("id") need_fulfilled = fulfillment_map.get(recipe_id, False) - wrapped_recipes.append(RecipeWrapper(r, need_fulfilled)) + recipe_wrapper = RecipeWrapper(r, need_fulfilled, self._grocy_url) + if recipe_wrapper.recipe["type"] == "normal": + wrapped_recipes.append(recipe_wrapper) return wrapped_recipes return await self.hass.async_add_executor_job(wrapper) diff --git a/custom_components/grocy/helpers.py b/custom_components/grocy/helpers.py index e7d7364..ea4a047 100644 --- a/custom_components/grocy/helpers.py +++ b/custom_components/grocy/helpers.py @@ -19,9 +19,12 @@ def extract_base_url_and_path(url: str) -> tuple[str, str]: class RecipeWrapper: """Wrapper around a grocy Recipe dictionary with fulfillment information.""" - def __init__(self, recipe: dict[str, Any], need_fulfilled: bool) -> None: + def __init__( + self, recipe: dict[str, Any], need_fulfilled: bool, grocy_url: str + ) -> None: self._recipe = recipe self._need_fulfilled = need_fulfilled + self._grocy_url = grocy_url @property def recipe(self) -> dict[str, Any]: @@ -42,11 +45,22 @@ def picture_url(self) -> str | None: return f"/api/grocy/recipepictures/{str(b64name, 'utf-8')}" return None + @property + def url(self) -> str | None: + """Return the recipe link URL.""" + return ( + self._grocy_url + + "/recipes?recipe=" + + str(self._recipe.get("id")) + + "#fullscreen" + ) + def as_dict(self) -> dict[str, Any]: """Return serialized attributes including the proxy picture URL and in stock status.""" props = dict(self._recipe) props["all_ingredients_in_stock"] = self.all_ingredients_in_stock props["picture_url"] = self.picture_url + props["url"] = self.url return props diff --git a/tests/test_entities.py b/tests/test_entities.py index 258e096..5602aee 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -198,7 +198,7 @@ def test_sensor_meal_plan_counts() -> None: def test_sensor_recipes_counts() -> None: """Verify recipes sensor counts correctly.""" from custom_components.grocy.helpers import RecipeWrapper - recipe_wrap = RecipeWrapper({"id": 1, "name": "Pizza", "picture_file_name": "pizza.jpg"}, True) + recipe_wrap = RecipeWrapper({"id": 1, "name": "Pizza", "picture_file_name": "pizza.jpg"}, True, "http://example.com/") entity = _build_sensor(ATTR_RECIPES, [recipe_wrap]) assert entity.native_value == 1 attrs = entity.extra_state_attributes diff --git a/tests/test_grocy_data.py b/tests/test_grocy_data.py index 29705b9..54e8e6c 100644 --- a/tests/test_grocy_data.py +++ b/tests/test_grocy_data.py @@ -54,7 +54,7 @@ async def immediate_executor(func, *args): return func(*args) hass.async_add_executor_job = AsyncMock(side_effect=immediate_executor) - return GrocyData(hass, mock_grocy) + return GrocyData(hass, mock_grocy, "https://exemple.com") # ─── async_update_data dispatch ─────────────────────────────────────────────── @@ -568,6 +568,7 @@ async def test_async_update_recipes(grocy_data) -> None: "name": "Pizza", "description": "Tasty", "picture_file_name": "pizza.jpg", + "type": "normal" } grocy_data.api.generic.list.return_value = [recipe_dict] @@ -592,6 +593,8 @@ async def test_async_update_recipes(grocy_data) -> None: "picture_file_name": "pizza.jpg", "all_ingredients_in_stock": True, "picture_url": "/api/grocy/recipepictures/cGl6emEuanBn", + "type": "normal", + "url": "https://exemple.com/recipes?recipe=1#fullscreen", } grocy_data.api.generic.list.assert_called_once_with(EntityType.RECIPES) @@ -602,7 +605,7 @@ async def test_async_update_recipes(grocy_data) -> None: def test_all_entity_keys_have_update_methods(hass, mock_grocy) -> None: """Verify all 14 entity keys mapped to update methods.""" hass.async_add_executor_job = AsyncMock() - data = GrocyData(hass, mock_grocy) + data = GrocyData(hass, mock_grocy, "https://exemple.com") expected_keys = { ATTR_STOCK, ATTR_CHORES,