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
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

View file

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

View file

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

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._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
? `<div class="state-msg error reload-error">${this._escape(this._reloadError)}</div>`
: "";
this.shadowRoot.innerHTML = `
<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>
</ha-card>
`;
@ -332,6 +410,20 @@ class CookCliCard extends HTMLElement {
overflow: hidden;
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 {
display: flex;
flex-wrap: nowrap;