First commit
This commit is contained in:
parent
67630e1b3f
commit
1156722b4d
67
custom_components/cookcli/__init__.py
Normal file
67
custom_components/cookcli/__init__.py
Normal 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)
|
||||||
BIN
custom_components/cookcli/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
custom_components/cookcli/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
custom_components/cookcli/__pycache__/api.cpython-312.pyc
Normal file
BIN
custom_components/cookcli/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
custom_components/cookcli/__pycache__/const.cpython-312.pyc
Normal file
BIN
custom_components/cookcli/__pycache__/const.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
127
custom_components/cookcli/api.py
Normal file
127
custom_components/cookcli/api.py
Normal 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")
|
||||||
112
custom_components/cookcli/config_flow.py
Normal file
112
custom_components/cookcli/config_flow.py
Normal 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
|
||||||
|
)
|
||||||
18
custom_components/cookcli/const.py
Normal file
18
custom_components/cookcli/const.py
Normal 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] = []
|
||||||
33
custom_components/cookcli/coordinator.py
Normal file
33
custom_components/cookcli/coordinator.py
Normal 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
|
||||||
12
custom_components/cookcli/manifest.json
Normal file
12
custom_components/cookcli/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
34
custom_components/cookcli/strings.json
Normal file
34
custom_components/cookcli/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
custom_components/cookcli/translations/en.json
Normal file
34
custom_components/cookcli/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
custom_components/cookcli/translations/fr.json
Normal file
34
custom_components/cookcli/translations/fr.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
custom_components/cookcli/websocket_api.py
Normal file
85
custom_components/cookcli/websocket_api.py
Normal 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)
|
||||||
Loading…
Reference in a new issue