From 1156722b4d1ac11d402c50f8a14aeb9eb2940067 Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Sun, 13 Sep 2026 17:22:52 +0200 Subject: [PATCH] First commit --- custom_components/cookcli/__init__.py | 67 +++++++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 3326 bytes .../cookcli/__pycache__/api.cpython-312.pyc | Bin 0 -> 7378 bytes .../__pycache__/config_flow.cpython-312.pyc | Bin 0 -> 7126 bytes .../cookcli/__pycache__/const.cpython-312.pyc | Bin 0 -> 451 bytes .../__pycache__/coordinator.cpython-312.pyc | Bin 0 -> 1972 bytes .../__pycache__/websocket_api.cpython-312.pyc | Bin 0 -> 4035 bytes custom_components/cookcli/api.py | 127 ++++++++++++++++++ custom_components/cookcli/config_flow.py | 112 +++++++++++++++ custom_components/cookcli/const.py | 18 +++ custom_components/cookcli/coordinator.py | 33 +++++ custom_components/cookcli/manifest.json | 12 ++ custom_components/cookcli/strings.json | 34 +++++ .../cookcli/translations/en.json | 34 +++++ .../cookcli/translations/fr.json | 34 +++++ custom_components/cookcli/websocket_api.py | 85 ++++++++++++ 16 files changed, 556 insertions(+) create mode 100644 custom_components/cookcli/__init__.py create mode 100644 custom_components/cookcli/__pycache__/__init__.cpython-312.pyc create mode 100644 custom_components/cookcli/__pycache__/api.cpython-312.pyc create mode 100644 custom_components/cookcli/__pycache__/config_flow.cpython-312.pyc create mode 100644 custom_components/cookcli/__pycache__/const.cpython-312.pyc create mode 100644 custom_components/cookcli/__pycache__/coordinator.cpython-312.pyc create mode 100644 custom_components/cookcli/__pycache__/websocket_api.cpython-312.pyc create mode 100644 custom_components/cookcli/api.py create mode 100644 custom_components/cookcli/config_flow.py create mode 100644 custom_components/cookcli/const.py create mode 100644 custom_components/cookcli/coordinator.py create mode 100644 custom_components/cookcli/manifest.json create mode 100644 custom_components/cookcli/strings.json create mode 100644 custom_components/cookcli/translations/en.json create mode 100644 custom_components/cookcli/translations/fr.json create mode 100644 custom_components/cookcli/websocket_api.py 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 0000000000000000000000000000000000000000..e23c9979d441c3d6656bca9f04456f61eb7a09c4 GIT binary patch literal 3326 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce2ed2a453314ca7109afaf83ac15aa3b356e83c GIT binary patch literal 451 zcmXw#y-LGS6vt0Ynl`mks7fg!6dlAtT|`hs2x+Q;G$~C|Fw3Rn29374CAkINI_l=; z8wfstPvEADgM-|u;N({5*2!C`XE^8ghx7fuw3G*Kg_ozRHv!;#CjL#F!{mU&BY1$p z2Q2skUho&N=!;nLC9X3#552^-LH2sns*paSR*P3`euudg~lHrgzH9 zUbCjUIx;L*Z?#nuH4MvhbtjQ$bER&?w-HmCup&a8I41OO@f4wFnD7jkZUlprk{$|3 zuTSDIrs>}N79RQWyZzf~?zl(rpc|efE4+k%8`2~0#-bny40c9JX(*2hrJ?dQbED$& jurQXPvhe|{<2>@DnOYiI@KZu|k6e literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..60fa512cf789642f94e2aca164c1e1a4d323dcfe GIT binary patch literal 1972 zcmZux>u(fQ6hC(!`|55VTcE6^ILND;;9~efYK(C$r8cz1(AMY>xtYw|maX$x@61|? z#Smz;B=Su~V~mePLQ^#Q!9RfX3yEL0fdrV;U_yxUt+5ah{ouK?yHLbQ_TKZjch9-! z_dDn3OeO{RZTac^WWNG%ODK&mUK7ab_b8kL3v{rECE3K0bcqOAwq;$$U9lBM)z!$R z+A$}t$0IprC!D07bW(Z>^|+O=o1CZM00h1@UH4=EV zv-Sm2)AV%`^n;a}1*?fA*?QKTLx=b+$n_H&rsaIP(v4cL=W(m#8iB{PvRC1ny|d&7 zi}MrQ2uhx-q5rMk!GXdZaWhZCCc|~TAoBUcs3(Jx!z^Y81}d3XJckv1zvKso8|0;M z{XQcw#>$ovur-0gbj`HiDA~-qBht>xVQUo1E)~nAUc1EHAZ&SL>EqnvVdfAsOJz1v zaU6qBt6&}~-yz;meSIc3kqiut=uvFn%xvgV))Ap-{zN?$|uOuOvy9;VEwUUO8t~upw(<Z-$(bEI2{lBU5Awj>&Jp6!}h`B6(@J@+?Nd24L8o%+>hfyjdwP z&U0)r&0b5ksAPi!&ue+5hC|lyr(Kf@UQ(nawvNvQakxk$FY%4Ig)-yZmr&9)-YE=O zTDw-~1~#v8eEa{w@x-w%yzUR;^1Fa$MUs=0T+elV+4))LwcOsN+}o@?zr)pSp_ zz2~pxmd$?v!6VDr&DAZ#m;KAE`t<0P>{#{ik!t$L-z&0k{u3d6iWC!MwyPVyCOedG z)Qz~chns1{tbP8j1XsBkw_hLM6;fGub| z-Y)8M__5LQCN=_gfanqIfE1;UXH{$=rzw5AV%T+0oKnj(DUCSGH4!3H=f25oo6xMFKTS)?#t!PGGYE_fKm zZEs?to!itydet1=Hg!Dni)(xZ^0ZvCaKL9pp7F$i>)*7!t}x zxfB(y#+oJ(ST#M@4{F08nghuR77SuCNTaRd<_2*}5jBloIS;kh2x>h@yihCTi+m5N zqA30WTo7qOZb0ESJaY@0e}T=nVb@J~{WmBsLGdQ+`8Cx!xAR)6b1BvN=_^-K+fT;t b###v_)m-61@Was3?w6Nxz4t(vqG105{geA{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7e71dd0283e95d7a350e6d92d6e80cf1f3546d30 GIT binary patch literal 4035 zcmc&$U2GKB6~6Pk<6V2#Yy5)&J45UM8_YThDiD)8u`x7|)CX{WRYK3`+5qw9UY7+@n z)4Tk`>LbwQbbo#A&y3Jb&;y&%>h00JMN?B|_J37d+8<*}r_pId;qJTUq*#}84X*Q;mCYt|2IcxJ zeUn?M^VCix^QNA+9H!HYd5cq~XW%oVDQep=Ic8bskA3r`o^o=T?9 z(b&JptV`5&kZG)~*hw>^8+)A`_2ugOlKDIZq6r7t`r5Ld**kXnuWKTIHtA8HcT{@FD#y&Dm@&VH#)kJY`vGqp&=DOPRnO%+T*T zaH5HdR4!5mr0)U*ia`2T8`zUU@_;`NK-IidKTbTq?s>%&e0xy86i7$vEcux(DPv9w!v}co+R6;` zT(1`M>i$aI5WIvRq})LDIbxzK=acq1z+N&-V*w8k40Uyb93o``qXBb6-W>4bWNe05 zXP5_BA9OL5wR_v)tQc6UdOJbH#R! z!fL-h9%x-14a59WxEbf({WM`6G2~(W^#2j-EdcwEIJ z!>e*7ZZq}-%zy)-&UEZ{WA`L>d!TdG3W8k;9EKxX1?GAM=WT%V2C+l%{RsRy=7u%n32k83p3;9HTQ3fEe|P#(F_m zOP_IlZ9(@+GN^wr&~oZPMKo=hJU$td8Cu|WSCCVYMH%K}tWv#j#_+Xcf(IRrwpTIH zTjAlv}`<#1Pm zxx4)wRE1FHRI0*wy_~6BZ^xsb8TIURCA=-qNfq#Fuoq*Fm&4p>-InTWxY}z_XxL4V!4%wkxij*L+C4u?Sfhg_LL 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)