Je surveille visuellement mon réseau Zigbee (Linkquality & Last_seen)

Objectif : rendre visibles (et exploitables) deux infos clés pour chaque device Zigbee :

  • linkquality (santé radio) et
  • last_seen (dernière communication)

Cela afin de repérer immédiatement une sirène “perdue” ou une batterie HS, sans attendre de le découvrir par hasard.

🧭 Pourquoi j’ai mis ça en place

Avec 69 appareils Zigbee, je n’avais pas de visibilité simple pour répondre à deux questions basiques : est-ce que l’appareil capte correctement ? et est-ce qu’il a communiqué récemment ?
Sur une sirène, ce n’est pas acceptable de le découvrir “au hasard” : je veux voir immédiatement si elle est muette, hors réseau, ou en difficulté radio.

J’ai donc décidé d’afficher partout :

  • linkquality : indicateur de qualité du lien radio (LQI). (C’est une “photo” utile pour repérer les zones faibles.)
  • last_seen : date/heure de la dernière communication (ou un temps relatif : 12s, 4m, 2h…).



⚠️ Piège #1 : il y a deux “configuration.yaml”

Au début, je me suis fait avoir : dans Home Assistant, il y a bien deux fichiers différents.

  • Le fichier principal de Home Assistant : /config/configuration.yaml
  • Le fichier de Zigbee2MQTT : /config/zigbee2mqtt/configuration.yaml

Important : pour last_seen et (en pratique) l’exposition/activation par défaut, tout se configure côté Zigbee2MQTT, donc dans /config/zigbee2mqtt/configuration.yaml.

🧹 Piège #2 : l’UI de l’add-on Zigbee2MQTT peut “nettoyer” des options

J’ai eu un comportement très pénible : j’ajoutais des options avancées, je sauvegardais… puis l’interface de l’add-on semblait “simplifier” le fichier en supprimant ce qu’elle ne comprenait pas.

La méthode fiable (chez moi) :

  • J’édite directement /config/zigbee2mqtt/configuration.yaml (File Editor ou SSH)
  • Je sauvegarde
  • Je redémarre Zigbee2MQTT

🕒 Activer last_seen dans Zigbee2MQTT (obligatoire)

Par défaut, Zigbee2MQTT peut ne pas publier last_seen (valeur “disable”).
Pour l’activer, j’ajoute ceci dans la section advanced de /config/zigbee2mqtt/configuration.yaml :

advanced:
  # Ajoute "last_seen" dans les messages MQTT
  # valeurs possibles: disable, ISO_8601, ISO_8601_local, epoch
  last_seen: ISO_8601_local

Ce réglage indique à Zigbee2MQTT d’ajouter l’attribut last_seen aux messages MQTT, avec un format lisible en heure locale.

Formats utiles (pour choisir)

  • ISO_8601_local : lisible + fuseau local (le plus pratique au quotidien).
  • ISO_8601 : lisible, mais en UTC (utile pour centraliser tout en UTC).
  • epoch : timestamp Unix (pratique pour scripts/traitements).

🧩 Activer last_seen et linkquality “par défaut” dans Home Assistant (facultatif)

Une fois last_seen activé côté Zigbee2MQTT, il reste un point : dans Home Assistant, ces entités sont souvent classées “diagnostic”, donc elles peuvent être créées mais désactivées automatiquement.

Pour que chaque nouvel appareil Zigbee ajouté arrive directement “prêt à superviser”, j’ajoute ceci dans /config/zigbee2mqtt/configuration.yaml :

device_options:
  homeassistant:
    last_seen:
      enabled_by_default: true
    linkquality:
      enabled_by_default: true

Puis je redémarre Zigbee2MQTT.

Important : impact sur les appareils déjà installés

Ce réglage aide surtout les nouveaux appareils (ou les appareils redécouverts).
Pour les devices déjà présents, il faut souvent activer manuellement les entités correspondantes dans Home Assistant.

🗄️ Base de données : garder l’info visible sans “polluer” l’historique

Le “risque” n’est pas tellement Zigbee2MQTT (il ajoute juste un champ timestamp dans les messages), mais plutôt Home Assistant si on enregistre chaque update de last_seen dans la base : ça peut générer beaucoup d’écritures avec beaucoup de capteurs.

Mon choix : je veux voir ces valeurs partout, mais je n’ai pas besoin de graphes minute par minute. J’exclus donc ces entités du recorder.

Ce que je mets dans /config/recorders.yaml

recorder:
  exclude:
    entity_globs:
      - sensor.*_last_seen
      - sensor.*_linkquality

Après ça, je redémarre Home Assistant : les valeurs restent visibles en temps réel, mais je limite fortement la création d’historique inutile.

🗺️ Supervision visuelle : un plan “radio + fraîcheur”

Je ne voulais pas “une entité perdue dans une liste” : je voulais une supervision visuelle, immédiatement lisible.

Principe :

  • Chaque appareil Zigbee apparaît sous forme d’un petit rectangle sur un plan.
  • Le nombre affiché en gros = Linkquality.
  • Le temps en bas à droite = Last_seen en relatif (s / m / h / d).
  • Le cadre noir = routeur (variable router: 1), pour reconnaître vite les routeurs.

🧰 La carte “decluttering” (réutilisable)

J’utilise la magie de la carte decluttering_templates que j’avais décrite dans cette page il y a 3 ans :

Ainsi decluttering_templates + custom:decluttering-card pour réutiliser le même composant partout, en ne changeant que 4 variables.

Je défini tout simplement 4 éléments qui sont différents pour chaque appareils :

  • zone: c’est le capteur qui va être traité
  • top: sa position depuis le haut
  • left: sa position depuis la gauche
  • router: 1 si c’est un routeur sinon 0

Exemple : page Dashboard très simple

type: picture-elements
image: /local/maison/1920x1000/off_0800.png
elements:
  - type: custom:decluttering-card
    template: linkquality
    variables:
      - zone: sensor.lumiere_chambre_parents
      - top: 20%
      - left: 35%
      - router: 1

  - type: custom:decluttering-card
    template: linkquality
    variables:
      - zone: sensor.fenetre_de_la_chambre_des_parents
      - top: 15%
      - left: 37%
      - router: 0

Le template linkquality (le “gros morceau”)

decluttering_templates:
  linkquality:
    default:
      - top: 5%
      - left: 95%
      - router: 0
    element:
      type: custom:button-card
      entity: |
        [[[
          return `[[zone]]_linkquality`;
        ]]]
      show_icon: false
      name: |
        [[[
          return '[[zone]]'
                  .replace(/^sensor\\./i, '')
                  .replace(/_/g, ' ')
                  .trim();
        ]]]
      show_state: true
      show_units: false
      custom_fields:
        lastseen: |
          [[[
            const obj = states[`[[zone]]_last_seen`];
            if (!obj || !obj.state || obj.state === 'unavailable') return '!!';
            const last = new Date(obj.state);
            if (isNaN(last.getTime())) return '!!';
            const now = new Date();
            const seconds = Math.floor((now - last) / 1000);

            if (seconds < 60) return `${seconds}s`;
            const minutes = Math.floor(seconds / 60);
            if (minutes < 60) return `${minutes}m`;
            const hours = Math.floor(minutes / 60);
            if (hours < 24) return `${hours}h`;
            const days = Math.floor(hours / 24);
            return `${days}d`;
          ]]]
      styles:
        card:
          - border: |
              [[[ return ([[router]] == 1) ? '2px solid black' : 'none'; ]]]
          - box-shadow: none
          - position: relative
          - border-radius: 8px
          - min-width: 100px
          - height: 45px
          - display: flex
          - flex-direction: column
          - justify-content: center
          - align-items: center
          - color: white
        name:
          - font-size: 10px
          - line-height: '1'
          - padding: 0
          - margin: 0
          - text-align: center
          - color: white
          - flex: 1
          - max-width: 95px
          - word-wrap: break-word
          - overflow: hidden
          - white-space: normal
        state:
          - font-size: 15px
          - font-weight: bold
          - line-height: '1'
          - padding: 0
          - margin: 0
          - text-align: center
          - color: white
          - flex: 1
        custom_fields:
          lastseen:
            - position: absolute
            - bottom: 0px
            - right: 0px
            - font-size: 10px
            - color: black
            - padding: 2px 4px
            - border-radius: 4px
            - box-shadow: 0 1px 3px rgba(0,0,0,0.3)
            - background: |
                [[[
                  const obj = states[`[[zone]]_last_seen`];
                  if (!obj || !obj.state) return 'rgba(128,128,128,0.5)';
                  const last = new Date(obj.state);
                  const now = new Date();
                  const seconds = Math.floor((now - last) / 1000);
                  if (seconds < 1200) return 'rgba(0,255,0,0.9)';       // Vert <20min
                  if (seconds < 1800) return 'rgba(255,255,0,0.9)';     // Jaune 20-30min
                  if (seconds < 7200) return 'rgba(255,165,0,0.9)';     // Orange <2h return 'rgba(255,0,0,0.9)'; // Rouge >2h
                ]]]
      state:
        - value: unavailable
          styles:
            card:
              - background: rgba(255,0,0,0.3)
        - operator: '>'
          value: 200
          styles:
            card:
              - background: rgba(0,180,0,0.3)
        - operator: '>'
          value: 150
          styles:
            card:
              - background: rgba(34,255,34,0.3)
        - operator: '>'
          value: 130
          styles:
            card:
              - background: rgba(255,255,0,0.3)
        - operator: '>='
          value: 80
          styles:
            card:
              - background: rgba(255,165,0,0.3)
        - operator: '<'
          value: 80
          styles:
            card:
              - background: rgba(255,107,53,0.3)
      style:
        top: '[[top]]'
        left: '[[left]]'

Simplement, que fait ce code ?

Ce template définit un modèle unique de carte que tu réutilises pour tous tes appareils Zigbee, en ne changeant que la variable [[zone]].

La propriété entity est construite dynamiquement en JavaScript ([[[ … ]]]) pour pointer vers l’entité [[zone]]_linkquality, ce qui permet d’afficher automatiquement la qualité de lien du device (valeur LQI remontée par Zigbee2MQTT ou ZHA).

Le name est lui aussi généré côté client : on retire le préfixe sensor. puis on remplace les _ par des espaces et on fait un trim(), ce qui produit un libellé lisible à partir de l’ID de l’entité.

Le champ personnalisé lastseen lit l’entité [[zone]]_last_seen dans states[], convertit sa valeur en objet Date, puis calcule la différence avec l’heure actuelle pour afficher un temps relatif (s, m, h, d). Si la valeur est vide, unavailable ou une date invalide, il affiche !! au lieu de laisser apparaître une valeur incohérente.

Le style du badge lastseen en bas à droite utilise une fonction JavaScript qui renvoie une couleur RGBA différente selon l’âge de la dernière communication (gris si pas de valeur, puis vert → jaune → orange → rouge en fonction du nombre de secondes écoulées), ce qui encode visuellement la fraîcheur des données.

La section state: du button-card définit plusieurs seuils sur la valeur de LinkQuality : indisponible, puis tranches >200, >150, >130, >=80, <80, chacune appliquant un fond coloré spécifique à la carte, ce qui transforme une valeur numérique en indicateur de qualité radio immédiatement lisible.

Les propriétés de mise en forme (flex, min-width, height, border-radius, position absolute du badge, etc.) assurent un rendu compact et uniforme, avec une bordure noire optionnelle quand [[router]] == 1 pour identifier visuellement les routeurs Zigbee par rapport aux simples terminaux.

🧠 Ajouts utiles : infos “santé” globales Zigbee2MQTT

Voici le code de cette carte

type: picture-elements
image: /local/maison/1920x1000/off_0800.png
elements:
  - type: custom:mod-card
    card:
      type: entities
      title: Zigbee2MQTT
      show_header_toggle: false
      entities:
        - entity: switch.zigbee2mqtt_bridge_permit_join
          name: Autoriser inclusion
        - entity: button.zigbee2mqtt_bridge_restart
          name: Redémarrer
        - entity: select.zigbee2mqtt_bridge_log_level
          name: Niveau de log
        - entity: binary_sensor.zigbee2mqtt_bridge_connection_state
          name: État de connexion
        - type: custom:template-entity-row
          name: Lancé depuis
          state: >
            {% if states('binary_sensor.zigbee2mqtt_bridge_connection_state')
            not in ['unavailable', 'unknown'] %}
              {{ ((now() - states('binary_sensor.zigbee2mqtt_bridge_connection_state').last_changed).total_seconds() / 3600) | round(1) }} h
            {% else %}
              Non disponible
            {% endif %}
        - entity: sensor.zigbee2mqtt_bridge_coordinator_version
          name: Version coordinateur
        - entity: sensor.zigbee2mqtt_bridge_version
          name: Version Zigbee2MQTT
        - entity: sensor.nombre_de_capteurs_linkq_en_erreur
          name: Injoignables
          icon: mdi:alert-circle
        - entity: sensor.nombre_de_capteurs_linkq
          name: Total
          icon: mdi:devices
        - entity: sensor.zigbee_linkquality_moyenne
          name: Moyenne LinkQuality
          icon: mdi:signal
    style:
      top: 35%
      left: 75%
      width: 450px

J’ai ajouté un capteur pour calculer la moyenne de Linkquality

Ce capteur s’ajoute dans le fichier templates.yaml, voici son code :

- name: "zigbee_linkquality_moyenne"
  unique_id: zigbee_linkquality_moyenne
  unit_of_measurement: "LQI"
  state_class: measurement
  state: >-
    {% set linkqualities = states.sensor 
       | selectattr('entity_id', 'search', '_linkquality')
       | selectattr('state', 'is_number')
       | map(attribute='state') | map('float') | list %}
    {% if linkqualities | length > 0 %}
      {{ (linkqualities | sum / linkqualities | length) | round(1) }}
    {% else %} 0 {% endif %}

J’ai ajouté un capteur pour calculer le nombre de capteurs

- name: "Nombre de capteurs Linkq"
  unique_id: sensor.nombre_capteurs_linkq
  state: >-
    {% set lq_sensors = states.sensor
       | selectattr('entity_id', 'search', '_linkquality$')
       | list %}
    {{ lq_sensors | count }}
  icon: mdi:signal-variant
  unit_of_measurement: "capteurs"

J’ai ajouté un capteur pour calculer le nombre d’appareils en erreur

- name: "Nombre de capteurs Linkq en erreur"
  unique_id: sensor.nombre_capteurs_linkq_erreur
  state: >-
    {% set lq_sensors = states.sensor
       | selectattr('entity_id', 'search', '_linkquality$')
       | selectattr('state', 'in', ['unavailable', 'unknown', 'None'])
       | list %}
    {{ lq_sensors | count }}
  icon: mdi:alert-circle
  unit_of_measurement: "capteurs"

J’ai ajouté un indicateur sur le dasboard principal pour signaler le nombre en erreur

Tout d’abord ce template pour afficher xx en erreur / yy au total

- name: "zigbee_injoignables_synthese"
  unique_id: sensor.zigbee_injoignables_synthese
  state: >-
    {{ states('sensor.nombre_de_capteurs_linkq_en_erreur') }}/{{ states('sensor.nombre_de_capteurs_linkq') }}
  icon: mdi:alert-circle

Et dans mon picture-elements, j’ajoute :

- type: conditional
  conditions:
    - condition: state
      entity: sensor.nombre_de_capteurs_linkq_en_erreur
      state_not: "0"
  elements:
    - type: custom:button-card
      entity: sensor.zigbee_injoignables_synthese
      icon: mdi:zigbee
      show_name: false
      show_state: true
      style:
        top: 5%
        left: 5%
        transform: translate(-50%, -50%)
      styles:
        state:
          - color: red
          - font-weight: 700
          - font-size: 16px
        icon:
          - color: red
          - width: 20px
          - height: 20px
        card:
          - border-radius: 999px
          - padding: 4px 6px
          - background: transparent
          - box-shadow: none

Ce bloc sert à afficher un petit indicateur rouge « Zigbee en erreur » sur le dashboard plan uniquement quand il y a au moins un capteur problématique.

C’est un élément conditional dans une carte picture-elements : il ne montre son contenu que si sensor.nombre_de_capteurs_linkq_en_erreur est différent de « 0 » (donc si au moins un capteur est injoignable ou en erreur de LinkQuality).

Quand la condition est vraie, il affiche un custom:button-card basé sur sensor.zigbee_injoignables_synthese, qui contient probablement un texte du style « 3 en erreur » ou similaire.

Le bouton est placé en haut à gauche du plan (5 % / 5 %), centré grâce au transform: translate(-50%, -50%), avec un fond transparent et sans ombre pour ne garder que l’icône Zigbee et le texte.

L’icône Zigbee et l’état sont stylés en rouge, en gras et en taille 16 px, avec un border-radius: 999px pour donner un aspect de pastille/étiquette, ce qui en fait un avertissement visuel discret mais bien visible tant qu’il existe des capteurs en erreur.

Pour savoir qui est en erreur

Pour avoir d’un coup d’œil la liste des équipements en erreur, voici les codes nécessaires.

La déclaration des templates

- name: "Nombre de capteurs Linkq en erreur"
  unique_id: sensor.nombre_capteurs_linkq_erreur
  state: >-
    {% set lq_sensors = states.sensor
       | selectattr('entity_id', 'search', '_linkquality$')
       | selectattr('state', 'in', ['unavailable', 'unknown', 'None'])
       | list %}
    {{ lq_sensors | count }}
  icon: mdi:alert-circle
  unit_of_measurement: "capteurs"

- name: "Liste capteurs Linkquality en erreur"
  unique_id: sensor.liste_capteurs_linkquality_erreur
  state: >-
    {% set lq_sensors = states.sensor
       | selectattr('entity_id', 'search', '_linkquality$')
       | selectattr('state', 'in', ['unavailable', 'unknown', 'None'])
       | map(attribute='entity_id')
       | map('regex_replace', 'sensor\\\\\\\\.(.*)_linkquality$', '\\\\\\\\1')
       | list %}
    {% set count = lq_sensors | length %}
    {% if count == 0 %}
      Aucun
    {% elif count <= 5 %}
      {{ lq_sensors | join(', ') }}
    {% else %}
      {{ lq_sensors[:5] | join(', ') ~ ' + ' ~ count }}
    {% endif %}
  attributes:
    count: >-
      {% set lq_sensors = states.sensor
         | selectattr('entity_id', 'search', '_linkquality$')
         | selectattr('state', 'in', ['unavailable', 'unknown', 'None'])
         | map(attribute='entity_id')
         | map('regex_replace', 'sensor\\\\\\\\.(.*)_linkquality$', '\\\\\\\\1')
         | list %}
      {{ lq_sensors | length }}
    full_list: >-
      {% set lq_sensors = states.sensor
         | selectattr('entity_id', 'search', '_linkquality$')
         | selectattr('state', 'in', ['unavailable', 'unknown', 'None'])
         | map(attribute='entity_id')
         | map('regex_replace', 'sensor\\\\\\\\.(.*)_linkquality$', '\\\\\\\\1')
         | list %}
      {{ lq_sensors | join(', ') | default('Aucun') }}
  icon: mdi:alert-circle

Et sur le Dasboard, toujours dans le picture-elements

type: picture-elements
image: /local/maison/1920x1000/off_0800.png
elements:
      - type: custom:mod-card
        card:
          type: entities
          title: Capteurs Zigbee en erreur
          entities:
            - type: custom:template-entity-row
              entity: sensor.liste_capteurs_linkquality_en_erreur
              name: Liste
              state: >
                {% set s = states('sensor.liste_capteurs_linkquality_en_erreur')
                %} {% if s not in ['unknown', 'unavailable', ''] %}
                  {{ s.replace(',', '\\\\n') }}
                {% else %}
                  Aucun
                {% endif %}
        style:
          top: 80%
          left: 75%
          width: 450px

✅ Récapitulatif simple (les 3 blocs indispensables)

1. Zigbee2MQTT : activer last_seen

advanced:
  last_seen: ISO_8601_local

2. Zigbee2MQTT : activer par défaut dans HA (optionnel mais pratique)

device_options:
  homeassistant:
    last_seen:
      enabled_by_default: true
    linkquality:
      enabled_by_default: true

3. Home Assistant : ne pas enregistrer ces entités dans la DB

recorder:
  exclude:
    entity_globs:
      - sensor.*_last_seen
      - sensor.*_linkquality

🎯 Ce que ça change au quotidien

  • Je vois immédiatement les zones faibles (linkquality bas) et je sais quoi renforcer (routeur, placement, canal, etc.).
  • Je sais exactement quand une sirène a cessé de parler (last_seen), donc je ne “suppose” plus.
  • Je garde une base de données saine (pas d’historique inutile sur des capteurs qui bougent tout le temps).



Besoin de précisions ? des questions ?

Cette discussion (si elle est encore ouverte) est disponible :

https://forum.hacf.fr/t/retex-je-surveille-visuellement-mon-reseau-zigbee-linkquality-last-seen/76732