Guide Développeur : Carte Lovelace Embarquée dans une Intégration Home Assistant

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 :

  1. Déclaration de dépendance dans manifest.json
  2. Enregistrement d’un chemin HTTP statique pour servir les fichiers JavaScript
  3. 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"
}

:red_exclamation_mark: Important
Sans les dépendances frontend et http, 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

:warning: AVERTISSEMENT
L’enregistrement doit se faire dans async_setup, pas dans async_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

:warning: PROBLÈME CRITIQUE
Le paramètre ?v=X.X.X dans 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 :

  1. Le backend met à jour l’URL de la ressource avec une nouvelle version
  2. MAIS la page en cache fait toujours référence à l’ANCIENNE URL
  3. 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 :

  1. Le backend expose la version via une commande WebSocket
  2. Le frontend vérifie la version au chargement de la carte
  3. La détection d’une incohérence déclenche une notification utilisateur
  4. 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 caches avant 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 :

  1. Vérifier si la notification d’incohérence de version apparaît
  2. Cliquer sur le bouton de rechargement dans la notification
  3. 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 :

  1. La commande WebSocket est enregistrée dans async_setup
  2. Le frontend appelle le bon type de commande (votre_integration/version)
  3. La constante de version correspond entre le backend et la sortie de construction
  4. 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
6 « J'aime »

Guide modifié ce jour suite à suggestion sur les soucis de cache et comment les solutionner complètement.
Les snippets ont été modifiés aussi.

Salut

Sujet très intéressant, je vais m’en inspiré pour merger mon intégration avec ces cartes.
Merci!

Du coup, je me demande s’il est possible d’avoir aussi des blueprints pour les automatisations.
Crois-tu que ce soit réalisable ?

1 « J'aime »

Il me semble qu’il suffit que tu ai un dossier blueprint dans le dossier de l’integration pour que HA les detecte.
A vérifier mais je crois bien.

1 « J'aime »