Salut
Quand j’avais développé l’intégration maree_france, je me rappelle avoir passé un bon moment avant d’arriver à fournir la carte custom directement depuis l’intégration.
Vu que c’est documenté nulle part, je colle ce petit guide ici sur comment faire ça. Ca peut servir, et pour les archives.
Le code est une version nettoyée de celui de maree_france
Ce guide explique comment intégrer automatiquement une carte Lovelace personnalisée dans une intégration Home Assistant, sans que l’utilisateur n’ait à ajouter manuellement la ressource JavaScript.
Aperçu
Le mécanisme repose sur trois piliers :
- Déclaration de dépendance dans
manifest.json - Enregistrement d’un chemin HTTP statique pour servir les fichiers JavaScript
- Ajout automatique de ressource aux ressources Lovelace (mode stockage)
Étape 1 : Structure des répertoires
custom_components/
└── votre_integration/
├── __init__.py # Point d'entrée de l'intégration
├── manifest.json # Métadonnées de l'intégration
├── const.py # Constantes (URL_BASE, JSMODULES)
├── services.yaml # Définitions de services (optionnel)
└── frontend/
├── __init__.py # JSModuleRegistration
└── votre-carte.js # Fichier JavaScript compilé de la carte
Étape 2 : Configurer manifest.json
Le fichier manifest.json doit déclarer les dépendances frontend et http :
{
"domain": "votre_integration",
"name": "Votre Intégration",
"version": "1.0.0",
"dependencies": [
"frontend",
"http"
],
"config_flow": true,
"iot_class": "cloud_polling"
}
Important
Sans les dépendancesfrontendethttp, l’enregistrement des ressources échouera.
Étape 3 : Définir les constantes
Dans const.py, définissez l’URL de base et la liste des modules JavaScript :
from pathlib import Path
import json
from typing import Final
# Lire la version depuis manifest.json
MANIFEST_PATH = Path(__file__).parent / "manifest.json"
with open(MANIFEST_PATH, encoding="utf-8") as f:
INTEGRATION_VERSION: Final[str] = json.load(f).get("version", "0.0.0")
DOMAIN: Final[str] = "votre_integration"
# URL de base pour les ressources frontend
URL_BASE: Final[str] = "/votre-integration"
# Liste des modules JavaScript à enregistrer
JSMODULES: Final[list[dict[str, str]]] = [
{
"name": "Votre Carte",
"filename": "votre-carte.js",
"version": INTEGRATION_VERSION,
},
# Ajouter l'éditeur si nécessaire
{
"name": "Editeur de Votre Carte",
"filename": "votre-carte-editor.js",
"version": INTEGRATION_VERSION,
},
]
Étape 4 : Créer la classe JSModuleRegistration
Créez frontend/__init__.py avec la classe d’enregistrement :
"""Enregistrement des modules JavaScript."""
import logging
from pathlib import Path
from typing import Any
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import async_call_later
from ..const import JSMODULES, URL_BASE, INTEGRATION_VERSION
_LOGGER = logging.getLogger(__name__)
class JSModuleRegistration:
"""Enregistre les modules JavaScript dans Home Assistant."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialiser le registraire."""
self.hass = hass
self.lovelace = self.hass.data.get("lovelace")
async def async_register(self) -> None:
"""Enregistrer les ressources frontend."""
await self._async_register_path()
# Enregistrer les modules uniquement si Lovelace est en mode stockage
if self.lovelace.mode == "storage":
await self._async_wait_for_lovelace_resources()
async def _async_register_path(self) -> None:
"""Enregistrer le chemin HTTP statique."""
try:
await self.hass.http.async_register_static_paths(
[StaticPathConfig(URL_BASE, Path(__file__).parent, False)]
)
_LOGGER.debug("Chemin enregistré : %s -> %s", URL_BASE, Path(__file__).parent)
except RuntimeError:
_LOGGER.debug("Chemin déjà enregistré : %s", URL_BASE)
async def _async_wait_for_lovelace_resources(self) -> None:
"""Attendre que les ressources Lovelace soient chargées."""
async def _check_loaded(_now: Any) -> None:
if self.lovelace.resources.loaded:
await self._async_register_modules()
else:
_LOGGER.debug("Ressources Lovelace non chargées, nouvel essai dans 5s")
async_call_later(self.hass, 5, _check_loaded)
await _check_loaded(0)
async def _async_register_modules(self) -> None:
"""Enregistrer ou mettre à jour les modules JavaScript."""
_LOGGER.debug("Installation des modules JavaScript")
# Récupérer les ressources existantes de cette intégration
existing_resources = [
r for r in self.lovelace.resources.async_items()
if r["url"].startswith(URL_BASE)
]
for module in JSMODULES:
url = f"{URL_BASE}/{module['filename']}"
registered = False
for resource in existing_resources:
if self._get_path(resource["url"]) == url:
registered = True
# Vérifier si une mise à jour est nécessaire
if self._get_version(resource["url"]) != module["version"]:
_LOGGER.info(
"Mise à jour de %s vers la version %s",
module["name"], module["version"]
)
await self.lovelace.resources.async_update_item(
resource["id"],
{
"res_type": "module",
"url": f"{url}?v={module['version']}",
},
)
break
if not registered:
_LOGGER.info(
"Enregistrement de %s version %s",
module["name"], module["version"]
)
await self.lovelace.resources.async_create_item(
{
"res_type": "module",
"url": f"{url}?v={module['version']}",
}
)
def _get_path(self, url: str) -> str:
"""Extraire le chemin sans les paramètres."""
return url.split("?")[0]
def _get_version(self, url: str) -> str:
"""Extraire la version de l'URL."""
parts = url.split("?")
if len(parts) > 1 and parts[1].startswith("v="):
return parts[1].replace("v=", "")
return "0"
async def async_unregister(self) -> None:
"""Supprimer les ressources Lovelace de cette intégration."""
if self.lovelace.mode == "storage":
for module in JSMODULES:
url = f"{URL_BASE}/{module['filename']}"
resources = [
r for r in self.lovelace.resources.async_items()
if r["url"].startswith(url)
]
for resource in resources:
await self.lovelace.resources.async_delete_item(resource["id"])
Étape 5 : Appeler l’enregistrement dans async_setup
Dans le fichier __init__.py principal, appelez JSModuleRegistration :
from homeassistant.core import (
HomeAssistant,
CoreState,
EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.components import websocket_api
from homeassistant.helpers import config_validation as cv
import voluptuous as vol
from .frontend import JSModuleRegistration
from .const import DOMAIN, INTEGRATION_VERSION
async def async_register_frontend(hass: HomeAssistant) -> None:
"""Enregistrer les modules frontend après le démarrage de HA."""
module_register = JSModuleRegistration(hass)
await module_register.async_register()
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/version",
}
)
@websocket_api.async_response
async def websocket_get_version(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Gérer la demande de version du frontend."""
connection.send_result(
msg["id"],
{"version": INTEGRATION_VERSION},
)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Configurer le composant."""
# Enregistrer la commande websocket pour la vérification de version
websocket_api.async_register_command(hass, websocket_get_version)
async def _setup_frontend(_event=None) -> None:
await async_register_frontend(hass)
# Si HA est déjà en cours d'exécution, enregistrer immédiatement
if hass.state == CoreState.running:
await _setup_frontend()
else:
# Sinon, attendre l'événement STARTED
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _setup_frontend)
return True
AVERTISSEMENT
L’enregistrement doit se faire dansasync_setup, pas dansasync_setup_entry.
Cela garantit que l’enregistrement n’a lieu qu’une fois par intégration, et non par entrée de configuration.
Étape 6 : Créer la carte JavaScript
La carte doit s’enregistrer en tant qu’élément personnalisé et se déclarer dans window.customCards :
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
// Version intégrée lors de la construction (remplacez par votre version réelle)
const CARD_VERSION = '1.0.0';
@customElement('votre-carte')
export class VotreCarte extends LitElement {
@property({ attribute: false }) hass;
@property({ attribute: false }) config;
private backendVersion = null;
private versionCheckDone = false;
static getConfigElement() {
return document.createElement('votre-carte-editor');
}
static getStubConfig() {
return { entity: '' };
}
setConfig(config) {
this.config = config;
}
getCardSize() {
return 3;
}
connectedCallback() {
super.connectedCallback();
this.checkVersion();
}
async checkVersion() {
if (this.versionCheckDone) return;
try {
const result = await this.hass.connection.sendMessagePromise({
type: 'votre_integration/version',
});
this.backendVersion = result.version;
this.versionCheckDone = true;
if (this.backendVersion !== CARD_VERSION) {
this.showVersionMismatch();
}
} catch (err) {
console.error('Échec de la vérification de version :', err);
}
}
showVersionMismatch() {
// Afficher une notification toast avec action de rechargement
// Utilisation de l'événement hass-notification au lieu de persistent_notification
// car le toast n'apparaît que dans la session courante et attire immédiatement l'attention
const message = `Incohérence de version détectée pour Votre Intégration ! Backend : ${this.backendVersion} | Frontend : ${CARD_VERSION}`;
this.dispatchEvent(
new CustomEvent('hass-notification', {
detail: {
message: message,
duration: -1, // Persistant jusqu'à fermeture
dismissable: true,
action: {
text: 'Recharger',
action: this.handleReload,
},
},
bubbles: true,
composed: true,
})
);
}
// Fonction fléchée pour préserver le contexte 'this' lors de l'utilisation comme gestionnaire d'action
handleReload = () => {
// Effacer le cache de l'application avant rechargement
if ('caches' in window) {
caches.keys().then((names) => {
names.forEach((name) => {
caches.delete(name);
});
}).then(() => {
window.location.reload();
});
} else {
window.location.reload();
}
}
render() {
if (!this.hass || !this.config) {
return html``;
}
return html`<ha-card header="Ma Carte">...</ha-card>`;
}
static styles = css`
ha-card { padding: 16px; }
`;
}
// Enregistrement pour apparaître dans le sélecteur de cartes
window.customCards = window.customCards || [];
window.customCards.push({
type: 'votre-carte',
name: 'Votre Carte',
preview: true,
description: 'Description de votre carte',
});
Étape 7 : Construction et déploiement
Configuration Webpack (exemple)
// webpack.config.cjs
const path = require('path');
const webpack = require('webpack');
const package = require('./package.json');
module.exports = {
mode: 'production',
entry: {
'votre-carte': './src/votre-carte.ts',
'votre-carte-editor': './src/votre-carte-editor.ts',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../custom_components/votre_integration/frontend'),
clean: {
keep: /__init__\.py$/, // Conserver le fichier Python
},
},
resolve: {
extensions: ['.js', '.ts'],
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: 'ts-loader',
},
],
},
plugins: [
// Injecter la version au moment de la construction
new webpack.DefinePlugin({
CARD_VERSION: JSON.stringify(package.version),
}),
],
};
Étape 8 : Service de rechargement optionnel
Pour une meilleure expérience utilisateur, vous pouvez ajouter un service pour effacer le cache et recharger. Créez services.yaml :
reload_frontend:
name: Recharger le Frontend
description: Efface le cache de l'application et recharge la page
fields: {}
Et ajoutez le gestionnaire de service dans __init__.py :
from homeassistant.helpers import service
async def async_reload_frontend(call):
"""Gérer l'appel au service de rechargement du frontend."""
# Ce service est principalement documenté pour les utilisateurs
# Le rechargement réel se produit du côté frontend
_LOGGER.info("Rechargement du frontend demandé via le service")
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Configurer le composant."""
# ... code précédent ...
# Enregistrer le service de rechargement
hass.services.async_register(
DOMAIN,
"reload_frontend",
async_reload_frontend,
)
return True
Gestion des versions et cache de l’application
Le problème du cache de l’application
PROBLÈME CRITIQUE
Le paramètre?v=X.X.Xdans les URL des ressources ne résout PAS tous les problèmes de cache !
Pourquoi les URL versionnées ne suffisent pas :
Les URL des modules JavaScript sont intégrées dans les fichiers de page mis en cache (cache de l’application). Lorsque vous mettez à jour votre intégration :
- Le backend met à jour l’URL de la ressource avec une nouvelle version
- MAIS la page en cache fait toujours référence à l’ANCIENNE URL
- Les utilisateurs obtiennent des incohérences de version selon leur page de démarrage
Où cela se manifeste :
- Navigateurs de bureau : Un rafraîchissement forcé (Ctrl+Shift+R) efface le cache, masquant le problème
- Applications compagnons (iOS/Android) : Le cache persiste, causant des problèmes constants
- Pages de démarrage différentes : Certaines pages sont en cache, d’autres non, ce qui entraîne un comportement incohérent
Solution recommandée : Vérification de version
Implémentez un système de vérification de version inspiré par Browser Mod :
- Le backend expose la version via une commande WebSocket
- Le frontend vérifie la version au chargement de la carte
- La détection d’une incohérence déclenche une notification utilisateur
- Un bouton « Effacer le cache + Recharger » est proposé à l’utilisateur
Principaux avantages :
- Les utilisateurs sont informés des incohérences de version
- Résolution en un clic (effacer le cache + recharger)
- Fonctionne de manière fiable sur toutes les plateformes
- Prévient les bugs mystérieux et les problèmes de support
Liste de contrôle pour l’implémentation
- La commande WebSocket du backend renvoie la version de l’intégration
- La carte frontend vérifie la version dans
connectedCallback() - L’incohérence de version affiche une notification toast (événement hass-notification)
- Le bouton de rechargement efface l’API
cachesavant de recharger - Version constante intégrée lors de la construction (webpack DefinePlugin)
- Optionnel : Service de rechargement pour les utilisateurs avancés
Résumé des éléments clés
| Élément | Fichier | Objectif |
|---|---|---|
| Dépendances | manifest.json |
"dependencies": ["frontend", "http"] |
| URL de base | const.py |
Définit /votre-integration |
| Liste des modules | const.py |
JSMODULES avec nom, nom de fichier, version |
| Export de version | const.py |
INTEGRATION_VERSION pour WebSocket |
| Chemin statique | frontend/__init__.py |
async_register_static_paths |
| Ressource Lovelace | frontend/__init__.py |
lovelace.resources.async_create_item |
| Gestionnaire WebSocket | __init__.py |
Point de terminaison pour la vérification de version |
| Déclencheur | __init__.py |
Dans async_setup, écouter EVENT_HOMEASSISTANT_STARTED |
| Élément personnalisé | votre-carte.js |
@customElement('votre-carte') |
| Vérif. de version | votre-carte.js |
checkVersion() au chargement de la carte |
| Effacement cache | votre-carte.js |
handleReload() efface l’API caches |
| Déclaration carte | votre-carte.js |
window.customCards.push({...}) |
Mode YAML vs Mode Stockage
[!NOTE]
L’enregistrement automatique des ressources ne fonctionne qu’en mode stockage (mode par défaut de Lovelace).En mode YAML, les utilisateurs doivent ajouter manuellement la ressource dans
ui-lovelace.yaml:resources: - url: /votre-integration/votre-carte.js type: module
Le chemin statique (/votre-integration/votre-carte.js) est enregistré dans tous les cas, le fichier est donc toujours accessible.
Dépannage
Les utilisateurs signalent que la « carte ne fonctionne plus après la mise à jour »
Cause : Le cache de l’application contient l’ancienne URL du module
Solution :
- Vérifier si la notification d’incohérence de version apparaît
- Cliquer sur le bouton de rechargement dans la notification
- Si la notification n’apparaît pas, effacer manuellement le cache :
- Bureau : Rafraîchissement forcé (Ctrl+Shift+R / Cmd+Shift+R)
- Application mobile : Effacer le cache de l’application ou forcer l’arrêt de l’application
La vérification de version ne fonctionne pas
Vérifier :
- La commande WebSocket est enregistrée dans
async_setup - Le frontend appelle le bon type de commande (
votre_integration/version) - La constante de version correspond entre le backend et la sortie de construction
- Consulter la console du navigateur pour les erreurs
Le cache ne s’efface pas au rechargement
Problème : L’API caches nécessite HTTPS ou localhost
Contournement : Ajouter un repli sur un rechargement standard si caches est indisponible
Désenregistrement (Optionnel)
Pour nettoyer les ressources lors de la désinstallation de l’intégration, appelez async_unregister dans un crochet approprié (si disponible dans votre flux).
Crédits
- Structure originale du guide tirée de l’intégration marees_france
- Approche de gestion des versions inspirée par Browser Mod
- Retours de la communauté sur le comportement du cache de l’application