Add service to reload recipes

This commit is contained in:
TheThomaas 2026-09-20 18:49:38 +02:00
parent 7e3b4674c4
commit f876730ead
8 changed files with 188 additions and 11 deletions

View file

@ -4,10 +4,12 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.components.http import StaticPathConfig from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry 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.helpers.aiohttp_client import async_get_clientsession
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
@ -106,6 +108,35 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
hass.bus.async_listen_once( hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, _register_card_resources 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 return True

View file

@ -58,12 +58,14 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from .const import RELOAD_PORT
import aiohttp import aiohttp
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10) DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10)
RELOAD_TIMEOUT = aiohttp.ClientTimeout(total=120)
class CookCliApiError(Exception): class CookCliApiError(Exception):
"""Erreur générique lors d'un appel à l'API CookCLI.""" """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: def __init__(self, session: aiohttp.ClientSession, host: str, port: int) -> None:
self._session = session self._session = session
self._base_url = f"http://{host}:{port}/api" self._base_url = f"http://{host}:{port}/api"
self._reload_url = f"http://{host}:{RELOAD_PORT}/reload"
async def async_test_connection(self) -> None: async def async_test_connection(self) -> None:
"""Vérifie que le serveur répond, utilisé par le config_flow.""" """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}") data = await self._request_json(f"/recipes/{path}")
return _resolve_recipe(data) 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]: async def async_get_image(self, image_ref: str) -> tuple[bytes, str | None]:
"""Récupère les octets d'une image statique CookCLI. """Récupère les octets d'une image statique CookCLI.
@ -143,13 +150,16 @@ class CookCliApiClient:
return f"/static/{image_ref}" return f"/static/{image_ref}"
@asynccontextmanager @asynccontextmanager
async def _get(self, endpoint: str): async def _get(
"""Contexte partagé pour un GET vers CookCLI, avec gestion d'erreurs.""" self,
url = f"{self._base_url}{endpoint}" 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: try:
async with self._session.request( async with self._session.request(method, url, timeout=timeout) as resp:
"GET", url, timeout=DEFAULT_TIMEOUT
) as resp:
if resp.status >= 400: if resp.status >= 400:
raise CookCliResponseError( raise CookCliResponseError(
f"CookCLI a répondu {resp.status} pour {url}" f"CookCLI a répondu {resp.status} pour {url}"

View file

@ -6,6 +6,7 @@ CONF_HOST = "host"
CONF_PORT = "port" CONF_PORT = "port"
DEFAULT_PORT = 9081 DEFAULT_PORT = 9081
RELOAD_PORT = 8098
# Nom du service tel que poussé par bashio::discovery.set côté add-on # Nom du service tel que poussé par bashio::discovery.set côté add-on
HASSIO_DISCOVERY_SLUG = "cookcli" HASSIO_DISCOVERY_SLUG = "cookcli"

View file

@ -0,0 +1,7 @@
reload:
fields:
entry_id:
required: false
selector:
config_entry:
integration: cookcli

View file

@ -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."
}
}
}
} }
} }

View file

@ -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."
}
}
}
} }
} }

View file

@ -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."
}
}
}
} }
} }

View file

@ -44,6 +44,8 @@ class CookCliCard extends HTMLElement {
this._error = null; this._error = null;
this._lastFetch = 0; this._lastFetch = 0;
this._selectedTag = null; this._selectedTag = null;
this._reloading = false;
this._reloadError = null;
if (!this.shadowRoot) { if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
@ -77,6 +79,7 @@ class CookCliCard extends HTMLElement {
set hass(hass) { set hass(hass) {
this._hass = hass; this._hass = hass;
this._subscribe();
if (this._recipes === null && !this._loading) { if (this._recipes === null && !this._loading) {
this._fetchRecipes(); this._fetchRecipes();
} }
@ -136,6 +139,10 @@ class CookCliCard extends HTMLElement {
} }
_onClick(event) { _onClick(event) {
if (event.target.closest("[data-action='reload']")) {
this._reload();
return;
}
if (event.target.closest("[data-tag-clear]")) { if (event.target.closest("[data-tag-clear]")) {
this._selectedTag = null; this._selectedTag = null;
this._render(); this._render();
@ -193,6 +200,63 @@ class CookCliCard extends HTMLElement {
return this._recipes.filter((r) => this._tagsOf(r).includes(this._selectedTag)); 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() { _renderTagFilter() {
if (!this._tagFilterEnabled()) return ""; if (!this._tagFilterEnabled()) return "";
const tags = this._allTags(); const tags = this._allTags();
@ -255,10 +319,24 @@ class CookCliCard extends HTMLElement {
const title = this._config.title || "Recettes"; const title = this._config.title || "Recettes";
const previous = this.shadowRoot.querySelector(".tag-filter"); const previous = this.shadowRoot.querySelector(".tag-filter");
const scrollLeft = previous ? previous.scrollLeft : 0; const scrollLeft = previous ? previous.scrollLeft : 0;
const errorMsg = this._reloadError
? `<div class="state-msg error reload-error">${this._escape(this._reloadError)}</div>`
: "";
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style>${this._styles()}</style> <style>${this._styles()}</style>
<ha-card header="${this._escape(title)}"> <ha-card>
<div class="card-header">
<span class="card-title">${this._escape(title)}</span>
<ha-icon-button
class="reload-btn ${this._reloading ? "spinning" : ""}"
data-action="reload"
label="Recharger les recettes"
${this._reloading ? "disabled" : ""}
>
<ha-icon icon="mdi:refresh"></ha-icon>
</ha-icon-button>
</div>
${errorMsg}
<div class="card-content">${this._renderList()}</div> <div class="card-content">${this._renderList()}</div>
</ha-card> </ha-card>
`; `;
@ -332,6 +410,20 @@ class CookCliCard extends HTMLElement {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 8px 8px 16px;
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, 24px);
line-height: 32px;
}
.reload-error { padding: 0 16px 8px; }
.reload-btn.spinning ha-icon { animation: cookcli-spin 1s linear infinite; }
@keyframes cookcli-spin { to { transform: rotate(360deg); } }
.tag-filter { .tag-filter {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;