Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions custom_components/grocy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ATTR_OVERDUE_CHORES,
ATTR_OVERDUE_PRODUCTS,
ATTR_OVERDUE_TASKS,
ATTR_RECIPES,
ATTR_SHOPPING_LIST,
ATTR_STOCK,
ATTR_TASKS,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions custom_components/grocy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -51,3 +52,4 @@
ATTR_SHOPPING_LIST: Final = "shopping_list"
ATTR_STOCK: Final = "stock"
ATTR_TASKS: Final = "tasks"
ATTR_RECIPES: Final = "recipes"
5 changes: 3 additions & 2 deletions custom_components/grocy/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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
Expand Down Expand Up @@ -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] = []
Expand Down
28 changes: 26 additions & 2 deletions custom_components/grocy/grocy_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,22 +29,23 @@
ATTR_OVERDUE_CHORES,
ATTR_OVERDUE_PRODUCTS,
ATTR_OVERDUE_TASKS,
ATTR_RECIPES,
ATTR_SHOPPING_LIST,
ATTR_STOCK,
ATTR_TASKS,
CONF_API_KEY,
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__)


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
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions custom_components/grocy/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
14 changes: 14 additions & 0 deletions custom_components/grocy/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ATTR_BATTERIES,
ATTR_CHORES,
ATTR_MEAL_PLAN,
ATTR_RECIPES,
ATTR_SHOPPING_LIST,
ATTR_STOCK,
ATTR_TASKS,
Expand All @@ -28,6 +29,7 @@
ITEMS,
MEAL_PLANS,
PRODUCTS,
RECIPES,
TASKS,
)
from .coordinator import GrocyCoordinatorData, GrocyDataUpdateCoordinator
Expand Down Expand Up @@ -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),
},
),
)


Expand Down
3 changes: 3 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
2 changes: 2 additions & 0 deletions docs/test-feature-map.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
56 changes: 53 additions & 3 deletions tests/test_grocy_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ATTR_SHOPPING_LIST,
ATTR_STOCK,
ATTR_TASKS,
ATTR_RECIPES,
CONF_API_KEY,
CONF_PORT,
CONF_URL,
Expand All @@ -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 ───────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand All @@ -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
4 changes: 3 additions & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
ATTR_SHOPPING_LIST,
ATTR_STOCK,
ATTR_TASKS,
ATTR_RECIPES,
DOMAIN,
PLATFORMS,
)
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down
Loading