diff --git a/custom_components/cookcli/__init__.py b/custom_components/cookcli/__init__.py index bcb1b19..03a0dab 100644 --- a/custom_components/cookcli/__init__.py +++ b/custom_components/cookcli/__init__.py @@ -4,10 +4,12 @@ from __future__ import annotations import logging from pathlib import Path +import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.const import EVENT_HOMEASSISTANT_STARTED @@ -106,6 +108,35 @@ async def async_setup(hass: HomeAssistant, config) -> bool: hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, _register_card_resources ) + + async def _handle_reload(call: ServiceCall) -> None: + """cookcli.reload : resynchronise l'add-on puis rafraîchit la liste.""" + entries = hass.data.get(DOMAIN, {}) + requested = call.data.get("entry_id") + + if requested is not None: + if requested not in entries: + raise ServiceValidationError(f"Serveur CookCLI inconnu : {requested}") + targets = {requested: entries[requested]} + else: + targets = dict(entries) + + for entry_id, data in targets.items(): + try: + await data["client"].async_reload() + except CookCliApiError as err: + raise HomeAssistantError(f"Reload CookCLI impossible : {err}") from err + await data["coordinator"].async_refresh() + # Les cartes ouvertes écoutent cet événement pour recharger leur liste. + hass.bus.async_fire(f"{DOMAIN}_reloaded", {"entry_id": entry_id}) + + hass.services.async_register( + DOMAIN, + "reload", + _handle_reload, + schema=vol.Schema({vol.Optional("entry_id"): str}), + ) + return True @@ -123,4 +154,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) + await hass.config_entries.async_reload(entry.entry_id) \ No newline at end of file diff --git a/custom_components/cookcli/api.py b/custom_components/cookcli/api.py index 67b4d92..d97c602 100644 --- a/custom_components/cookcli/api.py +++ b/custom_components/cookcli/api.py @@ -58,12 +58,14 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any +from .const import RELOAD_PORT + import aiohttp _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10) - +RELOAD_TIMEOUT = aiohttp.ClientTimeout(total=120) class CookCliApiError(Exception): """Erreur générique lors d'un appel à l'API CookCLI.""" @@ -92,6 +94,7 @@ class CookCliApiClient: def __init__(self, session: aiohttp.ClientSession, host: str, port: int) -> None: self._session = session self._base_url = f"http://{host}:{port}/api" + self._reload_url = f"http://{host}:{RELOAD_PORT}/reload" async def async_test_connection(self) -> None: """Vérifie que le serveur répond, utilisé par le config_flow.""" @@ -113,6 +116,10 @@ class CookCliApiClient: data = await self._request_json(f"/recipes/{path}") return _resolve_recipe(data) + async def async_reload(self) -> None: + async with self._get(self._reload_url, method="POST", timeout=RELOAD_TIMEOUT): + pass + async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]: """Récupère les octets d'une image statique CookCLI. @@ -143,13 +150,16 @@ class CookCliApiClient: return f"/static/{image_ref}" @asynccontextmanager - async def _get(self, endpoint: str): - """Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs.""" - url = f"{self._base_url}{endpoint}" + async def _get( + self, + endpoint: str, + method: str = "GET", + timeout: aiohttp.ClientTimeout = DEFAULT_TIMEOUT, + ): + """Contexte partagé pour un appel vers CookCLI, avec gestion d'erreurs.""" + url = endpoint if endpoint.startswith("http") else f"{self._base_url}{endpoint}" try: - async with self._session.request( - "GET", url, timeout=DEFAULT_TIMEOUT - ) as resp: + async with self._session.request(method, url, timeout=timeout) as resp: if resp.status >= 400: raise CookCliResponseError( f"CookCLI a répondu {resp.status} pour {url}" diff --git a/custom_components/cookcli/const.py b/custom_components/cookcli/const.py index 77cfb7d..4b2fda4 100644 --- a/custom_components/cookcli/const.py +++ b/custom_components/cookcli/const.py @@ -6,6 +6,7 @@ CONF_HOST = "host" CONF_PORT = "port" DEFAULT_PORT = 9081 +RELOAD_PORT = 8098 # Nom du service tel que poussé par bashio::discovery.set côté add-on HASSIO_DISCOVERY_SLUG = "cookcli" diff --git a/custom_components/cookcli/services.yaml b/custom_components/cookcli/services.yaml new file mode 100644 index 0000000..efeb6a3 --- /dev/null +++ b/custom_components/cookcli/services.yaml @@ -0,0 +1,7 @@ +reload: + fields: + entry_id: + required: false + selector: + config_entry: + integration: cookcli \ No newline at end of file diff --git a/custom_components/cookcli/strings.json b/custom_components/cookcli/strings.json index 9536672..494794f 100644 --- a/custom_components/cookcli/strings.json +++ b/custom_components/cookcli/strings.json @@ -30,5 +30,17 @@ } } } + }, + "services": { + "reload": { + "name": "Recharger les recettes", + "description": "Resynchronise les recettes depuis le dépôt git de l'add-on.", + "fields": { + "entry_id": { + "name": "Serveur CookCLI", + "description": "Laisser vide pour recharger tous les serveurs." + } + } + } } } diff --git a/custom_components/cookcli/translations/en.json b/custom_components/cookcli/translations/en.json index b5f5cc1..24fd2ab 100644 --- a/custom_components/cookcli/translations/en.json +++ b/custom_components/cookcli/translations/en.json @@ -30,5 +30,17 @@ } } } + }, + "services": { + "reload": { + "name": "Reload recipes", + "description": "Sync recipes from git repository in the addon.", + "fields": { + "entry_id": { + "name": "CookCLI Server", + "description": "Leave empty to reload all servers." + } + } + } } } diff --git a/custom_components/cookcli/translations/fr.json b/custom_components/cookcli/translations/fr.json index 9536672..494794f 100644 --- a/custom_components/cookcli/translations/fr.json +++ b/custom_components/cookcli/translations/fr.json @@ -30,5 +30,17 @@ } } } + }, + "services": { + "reload": { + "name": "Recharger les recettes", + "description": "Resynchronise les recettes depuis le dépôt git de l'add-on.", + "fields": { + "entry_id": { + "name": "Serveur CookCLI", + "description": "Laisser vide pour recharger tous les serveurs." + } + } + } } } diff --git a/custom_components/cookcli/www/cookcli-card.js b/custom_components/cookcli/www/cookcli-card.js index 660ccbc..a954aa4 100644 --- a/custom_components/cookcli/www/cookcli-card.js +++ b/custom_components/cookcli/www/cookcli-card.js @@ -44,6 +44,8 @@ class CookCliCard extends HTMLElement { this._error = null; this._lastFetch = 0; this._selectedTag = null; + this._reloading = false; + this._reloadError = null; if (!this.shadowRoot) { this.attachShadow({ mode: "open" }); @@ -77,6 +79,7 @@ class CookCliCard extends HTMLElement { set hass(hass) { this._hass = hass; + this._subscribe(); if (this._recipes === null && !this._loading) { this._fetchRecipes(); } @@ -136,6 +139,10 @@ class CookCliCard extends HTMLElement { } _onClick(event) { + if (event.target.closest("[data-action='reload']")) { + this._reload(); + return; + } if (event.target.closest("[data-tag-clear]")) { this._selectedTag = null; this._render(); @@ -193,6 +200,63 @@ class CookCliCard extends HTMLElement { return this._recipes.filter((r) => this._tagsOf(r).includes(this._selectedTag)); } + connectedCallback() { + this._subscribe(); + } + + disconnectedCallback() { + if (this._unsub) { + this._unsub(); + this._unsub = null; + } + } + + async _subscribe() { + if (this._unsub || this._subscribing || !this._hass || !this.isConnected) return; + this._subscribing = true; + try { + this._unsub = await this._hass.connection.subscribeEvents( + (ev) => this._onReloaded(ev), + "cookcli_reloaded" + ); + } catch (err) { + // Pas d'abonnement : le bouton retombe sur un rafraîchissement manuel. + } finally { + this._subscribing = false; + } + // La carte a pu être retirée du DOM pendant l'attente. + if (!this.isConnected && this._unsub) { + this._unsub(); + this._unsub = null; + } + } + + _onReloaded(ev) { + const target = this._config.entry_id; + if (target && ev.data && ev.data.entry_id !== target) return; + this._fetchRecipes(); + } + + async _reload() { + if (this._reloading || !this._hass) return; + this._reloading = true; + this._reloadError = null; + this._render(); + try { + const data = {}; + if (this._config.entry_id) data.entry_id = this._config.entry_id; + await this._hass.callService("cookcli", "reload", data); + // Normalement l'événement a déjà déclenché le rechargement ; + // on ne le refait à la main que si l'abonnement a échoué. + if (!this._unsub) await this._fetchRecipes(); + } catch (err) { + this._reloadError = (err && err.message) || "Échec du rechargement"; + } finally { + this._reloading = false; + this._render(); + } + } + _renderTagFilter() { if (!this._tagFilterEnabled()) return ""; const tags = this._allTags(); @@ -255,10 +319,24 @@ class CookCliCard extends HTMLElement { const title = this._config.title || "Recettes"; const previous = this.shadowRoot.querySelector(".tag-filter"); const scrollLeft = previous ? previous.scrollLeft : 0; - + const errorMsg = this._reloadError + ? `