[Tuto]Majs en cascade pour ESPHome, carte dynamique, suivi du statut et quasi temps réel

Hello,

Quand on commence à avoir pas mal de modules ESPHome à la maison, cliquer sur "Mettre à jour" sur chaque appareil peut vite devenir fastidieux. Et lancer un "Tout mettre à jour" simultanément peut parfois mettre à genoux le serveur ou saturer le réseau Wi-Fi lors des reconnexions simultanées.

Pour résoudre ça, je vous partage mon système de mise à jour en cascade (un par un) entièrement automatisé, accompagné d'une carte dynamique de type "centre de contrôle".

Les photos ont étés faites sur plusieurs versions de carte et d'automation ), donc il peut y avoir des incohérence ( suivant les versions, la v11 étant la dernière )

:sparkles: Ce que ça fait globalement :

Intelligence : L'automatisation détecte toute seule les modules ESPHome qui ont une mise à jour disponible et les trie par ordre alphabétique.

Séquentiel sécurisé : Elle lance la mise à jour du premier module, attend 1 minute, calcule le temps que l'opération a pris, puis passe au suivant.

Visuel : La carte affiche une barre de progression en temps réel (pourcentage, étape X/Y) et réorganise dynamiquement un tableau : l'ESP en cours de flash remonte automatiquement tout en haut (:counterclockwise_arrows_button:), ceux en attente restent au milieu (:hourglass_not_done:), et ceux terminés basculent en bas avec leur statut (:white_check_mark: ou :cross_mark:) accompagné de la durée précise du flash.

Contrôles intégrés : Un clic lance la boucle, un appui long stoppe tout en cas de pépin, et un double-tap nettoie le rapport.

:clipboard: 1. Les prérequis (Les Entrées / Inputs), entités firmware ou micrologiciel

Avant de commencer, vous devez créer deux entrées de type texte (Paramètres > Appareils et services > Entrées > Créer une entrée > Texte) :

input_text.esphome_maj_status (Sert à stocker le statut de la session sous forme de chaîne découpée).

input_text.esphome_maj_durees (Sert de dictionnaire pour lier chaque ESP à son temps de flash sous la forme entity:temps).

Il faut impérativement que les entités firmware ou micrologiciel soient activées dans chaque esp

  - variables:
      tous_les_esps: |-
        {{ states.update 
           | selectattr('state', 'eq', 'on') 
           | selectattr('entity_id', 'search', '(_firmware|_micrologiciel)') 
           | map(attribute='entity_id') | list | sort }}
      total_esp: "{{ tous_les_esps | count }}"

:robot: 2. L'Automatisation

Voici le code de l'automatisation. Elle gère la réinitialisation, le filtrage des entités et le calcul précis du temps écoulé.

Code de l'automation
alias: "ESPHome : Maj en cascade" 
description: "Mises à jour en cascade avec stockage du statut et des durées"
triggers: []
actions:
  - action: input_text.set_value
    data:
      entity_id: input_text.esphome_maj_status
      value: "idle|0|0|"
  - action: input_text.set_value
    data:
      entity_id: input_text.esphome_maj_durees
      value: ""
  - variables:
      tous_les_esps: |-
        {{ states.update 
           | selectattr('state', 'eq', 'on') 
           | selectattr('entity_id', 'search', '(_firmware|_micrologiciel)') 
           | map(attribute='entity_id') | list | sort }}
      total_esp: "{{ tous_les_esps | count }}"
  - action: input_text.set_value
    data:
      entity_id: input_text.esphome_maj_status
      value: "running|0|{{ total_esp }}|"
  - repeat:
      for_each: "{{ tous_les_esps }}"
      sequence:
        - action: input_text.set_value
          data:
            entity_id: input_text.esphome_maj_status
            value: "running|{{ repeat.index }}|{{ total_esp }}|{{ repeat.item }}|{{ now().timestamp() | int }}"
        - action: update.install
          target:
            entity_id: "{{ repeat.item }}"
        - delay:
            minutes: 1
        - action: input_text.set_value
          data:
            entity_id: input_text.esphome_maj_durees
            value: >-
              {% set debut = states('input_text.esphome_maj_status').split('|')[4] | int %} 
              {% set duree = [0, (now().timestamp() | int - debut - 60)] | max %}
              {% set actuel = states('input_text.esphome_maj_durees') %} 
              {{ (actuel ~ ',' if actuel | length > 0 else '') ~ repeat.item ~ ':' ~ duree }}
  - action: input_text.set_value
    data:
      entity_id: input_text.esphome_maj_status
      value: "done|{{ total_esp }}|{{ total_esp }}|"
mode: single

:flower_playing_cards: 3. La Carte
Pour l'affichage, il vous faudra la célèbre carte personnalisée button-card (disponible sur HACS). Le code JavaScript intégré s'occupe de parser les données et de trier la liste en direct.

Code de la carte
type: custom:button-card
entity: input_text.esphome_maj_status
triggers_update: all
name: Mises à jour ESPHome en cascade
icon: mdi:cpu-64-bit
tap_action:
  action: call-service
  service: automation.trigger
  service_data:
    entity_id: automation.esphome_maj_en_cascade
hold_action:
  action: call-service
  service: automation.turn_off
  service_data:
    entity_id: automation.esphome_maj_en_cascade
double_tap_action:
  action: call-service
  service: input_text.set_value
  service_data:
    entity_id: input_text.esphome_maj_status
    value: idle|0|0|
styles:
  card:
    - padding: 16px
    - cursor: pointer
  grid:
    - grid-template-areas: "\"i n\" \"i subtitle\" \"actions actions\" \"table table\""
    - grid-template-columns: auto 1fr
  name:
    - justify-self: start
    - font-weight: bold
    - font-size: 16px
  icon:
    - width: 28px
    - grid-row: span 2
  custom_fields:
    subtitle:
      - justify-self: start
      - font-size: 12px
      - color: window-list
      - margin-top: 2px
    actions:
      - justify-self: start
      - width: 100%
      - margin-top: 6px
      - margin-bottom: 4px
    table:
      - justify-self: start
      - width: 100%
      - margin-top: 12px
state:
  - operator: template
    value_template: |
      [[[
        const val = states['input_text.esphome_maj_status']?.state || '';
        return val.startsWith('running');
      ]]]
    styles:
      icon:
        - color: orange
        - animation: blink 2s linear infinite
      name:
        - color: orange
custom_fields:
  subtitle: |
    [[[
      const allEspsOn = Object.keys(states).filter(key =>
        key.startsWith('update.') &&
        (key.endsWith('_firmware') || key.endsWith('_micrologiciel')) &&
        states[key].state === 'on'
      ).length;
      return `Modules à mettre à jour : <span style="font-weight:bold;color:var(--primary-color);">${allEspsOn}</span>`;
    ]]]
  actions: |
    [[[
      return `<div style="color:#555;line-height:2;">
        <span style="font-size:14px;">👆 Tap : lancer les majs &nbsp;|&nbsp; ✋ Hold : stopper les majs</span><br>
        <span style="font-size:14px;">👆👆 Double tap : effacer le rapport</span>
      </div>`;
    ]]]
  table: |
    [[[
      const val = states['input_text.esphome_maj_status']?.state || '';
      const parts = val.split('|');
      const status = parts[0] || '';
      const idx = parseInt(parts[1]) || 0;
      const total = parseInt(parts[2]) || 0;
      const enCours = parts[3] || '';

      const dureesRaw = states['input_text.esphome_maj_durees']?.state || '';
      const dureesMap = {};
      if (dureesRaw && dureesRaw !== 'unknown') {
        dureesRaw.split(',').forEach(function(entry) {
          const p = entry.split(':');
          if (p.length === 2) dureesMap[p[0].trim()] = parseInt(p[1]) || 0;
        });
      }

      const esps = Object.keys(states).filter(key =>
        key.startsWith('update.') &&
        (key.endsWith('_firmware') || key.endsWith('_micrologiciel')) &&
        states[key].attributes.installed_version !== undefined &&
        states[key].attributes.latest_version !== undefined &&
        (states[key].attributes.release_url || '').includes('esphome')
      ).sort();

      const aFaire = esps.filter(k => states[k].state === 'on');

      if (status === '' || status === 'idle') {
        if (aFaire.length === 0) {
          return `<div style="color:grey;font-style:italic;padding-top:8px;">Aucune mise à jour disponible.</div>`;
        }
        return `<div style="color:grey;font-style:italic;padding-top:8px;">Cliquez pour lancer. Appui long pour stopper.</div>`;
      }

      const enCoursListe = enCours && states[enCours]?.state === 'on' ? [enCours] : [];
      const aFaireListe = esps.filter(k => states[k].state === 'on' && k !== enCours);
      const faitListe = esps.filter(k => states[k].state === 'off');
      const listeAffichee = [...enCoursListe, ...aFaireListe, ...faitListe];

      let headerBar = '';
      if (status === 'running' || status === 'done') {
        const pct = total > 0 ? ((idx / total) * 100).toFixed(1) : '0.0';
        const barColor = status === 'done' ? '#4caf50' : 'orange';
        const txtStatus = status === 'done' ? '✅ Terminé' : '🔄 En cours';
        headerBar = `
          <div style="padding:4px 0 12px 0;">
            <div style="display:flex;justify-content:space-between;font-size:12px;color:#aaa;margin-bottom:4px;">
              <span>${txtStatus} (${idx}/${total})</span>
              <span style="color:${barColor};font-weight:bold;">${pct}%</span>
            </div>
            <div style="width:100%;background:#333;height:8px;border-radius:4px;overflow:hidden;">
              <div style="width:${pct}%;background:${barColor};height:100%;border-radius:4px;transition:width 0.5s;"></div>
            </div>
          </div>`;
      }

      let rows = '';
      listeAffichee.forEach(function(key) {
        const attr = states[key].attributes;
        const nom = (attr.friendly_name || key)
          .replace(' Micrologiciel', '')
          .replace(' firmware', '');
        const etat = states[key].state;
        const depart = attr.installed_version || '?';
        const arrivee = attr.latest_version || '?';

        const isInProgress = status === 'running' && enCours === key;
        const isDone = etat === 'off';
        const isSuccess = isDone && depart === arrivee;
        const isFailed = isDone && depart !== arrivee;

        let dureeStr = '';
        if (isDone) {
          const d = dureesMap[key] || 0;
          if (d > 0) {
            dureeStr = d >= 60
              ? `${Math.floor(d/60)}m${d%60}s`
              : `${d}s`;
          }
        }

        const statutDisplay = isInProgress ? '🔄' : isSuccess ? '✅' : isFailed ? '❌' : '⏳';
        const color = isInProgress ? 'orange' : isSuccess ? '#4caf50' : isFailed ? '#f44336' : '#444';
        const nameColor = isInProgress ? 'orange' : isSuccess ? '#4caf50' : isFailed ? '#f44336' : '#fff';
        const bg = isInProgress ? 'rgba(255,165,0,0.06)' : 'transparent';

        rows += `
          <tr style="border-bottom:1px solid #1a1a1a;background:${bg};">
            <td style="padding:6px 0;font-weight:500;color:${nameColor};">${nom}</td>
            <td style="color:${color};font-size:14px;text-align:center;">${statutDisplay}</td>
            <td style="color:#666;font-size:11px;text-align:center;">${depart}</td>
            <td style="color:#aaa;font-size:11px;text-align:center;">${isDone ? arrivee : '→ ' + arrivee}</td>
            <td style="color:#555;font-size:11px;text-align:center;">${dureeStr}</td>
          </tr>`;
      });

      return `
        <div>
          ${headerBar}
          <table style="width:100%;border-collapse:collapse;text-align:left;font-size:13px;">
            <thead>
              <tr style="border-bottom:2px solid #333;color:#555;font-size:11px;text-transform:uppercase;">
                <th style="padding:4px 0;">ESP</th>
                <th style="text-align:center;width:40px;">État</th>
                <th style="text-align:center;">Départ</th>
                <th style="text-align:center;">Arrivée</th>
                <th style="text-align:center;">Durée</th>
              </tr>
            </thead>
            <tbody>${rows}</tbody>
          </table>
        </div>`;
    ]]]

Peek 05-07-2026 13-33

On lance, on fait autre chose et on va voir au besoin de temps en temps ou ça en est :slight_smile:

Si vous testez et constatez un bug, remontez à la suite :wink:

cdt

salut,
Donc ça lance plusieurs mises à jour d'un coup quand même ?
Même si elles sont décalées ?
Ça serait pas intéressant de se baser sur la version du firmware, lancer une maj d'un ESP et tant que la version du firmware n'est pas identique à la version d'ESPHome, ça ne passe pas au suivant ?

Hello,

Oui c'est ça, j'ai maj esphome ce matin 2026.6.3 > 2026.6.4,

Je lance la carte, et ça maj tous les esp un à un à la suite de la 2026.6.3 jusqu'à ce qu'ils soient en 2024.6.4 :slight_smile: si c'est plus explicite

Esp1 2026.6.3 > 2026.6.4
puis
Esp2 2026.6.1 > 2026.6.4
puis
Esp3 2026.5.3 > 2026.6.4
puis
Esp3 2026.6.2 > 2026.6.4
...

on peut stopper les majs en cours, ça reprend le calcul de % sur les esp restant

on démarre 2 esp arrêtés, on clique sur la carte, ça reprend le rapport complet avec toutes les majs déjà faites

les 3 du haut ont étés démarré après et au clic pour lancer la carte / automation, il recharge tout
33% parce que 1 esp sur 3 au clic

cdt

ok, super :wink:
Il me reste plus qu'à tester la compilation à distance.

Re,

Je n'ai pas précisé, il faut impérativement que les entités firmware ou micrologiciel soient activées dans chaque esp

  - variables:
      tous_les_esps: |-
        {{ states.update 
           | selectattr('state', 'eq', 'on') 
           | selectattr('entity_id', 'search', '(_firmware|_micrologiciel)') 
           | map(attribute='entity_id') | list | sort }}
      total_esp: "{{ tous_les_esps | count }}"

Je vais l'ajouter dans le premier message

cdt

Re,

A venir sous cette forme ou une autre :slight_smile:

Peek 05-07-2026 20-36

Peek 05-07-2026 20-50

cdt

Bonjour
A priori la nouvelle version de ESPHOME gère les files d'attente :