"""Client API pour le serveur CookCLI. Endpoints CookCLI utilisés : - GET /api/recipes -> arborescence complète des recettes - GET /api/recipes/{path} -> détail d'une recette (ingredients, steps, metadata) Format réel confirmé pour GET /api/recipes (arborescence récursive) : { "name": "recipes", "path": "/config/recipes", "recipe": null, "children": { "Chocolat chaud": { "name": "Chocolat chaud", "path": "/config/recipes/chocolat-chaud.cook", "recipe": {"metadata": {...}, "source": {...}}, "children": {} }, "example": { "name": "example", "path": "/config/recipes/example", "recipe": null, "children": { ... sous-dossiers/recettes imbriqués ... } } } } Un nœud est une recette si sa clé "recipe" n'est pas null ; sinon c'est un dossier à parcourir récursivement via "children". `_normalize_recipe_list` aplatit cette arborescence en une liste de `RecipeSummary`, avec un `path` relatif au dossier racine (ex: "chocolat-chaud.cook", "example/sauces/Hollandaise.cook") pour correspondre à ce qu'attend GET /api/recipes/{path}. Hypothèse à vérifier : ce path relatif inclut l'extension ".cook", ce qui correspond au path filesystem brut renvoyé par le serveur. Si GET /api/recipes/{path} attend un format différent (sans extension, slugifié, etc.), ajuste `async_get_recipe` en conséquence. Format réel confirmé pour GET /api/recipes/{path} (détail d'une recette) : la réponse encapsule un objet `recipe` du parser cooklang-rs, où les listes `ingredients`/`cookware`/`timers` sont à plat, et les étapes (`recipe.sections[].content[].value.items[]`) référencent ces objets **par index** plutôt que de les inclure directement (ex: `{"type": "ingredient", "index": 0}`). `async_get_recipe` résout ces références côté intégration avant de renvoyer les données, pour éviter que la carte Lovelace (ou tout autre consommateur) ait à recroiser les index elle-même. Les valeurs numériques suivent aussi un format imbriqué (`{"type": "number", "value": {"type": "regular", "value": 1.0}}`). Seul le sous-type "regular" a été observé en pratique ; les sous-types "fraction" ou similaires (non confirmés) sont retournés bruts par `_resolve_value` en cas de format inconnu, plutôt que de faire planter la résolution. """ from __future__ import annotations import asyncio import logging from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any import aiohttp _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10) class CookCliApiError(Exception): """Erreur générique lors d'un appel à l'API CookCLI.""" class CookCliConnectionError(CookCliApiError): """Le serveur CookCLI est injoignable.""" class CookCliResponseError(CookCliApiError): """Le serveur a répondu avec un statut d'erreur ou un contenu invalide.""" @dataclass class RecipeSummary: """Représentation minimale d'une recette pour la liste navigable.""" path: str name: str raw: dict[str, Any] = field(default_factory=dict) class CookCliApiClient: """Wrapper HTTP autour de l'API CookCLI.""" def __init__(self, session: aiohttp.ClientSession, host: str, port: int) -> None: self._session = session self._base_url = f"http://{host}:{port}/api" async def async_test_connection(self) -> None: """Vérifie que le serveur répond, utilisé par le config_flow.""" await self.async_list_recipes() async def async_list_recipes(self) -> list[RecipeSummary]: """Récupère la liste des recettes disponibles.""" data = await self._request_json("/recipes") return self._normalize_recipe_list(data) async def async_get_recipe(self, path: str) -> dict[str, Any]: """Récupère le détail d'une recette, avec étapes déjà résolues. La réponse brute de CookCLI référence les ingrédients/ustensiles/ minuteurs par index dans les étapes ; ce qui est renvoyé ici a déjà remplacé ces index par les objets correspondants (voir `_resolve_recipe`). """ data = await self._request_json(f"/recipes/{path}") return _resolve_recipe(data) async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]: """Récupère les octets d'une image statique CookCLI. `image_ref` peut prendre trois formes selon d'où il vient : - "/api/static/img/x.jpg" : valeur brute du champ "image" du détail d'une recette (chemin déjà préfixé par /api). - "/img/x.jpg" ou "img/x.jpg" : valeur brute de metadata.image dans l'arborescence de /api/recipes (chemin relatif, avec ou sans "/" initial selon comment la recette a été écrite). Ces trois formes ont été observées dans des réponses réelles du serveur (pas juste supposées) ; `_image_endpoint` les normalise toutes vers /static/... — à vérifier visuellement une fois la carte rechargée, au cas où une recette aurait un format différent. """ endpoint = self._image_endpoint(image_ref) async with self._get(endpoint) as resp: content_type = resp.headers.get("Content-Type") data = await resp.read() return data, content_type @staticmethod def _image_endpoint(image_ref: str) -> str: if image_ref.startswith("/api/"): return image_ref[len("/api") :] if image_ref.startswith("/"): return f"/static{image_ref}" return f"/static/{image_ref}" @asynccontextmanager async def _get(self, endpoint: str): """Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs.""" url = f"{self._base_url}{endpoint}" try: async with self._session.request( "GET", url, timeout=DEFAULT_TIMEOUT ) as resp: if resp.status >= 400: raise CookCliResponseError( f"CookCLI a répondu {resp.status} pour {url}" ) yield resp except asyncio.TimeoutError as err: raise CookCliConnectionError(f"Timeout en contactant {url}") from err except aiohttp.ClientError as err: raise CookCliConnectionError(f"Impossible de joindre {url}: {err}") from err async def _request_json(self, endpoint: str) -> Any: async with self._get(endpoint) as resp: try: return await resp.json() except (aiohttp.ContentTypeError, ValueError) as err: raise CookCliResponseError( f"Réponse non-JSON de CookCLI pour {endpoint}" ) from err @staticmethod def _normalize_recipe_list(data: Any) -> list[RecipeSummary]: """Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate.""" if not isinstance(data, dict): _LOGGER.warning( "Format de /api/recipes inattendu (attendu un objet, reçu %s)", type(data), ) return [] root_path = data.get("path", "") summaries: list[RecipeSummary] = [] CookCliApiClient._walk_recipe_tree(data, root_path, summaries) return summaries @staticmethod def _walk_recipe_tree( node: dict[str, Any], root_path: str, out: list[RecipeSummary] ) -> None: """Parcourt récursivement un nœud de l'arborescence de recettes.""" recipe = node.get("recipe") if recipe is not None: abs_path = node.get("path", "") rel_path = abs_path if root_path and abs_path.startswith(root_path): rel_path = abs_path[len(root_path) :].lstrip("/") name = node.get("name") or _name_from_path(rel_path) out.append(RecipeSummary(path=rel_path, name=name, raw=node)) for child in (node.get("children") or {}).values(): CookCliApiClient._walk_recipe_tree(child, root_path, out) def _name_from_path(path: str) -> str: """Dérive un nom lisible depuis un chemin de fichier .cook.""" return path.rsplit("/", 1)[-1].removesuffix(".cook") # --------------------------------------------------------------------------- # Résolution du détail d'une recette : remplace les références par index # (ingredient/cookware/timer) par les objets correspondants, et aplatit les # valeurs numériques imbriquées du parser cooklang-rs. # --------------------------------------------------------------------------- def _resolve_number(number_obj: Any) -> Any: """Résout un objet 'number' cooklang-rs en valeur simple. Seul le sous-type "regular" (nombre décimal simple) a été observé en pratique. Les autres sous-types (fractions, valeurs mixtes...) ne sont pas confirmés : on renvoie l'objet brut plutôt que de deviner un format. """ if not isinstance(number_obj, dict): return number_obj if number_obj.get("type") == "regular": return number_obj.get("value") _LOGGER.debug("Sous-type de nombre non géré, valeur brute conservée: %s", number_obj) return number_obj def _resolve_value(value_obj: Any) -> Any: """Résout un objet 'value' générique (nombre, texte...) en valeur simple.""" if not isinstance(value_obj, dict): return value_obj value_type = value_obj.get("type") inner = value_obj.get("value") if value_type == "number": return _resolve_number(inner) if value_type == "text": return inner _LOGGER.debug("Type de valeur non géré, valeur brute conservée: %s", value_obj) return inner if inner is not None else value_obj def _resolve_quantity(quantity_obj: Any) -> dict[str, Any] | None: """Résout une quantité (ingredient/cookware/timer) en {value, unit}.""" if not isinstance(quantity_obj, dict): return None return { "value": _resolve_value(quantity_obj.get("value")), "unit": quantity_obj.get("unit"), } def _resolve_ingredient(ingredient: dict[str, Any]) -> dict[str, Any]: return { "name": ingredient.get("name"), "quantity": _resolve_quantity(ingredient.get("quantity")), "note": ingredient.get("note"), } def _resolve_cookware(cookware: dict[str, Any]) -> dict[str, Any]: return { "name": cookware.get("name"), "quantity": _resolve_quantity(cookware.get("quantity")), "note": cookware.get("note"), } def _resolve_timer(timer: dict[str, Any]) -> dict[str, Any]: quantity = timer.get("quantity") or {} return { "name": timer.get("name"), "duration": _resolve_value(quantity.get("value")), "unit": quantity.get("unit"), } def _resolve_step_item( item: dict[str, Any], ingredients: list[dict[str, Any]], cookware: list[dict[str, Any]], timers: list[dict[str, Any]], ) -> dict[str, Any]: """Remplace un item d'étape référencé par index par l'objet résolu.""" item_type = item.get("type") if item_type == "text": return {"type": "text", "value": item.get("value", "")} lookup = {"ingredient": ingredients, "cookware": cookware, "timer": timers} if item_type in lookup: index = item.get("index") objects = lookup[item_type] resolved = objects[index] if index is not None and 0 <= index < len(objects) else {} if not resolved: _LOGGER.debug( "Index %s introuvable pour un item de type %s", index, item_type ) return {"type": item_type, **resolved} _LOGGER.debug("Type d'item d'étape non géré, conservé brut: %s", item_type) return {"type": item_type, "raw": item} def _resolve_recipe(data: dict[str, Any]) -> dict[str, Any]: """Transforme la réponse brute de GET /api/recipes/{path} en structure directement exploitable (titre, ingrédients, étapes avec items résolus). """ recipe = data.get("recipe") or {} metadata = (recipe.get("metadata") or {}).get("map") or {} ingredients = [_resolve_ingredient(i) for i in recipe.get("ingredients", [])] cookware = [_resolve_cookware(c) for c in recipe.get("cookware", [])] timers = [_resolve_timer(t) for t in recipe.get("timers", [])] sections: list[dict[str, Any]] = [] for section in recipe.get("sections", []): steps: list[dict[str, Any]] = [] for content_item in section.get("content", []): if content_item.get("type") != "step": _LOGGER.debug( "Type de contenu de section ignoré: %s", content_item.get("type") ) continue step_value = content_item.get("value") or {} items = [ _resolve_step_item(it, ingredients, cookware, timers) for it in step_value.get("items", []) ] steps.append({"number": step_value.get("number"), "items": items}) sections.append({"name": section.get("name"), "steps": steps}) return { "title": metadata.get("title"), "image": data.get("image"), "scale": data.get("scale"), "metadata": metadata, "ingredients": ingredients, "cookware": cookware, "sections": sections, }