Compare commits
4 commits
1156722b4d
...
eebe1bbd7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebe1bbd7e | ||
|
|
aca61a9069 | ||
|
|
a7e4da5523 | ||
|
|
53fc14dbef |
|
|
@ -12,6 +12,7 @@ from .api import CookCliApiClient, CookCliApiError
|
||||||
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
|
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
|
||||||
from .coordinator import CookCliCoordinator
|
from .coordinator import CookCliCoordinator
|
||||||
from .websocket_api import async_setup_websocket_api
|
from .websocket_api import async_setup_websocket_api
|
||||||
|
from .image_proxy import CookCliImageView
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,60 @@
|
||||||
"""Client API pour le serveur CookCLI.
|
"""Client API pour le serveur CookCLI.
|
||||||
|
|
||||||
Endpoints CookCLI utilisés :
|
Endpoints CookCLI utilisés :
|
||||||
- GET /api/recipes -> liste/arborescence des recettes
|
- GET /api/recipes -> arborescence complète des recettes
|
||||||
- GET /api/recipes/{path} -> détail d'une recette (ingredients, steps, metadata)
|
- GET /api/recipes/{path} -> détail d'une recette (ingredients, steps, metadata)
|
||||||
|
|
||||||
Le endpoint de liste n'a pas pu être vérifié à 100% depuis les sources au
|
Format réel confirmé pour GET /api/recipes (arborescence récursive) :
|
||||||
moment de l'écriture de ce client : le format exact (liste plate vs
|
|
||||||
arborescence de dossiers) doit être confirmé avec un `curl` réel contre le
|
|
||||||
serveur, ex :
|
|
||||||
|
|
||||||
curl http://<ip-addon>:9081/api/recipes | jq
|
{
|
||||||
|
"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 ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
`_normalize_recipe_list` ci-dessous encaisse les deux formes les plus
|
Un nœud est une recette si sa clé "recipe" n'est pas null ; sinon c'est un
|
||||||
probables ; ajuste-le si le format réel diffère.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -63,65 +99,249 @@ class CookCliApiClient:
|
||||||
|
|
||||||
async def async_list_recipes(self) -> list[RecipeSummary]:
|
async def async_list_recipes(self) -> list[RecipeSummary]:
|
||||||
"""Récupère la liste des recettes disponibles."""
|
"""Récupère la liste des recettes disponibles."""
|
||||||
data = await self._request("GET", "/recipes")
|
data = await self._request_json("/recipes")
|
||||||
return self._normalize_recipe_list(data)
|
return self._normalize_recipe_list(data)
|
||||||
|
|
||||||
async def async_get_recipe(self, path: str) -> dict[str, Any]:
|
async def async_get_recipe(self, path: str) -> dict[str, Any]:
|
||||||
"""Récupère le détail complet d'une recette (ingredients + steps)."""
|
"""Récupère le détail d'une recette, avec étapes déjà résolues.
|
||||||
return await self._request("GET", f"/recipes/{path}")
|
|
||||||
|
|
||||||
async def _request(self, method: str, endpoint: str) -> Any:
|
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}"
|
url = f"{self._base_url}{endpoint}"
|
||||||
try:
|
try:
|
||||||
async with self._session.request(
|
async with self._session.request(
|
||||||
method, url, timeout=DEFAULT_TIMEOUT
|
"GET", url, timeout=DEFAULT_TIMEOUT
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status >= 400:
|
if resp.status >= 400:
|
||||||
raise CookCliResponseError(
|
raise CookCliResponseError(
|
||||||
f"CookCLI a répondu {resp.status} pour {url}"
|
f"CookCLI a répondu {resp.status} pour {url}"
|
||||||
)
|
)
|
||||||
try:
|
yield resp
|
||||||
return await resp.json()
|
|
||||||
except (aiohttp.ContentTypeError, ValueError) as err:
|
|
||||||
raise CookCliResponseError(
|
|
||||||
f"Réponse non-JSON de CookCLI pour {url}"
|
|
||||||
) from err
|
|
||||||
except asyncio.TimeoutError as err:
|
except asyncio.TimeoutError as err:
|
||||||
raise CookCliConnectionError(f"Timeout en contactant {url}") from err
|
raise CookCliConnectionError(f"Timeout en contactant {url}") from err
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
raise CookCliConnectionError(f"Impossible de joindre {url}: {err}") from 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
|
@staticmethod
|
||||||
def _normalize_recipe_list(data: Any) -> list[RecipeSummary]:
|
def _normalize_recipe_list(data: Any) -> list[RecipeSummary]:
|
||||||
"""Normalise la réponse de listing en une liste plate de RecipeSummary.
|
"""Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
Encaisse plusieurs formes possibles :
|
_LOGGER.warning(
|
||||||
- une liste de chemins/objets directement
|
"Format de /api/recipes inattendu (attendu un objet, reçu %s)",
|
||||||
- un dict avec une clé "recipes" ou "index"
|
type(data),
|
||||||
- une arborescence imbriquée par dossier
|
)
|
||||||
"""
|
return []
|
||||||
items: list[Any]
|
|
||||||
if isinstance(data, list):
|
|
||||||
items = data
|
|
||||||
elif isinstance(data, dict):
|
|
||||||
items = data.get("recipes") or data.get("index") or []
|
|
||||||
else:
|
|
||||||
items = []
|
|
||||||
|
|
||||||
|
root_path = data.get("path", "")
|
||||||
summaries: list[RecipeSummary] = []
|
summaries: list[RecipeSummary] = []
|
||||||
for item in items:
|
CookCliApiClient._walk_recipe_tree(data, root_path, summaries)
|
||||||
if isinstance(item, str):
|
|
||||||
summaries.append(RecipeSummary(path=item, name=_name_from_path(item)))
|
|
||||||
elif isinstance(item, dict):
|
|
||||||
path = item.get("path") or item.get("slug") or item.get("name", "")
|
|
||||||
name = item.get("name") or _name_from_path(path)
|
|
||||||
summaries.append(RecipeSummary(path=path, name=name, raw=item))
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("Entrée de recette ignorée (format inattendu): %s", item)
|
|
||||||
|
|
||||||
return 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:
|
def _name_from_path(path: str) -> str:
|
||||||
"""Dérive un nom lisible depuis un chemin de fichier .cook."""
|
"""Dérive un nom lisible depuis un chemin de fichier .cook."""
|
||||||
return path.rsplit("/", 1)[-1].removesuffix(".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,
|
||||||
|
}
|
||||||
|
|
|
||||||
52
custom_components/cookcli/image_proxy.py
Normal file
52
custom_components/cookcli/image_proxy.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Proxy HTTP pour les images statiques de CookCLI.
|
||||||
|
|
||||||
|
Le navigateur ne peut pas atteindre le serveur CookCLI directement (ni le
|
||||||
|
port ingress 9080, restreint à l'IP du Supervisor par nginx, ni le port
|
||||||
|
direct 9081, non publié sur le LAN). Seul le backend Home Assistant, sur le
|
||||||
|
même réseau Docker interne que l'add-on, peut le faire. Cette vue relaie
|
||||||
|
donc les octets de l'image en passant par ce backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .api import CookCliApiError
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CookCliImageView(HomeAssistantView):
|
||||||
|
"""GET /api/cookcli/image/{entry_id}/{image_ref} -> octets de l'image."""
|
||||||
|
|
||||||
|
url = "/api/cookcli/image/{entry_id}/{image_ref:.*}"
|
||||||
|
name = "api:cookcli:image"
|
||||||
|
requires_auth = True
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self, request: web.Request, entry_id: str, image_ref: str
|
||||||
|
) -> web.Response:
|
||||||
|
entry_data = self.hass.data.get(DOMAIN, {}).get(entry_id)
|
||||||
|
if entry_data is None:
|
||||||
|
return web.Response(status=404, text="Intégration CookCLI introuvable")
|
||||||
|
|
||||||
|
client = entry_data["client"]
|
||||||
|
try:
|
||||||
|
data, content_type = await client.async_get_image(image_ref)
|
||||||
|
except CookCliApiError as err:
|
||||||
|
_LOGGER.debug("Échec de récupération de l'image %s: %s", image_ref, err)
|
||||||
|
return web.Response(status=502, text="Impossible de récupérer l'image")
|
||||||
|
|
||||||
|
return web.Response(
|
||||||
|
body=data,
|
||||||
|
content_type=content_type or "application/octet-stream",
|
||||||
|
headers={"Cache-Control": "max-age=3600"},
|
||||||
|
)
|
||||||
|
|
@ -6,10 +6,17 @@ Deux commandes :
|
||||||
|
|
||||||
Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser
|
Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser
|
||||||
lequel interroger ; sinon, le premier configuré est utilisé.
|
lequel interroger ; sinon, le premier configuré est utilisé.
|
||||||
|
|
||||||
|
Les deux commandes ajoutent un champ "image_url" déjà prêt à l'emploi pour
|
||||||
|
un <img src="...">, qui pointe vers CookCliImageView (voir image_proxy.py) —
|
||||||
|
le frontend n'a pas besoin de connaître le format brut des chemins d'image
|
||||||
|
renvoyés par CookCLI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
@ -18,13 +25,22 @@ from .api import CookCliApiError
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | None:
|
def _resolve_entry(
|
||||||
|
hass: HomeAssistant, entry_id: str | None
|
||||||
|
) -> tuple[str | None, dict | None]:
|
||||||
entries = hass.data.get(DOMAIN, {})
|
entries = hass.data.get(DOMAIN, {})
|
||||||
if entry_id is not None:
|
if entry_id is not None:
|
||||||
return entries.get(entry_id)
|
return entry_id, entries.get(entry_id)
|
||||||
if entries:
|
if entries:
|
||||||
return next(iter(entries.values()))
|
first_id, first_data = next(iter(entries.items()))
|
||||||
return None
|
return first_id, first_data
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _image_url(entry_id: str, image_ref: str | None) -> str | None:
|
||||||
|
if not image_ref:
|
||||||
|
return None
|
||||||
|
return f"/api/cookcli/image/{entry_id}/{quote(image_ref, safe='/')}"
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
|
|
@ -37,20 +53,26 @@ def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | Non
|
||||||
async def ws_list_recipes(
|
async def ws_list_recipes(
|
||||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
|
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id"))
|
||||||
if entry_data is None:
|
if entry_data is None:
|
||||||
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
|
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
|
||||||
return
|
return
|
||||||
|
|
||||||
coordinator = entry_data["coordinator"]
|
coordinator = entry_data["coordinator"]
|
||||||
connection.send_result(
|
recipes = []
|
||||||
msg["id"],
|
for r in coordinator.data or []:
|
||||||
{
|
metadata = ((r.raw or {}).get("recipe") or {}).get("metadata") or {}
|
||||||
"recipes": [
|
recipes.append(
|
||||||
{"path": r.path, "name": r.name} for r in coordinator.data or []
|
{
|
||||||
]
|
"path": r.path,
|
||||||
},
|
"name": r.name,
|
||||||
)
|
"time": metadata.get("time"),
|
||||||
|
"servings": metadata.get("servings"),
|
||||||
|
"tags": metadata.get("tags", []),
|
||||||
|
"image_url": _image_url(entry_id, metadata.get("image")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
connection.send_result(msg["id"], {"recipes": recipes})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
|
|
@ -64,7 +86,7 @@ async def ws_list_recipes(
|
||||||
async def ws_get_recipe(
|
async def ws_get_recipe(
|
||||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
|
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id"))
|
||||||
if entry_data is None:
|
if entry_data is None:
|
||||||
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
|
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
|
||||||
return
|
return
|
||||||
|
|
@ -76,6 +98,7 @@ async def ws_get_recipe(
|
||||||
connection.send_error(msg["id"], "cannot_connect", str(err))
|
connection.send_error(msg["id"], "cannot_connect", str(err))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
recipe["image_url"] = _image_url(entry_id, recipe.get("image"))
|
||||||
connection.send_result(msg["id"], recipe)
|
connection.send_result(msg["id"], recipe)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
307
www/cookcli-card.js
Normal file
307
www/cookcli-card.js
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
/**
|
||||||
|
* Carte Lovelace pour l'intégration CookCLI.
|
||||||
|
*
|
||||||
|
* Consomme les commandes websocket exposées par l'intégration :
|
||||||
|
* - cookcli/recipes -> liste (path, name, time, servings, tags)
|
||||||
|
* - cookcli/recipe -> détail (title, ingredients, cookware, sections)
|
||||||
|
*
|
||||||
|
* Limitation connue : les images de recettes ne sont pas affichées. Le
|
||||||
|
* serveur CookCLI n'est joignable depuis le navigateur ni sur son port
|
||||||
|
* ingress (restreint au Supervisor) ni sur son port direct (non exposé au
|
||||||
|
* LAN) — il faudrait un endpoint de proxy côté intégration pour les servir.
|
||||||
|
* Voir le README du projet pour plus de détails si tu veux l'ajouter.
|
||||||
|
*
|
||||||
|
* Installation :
|
||||||
|
* 1. Copie ce fichier dans config/www/cookcli-card.js
|
||||||
|
* 2. Paramètres -> Tableaux de bord -> menu ⋮ -> Ressources -> Ajouter :
|
||||||
|
* URL "/local/cookcli-card.js", type "Module JavaScript"
|
||||||
|
* 3. Ajoute une carte manuelle sur un tableau de bord :
|
||||||
|
* type: custom:cookcli-card
|
||||||
|
* title: Mes recettes
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CookCliCard extends HTMLElement {
|
||||||
|
static getStubConfig() {
|
||||||
|
return { title: "Recettes" };
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig(config) {
|
||||||
|
this._config = config || {};
|
||||||
|
this._view = "list";
|
||||||
|
this._recipes = null;
|
||||||
|
this._selected = null;
|
||||||
|
this._loading = false;
|
||||||
|
this._error = null;
|
||||||
|
|
||||||
|
if (!this.shadowRoot) {
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
this.shadowRoot.addEventListener("click", (event) => this._onClick(event));
|
||||||
|
}
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
set hass(hass) {
|
||||||
|
this._hass = hass;
|
||||||
|
if (this._recipes === null && !this._loading) {
|
||||||
|
this._fetchRecipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardSize() {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wsMessage(extra) {
|
||||||
|
const msg = { ...extra };
|
||||||
|
if (this._config.entry_id) {
|
||||||
|
msg.entry_id = this._config.entry_id;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetchRecipes() {
|
||||||
|
this._loading = true;
|
||||||
|
this._error = null;
|
||||||
|
this._render();
|
||||||
|
try {
|
||||||
|
const result = await this._hass.connection.sendMessagePromise(
|
||||||
|
this._wsMessage({ type: "cookcli/recipes" })
|
||||||
|
);
|
||||||
|
this._recipes = result.recipes || [];
|
||||||
|
} catch (err) {
|
||||||
|
this._error = (err && err.message) || "Erreur inconnue";
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _openRecipe(path) {
|
||||||
|
this._loading = true;
|
||||||
|
this._error = null;
|
||||||
|
this._render();
|
||||||
|
try {
|
||||||
|
const result = await this._hass.connection.sendMessagePromise(
|
||||||
|
this._wsMessage({ type: "cookcli/recipe", path })
|
||||||
|
);
|
||||||
|
this._selected = result;
|
||||||
|
this._view = "detail";
|
||||||
|
} catch (err) {
|
||||||
|
this._error = (err && err.message) || "Erreur inconnue";
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClick(event) {
|
||||||
|
const backEl = event.target.closest("[data-action='back']");
|
||||||
|
if (backEl) {
|
||||||
|
this._view = "list";
|
||||||
|
this._selected = null;
|
||||||
|
this._error = null;
|
||||||
|
this._render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const itemEl = event.target.closest("[data-path]");
|
||||||
|
if (itemEl) {
|
||||||
|
this._openRecipe(itemEl.dataset.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_escape(str) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = str ?? "";
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatQuantity(quantity) {
|
||||||
|
if (!quantity) return "";
|
||||||
|
const value = quantity.value ?? "";
|
||||||
|
const unit = quantity.unit ?? "";
|
||||||
|
return `${value} ${unit}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStepItem(item) {
|
||||||
|
switch (item.type) {
|
||||||
|
case "text":
|
||||||
|
return this._escape(item.value);
|
||||||
|
case "ingredient": {
|
||||||
|
const qty = this._formatQuantity(item.quantity);
|
||||||
|
return `<span class="ingredient">${this._escape(item.name)}${
|
||||||
|
qty ? ` (${this._escape(qty)})` : ""
|
||||||
|
}</span>`;
|
||||||
|
}
|
||||||
|
case "cookware":
|
||||||
|
return `<span class="cookware">${this._escape(item.name)}</span>`;
|
||||||
|
case "timer": {
|
||||||
|
const duration = item.duration ?? "";
|
||||||
|
const unit = item.unit ?? "";
|
||||||
|
return `<span class="timer">⏱ ${this._escape(String(duration))}${
|
||||||
|
unit ? " " + this._escape(unit) : ""
|
||||||
|
}</span>`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderList() {
|
||||||
|
if (this._loading) {
|
||||||
|
return `<div class="state-msg">Chargement…</div>`;
|
||||||
|
}
|
||||||
|
if (this._error) {
|
||||||
|
return `<div class="state-msg error">${this._escape(this._error)}</div>`;
|
||||||
|
}
|
||||||
|
if (!this._recipes || this._recipes.length === 0) {
|
||||||
|
return `<div class="state-msg">Aucune recette trouvée.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = this._recipes
|
||||||
|
.map((r) => {
|
||||||
|
const meta = [];
|
||||||
|
if (r.time) meta.push(`<span>⏱ ${this._escape(r.time)}</span>`);
|
||||||
|
if (r.servings) meta.push(`<span>🍽 ${this._escape(String(r.servings))}</span>`);
|
||||||
|
const thumb = r.image_url
|
||||||
|
? `<img class="recipe-thumb" src="${this._escape(r.image_url)}" alt="" loading="lazy" onerror="this.style.display='none'">`
|
||||||
|
: `<div class="recipe-thumb recipe-thumb-placeholder"></div>`;
|
||||||
|
return `
|
||||||
|
<div class="recipe-row" data-path="${this._escape(r.path)}">
|
||||||
|
${thumb}
|
||||||
|
<div class="recipe-row-text">
|
||||||
|
<div class="recipe-name">${this._escape(r.name)}</div>
|
||||||
|
<div class="recipe-meta">${meta.join("")}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<div class="recipe-list">${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDetail() {
|
||||||
|
if (this._loading) {
|
||||||
|
return `<div class="state-msg">Chargement…</div>`;
|
||||||
|
}
|
||||||
|
if (this._error) {
|
||||||
|
return `
|
||||||
|
<button class="back-btn" data-action="back">← Retour à la liste</button>
|
||||||
|
<div class="state-msg error">${this._escape(this._error)}</div>`;
|
||||||
|
}
|
||||||
|
if (!this._selected) return "";
|
||||||
|
|
||||||
|
const r = this._selected;
|
||||||
|
|
||||||
|
const ingredients = (r.ingredients || [])
|
||||||
|
.map((i) => {
|
||||||
|
const qty = this._formatQuantity(i.quantity);
|
||||||
|
return `<li>${qty ? `<strong>${this._escape(qty)}</strong> ` : ""}${this._escape(
|
||||||
|
i.name
|
||||||
|
)}</li>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const cookware = (r.cookware || [])
|
||||||
|
.map((c) => `<li>${this._escape(c.name)}</li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const sections = (r.sections || [])
|
||||||
|
.map((section) => {
|
||||||
|
const steps = (section.steps || [])
|
||||||
|
.map(
|
||||||
|
(step) =>
|
||||||
|
`<li>${(step.items || [])
|
||||||
|
.map((item) => this._renderStepItem(item))
|
||||||
|
.join("")}</li>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
${section.name ? `<h4>${this._escape(section.name)}</h4>` : ""}
|
||||||
|
<ol class="steps">${steps}</ol>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<button class="back-btn" data-action="back">← Retour à la liste</button>
|
||||||
|
${
|
||||||
|
r.image_url
|
||||||
|
? `<img class="recipe-hero" src="${this._escape(r.image_url)}" alt="" onerror="this.style.display='none'">`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<h2>${this._escape(r.title || "")}</h2>
|
||||||
|
${cookware ? `<h4>Ustensiles</h4><ul class="cookware-list">${cookware}</ul>` : ""}
|
||||||
|
<h4>Ingrédients</h4>
|
||||||
|
<ul class="ingredients">${ingredients}</ul>
|
||||||
|
<h4>Préparation</h4>
|
||||||
|
${sections}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
if (!this.shadowRoot) return;
|
||||||
|
const title = this._config.title || "Recettes";
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>${this._styles()}</style>
|
||||||
|
<ha-card header="${this._view === "list" ? this._escape(title) : ""}">
|
||||||
|
<div class="card-content">
|
||||||
|
${this._view === "list" ? this._renderList() : this._renderDetail()}
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_styles() {
|
||||||
|
return `
|
||||||
|
.card-content { padding: 0 16px 16px; }
|
||||||
|
.state-msg { padding: 16px 0; color: var(--secondary-text-color); }
|
||||||
|
.state-msg.error { color: var(--error-color, #db4437); }
|
||||||
|
.recipe-list { display: flex; flex-direction: column; }
|
||||||
|
.recipe-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 4px; border-bottom: 1px solid var(--divider-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.recipe-row:last-child { border-bottom: none; }
|
||||||
|
.recipe-row:hover { background: var(--secondary-background-color); }
|
||||||
|
.recipe-thumb {
|
||||||
|
width: 56px; height: 56px; border-radius: 8px; object-fit: cover;
|
||||||
|
flex-shrink: 0; background: var(--secondary-background-color);
|
||||||
|
}
|
||||||
|
.recipe-thumb-placeholder { background: var(--divider-color); }
|
||||||
|
.recipe-row-text { min-width: 0; }
|
||||||
|
.recipe-name {
|
||||||
|
font-weight: 500; color: var(--primary-text-color);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.recipe-meta {
|
||||||
|
display: flex; gap: 12px; font-size: 0.85em;
|
||||||
|
color: var(--secondary-text-color); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.recipe-hero {
|
||||||
|
width: 100%; max-height: 220px; object-fit: cover;
|
||||||
|
border-radius: 8px; margin: 4px 0 8px; display: block;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
background: none; border: none; color: var(--primary-color);
|
||||||
|
font-size: 0.95em; cursor: pointer; padding: 12px 0 4px; display: block;
|
||||||
|
}
|
||||||
|
h2 { margin: 4px 0 8px; color: var(--primary-text-color); }
|
||||||
|
h4 { margin: 16px 0 4px; color: var(--primary-text-color); }
|
||||||
|
ul, ol { margin: 4px 0; padding-left: 20px; color: var(--primary-text-color); }
|
||||||
|
ul.ingredients li, ul.cookware-list li { margin: 2px 0; }
|
||||||
|
ol.steps li { margin: 8px 0; line-height: 1.5; }
|
||||||
|
.ingredient { color: var(--primary-color); font-weight: 500; }
|
||||||
|
.cookware { font-style: italic; color: var(--secondary-text-color); }
|
||||||
|
.timer { color: var(--warning-color, #ff9800); font-weight: 500; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("cookcli-card", CookCliCard);
|
||||||
|
|
||||||
|
window.customCards = window.customCards || [];
|
||||||
|
window.customCards.push({
|
||||||
|
type: "cookcli-card",
|
||||||
|
name: "CookCLI Recipes",
|
||||||
|
description: "Liste navigable de tes recettes CookCLI, avec ingrédients et étapes.",
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue