First commit

This commit is contained in:
TheThomaas 2026-09-13 17:22:52 +02:00
parent 67630e1b3f
commit 1156722b4d
16 changed files with 556 additions and 0 deletions

View file

@ -0,0 +1,67 @@
"""Intégration CookCLI pour Home Assistant."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
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
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Initialise CookCLI à partir d'une entrée de configuration."""
session = async_get_clientsession(hass)
client = CookCliApiClient(
session, entry.data[CONF_HOST], entry.data[CONF_PORT]
)
coordinator = CookCliCoordinator(hass, client)
try:
await coordinator.async_config_entry_first_refresh()
except CookCliApiError as err:
_LOGGER.warning("CookCLI injoignable au démarrage: %s", err)
raise
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
"client": client,
"coordinator": coordinator,
}
# Les commandes websocket sont globales au domaine, on ne les enregistre
# qu'une seule fois même si plusieurs serveurs CookCLI sont configurés.
if len(hass.data[DOMAIN]) == 1:
async_setup_websocket_api(hass)
if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Décharge une entrée de configuration CookCLI."""
unload_ok = True
if PLATFORMS:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Recharge l'entrée si ses options changent (host/port modifiés)."""
await hass.config_entries.async_reload(entry.entry_id)

View file

@ -0,0 +1,127 @@
"""Client API pour le serveur CookCLI.
Endpoints CookCLI utilisés :
- GET /api/recipes -> liste/arborescence des recettes
- 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
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
`_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 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("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 complet d'une recette (ingredients + steps)."""
return await self._request("GET", f"/recipes/{path}")
async def _request(self, method: str, endpoint: str) -> Any:
url = f"{self._base_url}{endpoint}"
try:
async with self._session.request(
method, url, timeout=DEFAULT_TIMEOUT
) as resp:
if resp.status >= 400:
raise CookCliResponseError(
f"CookCLI a répondu {resp.status} pour {url}"
)
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
@staticmethod
def _normalize_recipe_list(data: Any) -> list[RecipeSummary]:
"""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 = []
summaries: list[RecipeSummary] = []
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
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")

View file

@ -0,0 +1,112 @@
"""Config flow pour CookCLI.
Deux chemins d'entrée possibles :
- async_step_hassio : déclenché automatiquement quand l'add-on pousse sa
découverte via `bashio::discovery.set` (voir README du custom_component).
- async_step_user : saisie manuelle host/port, utile si CookCLI tourne en
dehors du Supervisor (ex: conteneur Docker autonome sur le réseau local).
"""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import CookCliApiClient, CookCliApiError
from .const import CONF_HOST, CONF_PORT, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
)
class CookCliConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Gère la création d'une entrée de configuration CookCLI."""
VERSION = 1
def __init__(self) -> None:
self._discovered_config: dict[str, Any] = {}
async def _async_validate(self, host: str, port: int) -> str | None:
"""Teste la connexion. Retourne un code d'erreur ou None si OK."""
session = async_get_clientsession(self.hass)
client = CookCliApiClient(session, host, port)
try:
await client.async_test_connection()
except CookCliApiError:
_LOGGER.debug("Échec de connexion à CookCLI sur %s:%s", host, port, exc_info=True)
return "cannot_connect"
return None
# ---- Découverte automatique via le Supervisor ----
async def async_step_hassio(self, discovery_info: Any) -> FlowResult:
"""Point d'entrée quand le Supervisor annonce l'add-on CookCLI."""
config = discovery_info.config
host = config["host"]
port = config.get("port", DEFAULT_PORT)
await self.async_set_unique_id(f"{host}:{port}")
self._abort_if_unique_id_configured()
self._discovered_config = {CONF_HOST: host, CONF_PORT: port}
self.context["title_placeholders"] = {"host": host}
return await self.async_step_hassio_confirm()
async def async_step_hassio_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Demande une confirmation avant de créer l'entrée découverte."""
if user_input is not None:
error = await self._async_validate(
self._discovered_config[CONF_HOST], self._discovered_config[CONF_PORT]
)
if error is None:
return self.async_create_entry(
title="CookCLI (add-on)", data=self._discovered_config
)
return self.async_show_form(
step_id="hassio_confirm", errors={"base": error}
)
return self.async_show_form(
step_id="hassio_confirm",
description_placeholders={
"host": self._discovered_config.get(CONF_HOST, "")
},
)
# ---- Saisie manuelle ----
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
await self.async_set_unique_id(f"{host}:{port}")
self._abort_if_unique_id_configured()
error = await self._async_validate(host, port)
if error is None:
return self.async_create_entry(title=f"CookCLI ({host})", data=user_input)
errors["base"] = error
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)

View file

@ -0,0 +1,18 @@
"""Constantes pour l'intégration CookCLI."""
DOMAIN = "cookcli"
CONF_HOST = "host"
CONF_PORT = "port"
DEFAULT_PORT = 9081
# Nom du service tel que poussé par bashio::discovery.set côté add-on
HASSIO_DISCOVERY_SLUG = "cookcli"
# Intervalle de rafraîchissement de la liste des recettes.
# Les fichiers .cook ne changent qu'après un sync git côté add-on,
# donc pas besoin de poller souvent.
UPDATE_INTERVAL_MINUTES = 15
PLATFORMS: list[str] = []

View file

@ -0,0 +1,33 @@
"""Coordinator pour l'intégration CookCLI."""
from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import CookCliApiClient, CookCliApiError, RecipeSummary
from .const import DOMAIN, UPDATE_INTERVAL_MINUTES
_LOGGER = logging.getLogger(__name__)
class CookCliCoordinator(DataUpdateCoordinator[list[RecipeSummary]]):
"""Maintient la liste des recettes à jour."""
def __init__(self, hass: HomeAssistant, client: CookCliApiClient) -> None:
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=UPDATE_INTERVAL_MINUTES),
)
self.client = client
async def _async_update_data(self) -> list[RecipeSummary]:
try:
return await self.client.async_list_recipes()
except CookCliApiError as err:
raise UpdateFailed(f"Erreur en récupérant les recettes CookCLI: {err}") from err

View file

@ -0,0 +1,12 @@
{
"domain": "cookcli",
"name": "CookCLI",
"codeowners": [],
"config_flow": true,
"dependencies": ["hassio"],
"documentation": "https://git.thethomaas.net/TheThomaas/home-assistant-apps",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": [],
"version": "0.1.0"
}

View file

@ -0,0 +1,34 @@
{
"config": {
"step": {
"user": {
"title": "Connexion à CookCLI",
"description": "Renseigne l'adresse du serveur CookCLI si l'auto-découverte n'a pas fonctionné.",
"data": {
"host": "Hôte",
"port": "Port"
}
},
"hassio_confirm": {
"title": "Serveur CookCLI détecté",
"description": "L'add-on CookCLI a été détecté sur {host}. Ajouter cette intégration ?"
}
},
"error": {
"cannot_connect": "Impossible de contacter le serveur CookCLI. Vérifie l'adresse, le port, et que l'add-on est démarré."
},
"abort": {
"already_configured": "Ce serveur CookCLI est déjà configuré."
}
},
"options": {
"step": {
"init": {
"data": {
"host": "Hôte",
"port": "Port"
}
}
}
}
}

View file

@ -0,0 +1,34 @@
{
"config": {
"step": {
"user": {
"title": "Connect to CookCLI",
"description": "Enter the CookCLI server address if auto-discovery didn't work.",
"data": {
"host": "Host",
"port": "Port"
}
},
"hassio_confirm": {
"title": "CookCLI server discovered",
"description": "The CookCLI add-on was detected at {host}. Add this integration?"
}
},
"error": {
"cannot_connect": "Could not reach the CookCLI server. Check the address, port, and that the add-on is running."
},
"abort": {
"already_configured": "This CookCLI server is already configured."
}
},
"options": {
"step": {
"init": {
"data": {
"host": "Host",
"port": "Port"
}
}
}
}
}

View file

@ -0,0 +1,34 @@
{
"config": {
"step": {
"user": {
"title": "Connexion à CookCLI",
"description": "Renseigne l'adresse du serveur CookCLI si l'auto-découverte n'a pas fonctionné.",
"data": {
"host": "Hôte",
"port": "Port"
}
},
"hassio_confirm": {
"title": "Serveur CookCLI détecté",
"description": "L'add-on CookCLI a été détecté sur {host}. Ajouter cette intégration ?"
}
},
"error": {
"cannot_connect": "Impossible de contacter le serveur CookCLI. Vérifie l'adresse, le port, et que l'add-on est démarré."
},
"abort": {
"already_configured": "Ce serveur CookCLI est déjà configuré."
}
},
"options": {
"step": {
"init": {
"data": {
"host": "Hôte",
"port": "Port"
}
}
}
}
}

View file

@ -0,0 +1,85 @@
"""Commandes websocket_api pour exposer les recettes CookCLI au frontend.
Deux commandes :
- cookcli/recipes -> liste des recettes (depuis le coordinator, en cache)
- cookcli/recipe -> détail d'une recette (appel direct à l'API, pas de cache)
Si plusieurs serveurs CookCLI sont configurés, `entry_id` permet de préciser
lequel interroger ; sinon, le premier configuré est utilisé.
"""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from .api import CookCliApiError
from .const import DOMAIN
def _resolve_entry_data(hass: HomeAssistant, entry_id: str | None) -> dict | None:
entries = hass.data.get(DOMAIN, {})
if entry_id is not None:
return entries.get(entry_id)
if entries:
return next(iter(entries.values()))
return None
@websocket_api.websocket_command(
{
vol.Required("type"): "cookcli/recipes",
vol.Optional("entry_id"): str,
}
)
@websocket_api.async_response
async def ws_list_recipes(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
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"]
connection.send_result(
msg["id"],
{
"recipes": [
{"path": r.path, "name": r.name} for r in coordinator.data or []
]
},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "cookcli/recipe",
vol.Required("path"): str,
vol.Optional("entry_id"): str,
}
)
@websocket_api.async_response
async def ws_get_recipe(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
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
client = entry_data["client"]
try:
recipe = await client.async_get_recipe(msg["path"])
except CookCliApiError as err:
connection.send_error(msg["id"], "cannot_connect", str(err))
return
connection.send_result(msg["id"], recipe)
def async_setup_websocket_api(hass: HomeAssistant) -> None:
"""Enregistre les commandes websocket. À appeler une seule fois."""
websocket_api.async_register_command(hass, ws_list_recipes)
websocket_api.async_register_command(hass, ws_get_recipe)