Compare commits
No commits in common. "eebe1bbd7e425e302d1570c1ba40b46b8a24423f" and "1156722b4d1ac11d402c50f8a14aeb9eb2940067" have entirely different histories.
eebe1bbd7e
...
1156722b4d
|
|
@ -12,7 +12,6 @@ from .api import CookCliApiClient, CookCliApiError
|
|||
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
|
||||
from .coordinator import CookCliCoordinator
|
||||
from .websocket_api import async_setup_websocket_api
|
||||
from .image_proxy import CookCliImageView
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,60 +1,24 @@
|
|||
"""Client API pour le serveur CookCLI.
|
||||
|
||||
Endpoints CookCLI utilisés :
|
||||
- GET /api/recipes -> arborescence complète des recettes
|
||||
- GET /api/recipes -> liste/arborescence 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) :
|
||||
Le endpoint de liste n'a pas pu être vérifié à 100% depuis les sources au
|
||||
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 :
|
||||
|
||||
{
|
||||
"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 ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
curl http://<ip-addon>:9081/api/recipes | jq
|
||||
|
||||
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.
|
||||
`_normalize_recipe_list` ci-dessous encaisse les deux formes les plus
|
||||
probables ; ajuste-le si le format réel diffère.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -99,249 +63,65 @@ class CookCliApiClient:
|
|||
|
||||
async def async_list_recipes(self) -> list[RecipeSummary]:
|
||||
"""Récupère la liste des recettes disponibles."""
|
||||
data = await self._request_json("/recipes")
|
||||
data = await self._request("GET", "/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.
|
||||
"""Récupère le détail complet d'une recette (ingredients + steps)."""
|
||||
return await self._request("GET", f"/recipes/{path}")
|
||||
|
||||
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."""
|
||||
async def _request(self, method: str, endpoint: str) -> Any:
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
try:
|
||||
async with self._session.request(
|
||||
"GET", url, timeout=DEFAULT_TIMEOUT
|
||||
method, url, timeout=DEFAULT_TIMEOUT
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
raise CookCliResponseError(
|
||||
f"CookCLI a répondu {resp.status} pour {url}"
|
||||
)
|
||||
yield resp
|
||||
try:
|
||||
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:
|
||||
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 []
|
||||
"""Normalise la réponse de listing en une liste plate de RecipeSummary.
|
||||
|
||||
Encaisse plusieurs formes possibles :
|
||||
- une liste de chemins/objets directement
|
||||
- un dict avec une clé "recipes" ou "index"
|
||||
- une arborescence imbriquée par dossier
|
||||
"""
|
||||
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] = []
|
||||
CookCliApiClient._walk_recipe_tree(data, root_path, summaries)
|
||||
for item in items:
|
||||
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
|
||||
|
||||
@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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
"""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,17 +6,10 @@ Deux commandes :
|
|||
|
||||
Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser
|
||||
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 urllib.parse import quote
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
|
@ -25,22 +18,13 @@ from .api import CookCliApiError
|
|||
from .const import DOMAIN
|
||||
|
||||
|
||||
def _resolve_entry(
|
||||
hass: HomeAssistant, entry_id: str | None
|
||||
) -> tuple[str | None, dict | None]:
|
||||
def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | None:
|
||||
entries = hass.data.get(DOMAIN, {})
|
||||
if entry_id is not None:
|
||||
return entry_id, entries.get(entry_id)
|
||||
return entries.get(entry_id)
|
||||
if entries:
|
||||
first_id, first_data = next(iter(entries.items()))
|
||||
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='/')}"
|
||||
return next(iter(entries.values()))
|
||||
return None
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
|
|
@ -53,26 +37,20 @@ def _image_url(entry_id: str, image_ref: str | None) -> str | None:
|
|||
async def ws_list_recipes(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id"))
|
||||
entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
|
||||
if entry_data is None:
|
||||
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
|
||||
return
|
||||
|
||||
coordinator = entry_data["coordinator"]
|
||||
recipes = []
|
||||
for r in coordinator.data or []:
|
||||
metadata = ((r.raw or {}).get("recipe") or {}).get("metadata") or {}
|
||||
recipes.append(
|
||||
{
|
||||
"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})
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"recipes": [
|
||||
{"path": r.path, "name": r.name} for r in coordinator.data or []
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
|
|
@ -86,7 +64,7 @@ async def ws_list_recipes(
|
|||
async def ws_get_recipe(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
entry_id, entry_data = _resolve_entry(hass, msg.get("entry_id"))
|
||||
entry_data = _resolve_entry_data(hass, msg.get("entry_id"))
|
||||
if entry_data is None:
|
||||
connection.send_error(msg["id"], "not_found", "Aucune intégration CookCLI configurée")
|
||||
return
|
||||
|
|
@ -98,7 +76,6 @@ async def ws_get_recipe(
|
|||
connection.send_error(msg["id"], "cannot_connect", str(err))
|
||||
return
|
||||
|
||||
recipe["image_url"] = _image_url(entry_id, recipe.get("image"))
|
||||
connection.send_result(msg["id"], recipe)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
/**
|
||||
* 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