diff --git a/custom_components/cookcli/__init__.py b/custom_components/cookcli/__init__.py new file mode 100644 index 0000000..f3e25a0 --- /dev/null +++ b/custom_components/cookcli/__init__.py @@ -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) diff --git a/custom_components/cookcli/__pycache__/__init__.cpython-312.pyc b/custom_components/cookcli/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..e23c997 Binary files /dev/null and b/custom_components/cookcli/__pycache__/__init__.cpython-312.pyc differ diff --git a/custom_components/cookcli/__pycache__/api.cpython-312.pyc b/custom_components/cookcli/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..8e2c3a9 Binary files /dev/null and b/custom_components/cookcli/__pycache__/api.cpython-312.pyc differ diff --git a/custom_components/cookcli/__pycache__/config_flow.cpython-312.pyc b/custom_components/cookcli/__pycache__/config_flow.cpython-312.pyc new file mode 100644 index 0000000..8794b17 Binary files /dev/null and b/custom_components/cookcli/__pycache__/config_flow.cpython-312.pyc differ diff --git a/custom_components/cookcli/__pycache__/const.cpython-312.pyc b/custom_components/cookcli/__pycache__/const.cpython-312.pyc new file mode 100644 index 0000000..ce2ed2a Binary files /dev/null and b/custom_components/cookcli/__pycache__/const.cpython-312.pyc differ diff --git a/custom_components/cookcli/__pycache__/coordinator.cpython-312.pyc b/custom_components/cookcli/__pycache__/coordinator.cpython-312.pyc new file mode 100644 index 0000000..60fa512 Binary files /dev/null and b/custom_components/cookcli/__pycache__/coordinator.cpython-312.pyc differ diff --git a/custom_components/cookcli/__pycache__/websocket_api.cpython-312.pyc b/custom_components/cookcli/__pycache__/websocket_api.cpython-312.pyc new file mode 100644 index 0000000..7e71dd0 Binary files /dev/null and b/custom_components/cookcli/__pycache__/websocket_api.cpython-312.pyc differ diff --git a/custom_components/cookcli/api.py b/custom_components/cookcli/api.py new file mode 100644 index 0000000..1dff1c7 --- /dev/null +++ b/custom_components/cookcli/api.py @@ -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://: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") diff --git a/custom_components/cookcli/config_flow.py b/custom_components/cookcli/config_flow.py new file mode 100644 index 0000000..df39c46 --- /dev/null +++ b/custom_components/cookcli/config_flow.py @@ -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 + ) diff --git a/custom_components/cookcli/const.py b/custom_components/cookcli/const.py new file mode 100644 index 0000000..9f8a0cc --- /dev/null +++ b/custom_components/cookcli/const.py @@ -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] = [] diff --git a/custom_components/cookcli/coordinator.py b/custom_components/cookcli/coordinator.py new file mode 100644 index 0000000..40db81d --- /dev/null +++ b/custom_components/cookcli/coordinator.py @@ -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 diff --git a/custom_components/cookcli/manifest.json b/custom_components/cookcli/manifest.json new file mode 100644 index 0000000..e4a77f3 --- /dev/null +++ b/custom_components/cookcli/manifest.json @@ -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" +} diff --git a/custom_components/cookcli/strings.json b/custom_components/cookcli/strings.json new file mode 100644 index 0000000..9536672 --- /dev/null +++ b/custom_components/cookcli/strings.json @@ -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" + } + } + } + } +} diff --git a/custom_components/cookcli/translations/en.json b/custom_components/cookcli/translations/en.json new file mode 100644 index 0000000..b5f5cc1 --- /dev/null +++ b/custom_components/cookcli/translations/en.json @@ -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" + } + } + } + } +} diff --git a/custom_components/cookcli/translations/fr.json b/custom_components/cookcli/translations/fr.json new file mode 100644 index 0000000..9536672 --- /dev/null +++ b/custom_components/cookcli/translations/fr.json @@ -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" + } + } + } + } +} diff --git a/custom_components/cookcli/websocket_api.py b/custom_components/cookcli/websocket_api.py new file mode 100644 index 0000000..a85fc41 --- /dev/null +++ b/custom_components/cookcli/websocket_api.py @@ -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)