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..2331d98 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 @@ -81,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 14ba62d..f98e913 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__) @@ -43,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 @@ -62,7 +64,9 @@ 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, } + self._grocy_url = grocy_url async def async_update_data(self, entity_key): """Update data.""" @@ -255,6 +259,26 @@ 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) + 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) + 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..ea4a047 100644 --- a/custom_components/grocy/helpers.py +++ b/custom_components/grocy/helpers.py @@ -16,6 +16,54 @@ 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, grocy_url: str + ) -> None: + self._recipe = recipe + self._need_fulfilled = need_fulfilled + self._grocy_url = grocy_url + + @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 + + @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 + + 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..5602aee 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, "http://example.com/") + 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..54e8e6c 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, @@ -53,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 ─────────────────────────────────────────────── @@ -552,11 +553,59 @@ 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", + "type": "normal" + } + 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", + "type": "normal", + "url": "https://exemple.com/recipes?recipe=1#fullscreen", + } + + 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) + data = GrocyData(hass, mock_grocy, "https://exemple.com") expected_keys = { ATTR_STOCK, ATTR_CHORES, @@ -571,5 +620,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