I visually monitor my Zigbee network (Linkquality & Last_seen)

Objective: make visible (and usable) two key pieces of information for each Zigbee device:

  • linkquality (radio health) and
  • last_seen (last communication)

This is to immediately detect a “lost” siren or HS battery, without waiting to discover it by chance.

🧭 Why I put this in place

With 69 Zigbee devices, I had no simple visibility to answer two basic questions: Does the device capture correctly? and has he communicated recently?
On a siren, it is not acceptable to find out “at random”: I want to see immediately if she's mute, off-grid, or in radio trouble.

I have therefore decided to display everywhere:

  • linkquality : Radio Link Quality Indicator (LQI). (This is a useful “photo” to identify weak areas.)
  • last_seen : date/time of last communication (or relative time: 12s, 4m, 2h …).



⚠ Trap #1 : there are two “configuration.yaml”

At first, I was told: In Home Assistant, there are two different files.

  • The main file of Home Assistant: /config/configuration.yaml
  • The Zigbee2MQTT file: /config/zigbee2mqtt/configuration.yaml

Important: for last_seen and (in practice) the default exposure/activation, everything is configured sideways Zigbee2MQTT, i.e. in /config/zigbee2mqtt/configuration.yaml.

🧹 Trap #2 : Zigbee2MQTT add-on UI can “clean” options

I have behaved very painfully: I added advanced options, saved … and then the add-on interface seemed to “simplify” the file by deleting what it did not understand.

The reliable method (at home):

  • I edit directly /config/zigbee2mqtt/configuration.yaml (File Editor or SSH)
  • I save
  • I am restarting Zigbee2MQTT

🕒 Activate last_seen in Zigbee2MQTT (mandatory)

By default, Zigbee2MQTT may not publish last_seen (disable value).
To activate it, I add this in the section advanced from /config/zigbee2mqtt/configuration.yaml :

advanced: # Adds "last_seen" in MQTT messages # possible values: disable, ISO_8601, ISO_8601_local, epoch last_seen: ISO_8601 _local

This setting tells Zigbee2MQTT to add the attribute last_seen MQTT messages, with a local time readable format.

Useful formats (to choose)

  • ISO_8601 _local : legible + local spindle (the most practical on a daily basis).
  • ISO_8601 : readable, but in UTC (useful to centralize everything in UTC).
  • epoch : timestamp Unix (practice for scripts/processing).

🧩 Activate last_seen and linkquality “default” in Home Assistant (optional)

Once last_seen activated on the Zigbee2MQTT side, one point remains: in Home Assistant, these entities are often classified as “diagnostic”, so they can be created but disabled automatically.

In order for each new Zigbee device added to arrive directly “ready to supervise”, I add this in /config/zigbee2mqtt/configuration.yaml :

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

Then I restart Zigbee2MQTT.

Important: impact on devices already installed

This setting especially helps the new devices (or rediscovered devices).
For devices already present, it is often necessary to manually activate the corresponding entities in Home Assistant.

🗄 Database: keep the info visible without “polluting” the history

The “risk” is not so much Zigbee2MQTT (it just adds a timestamp field in the messages), but rather Home Assistant if you save each update of last_seen in the database: It can generate a lot of writing with a lot of sensors.

My choice: I want see these values everywhere, but I do not need minute-by-minute graphs. I therefore exclude those entities from the recorder.

What I put in /config/recorders.yaml

recorder: exclude: entity_globs: - sensor.*_last _seen - sensor.*_linkquality

After that, I restart Home Assistant: the values remain visible in real time, but I strongly limit the creation of unnecessary history.

🗺 Visual supervision: a “radio + freshness” plan

I did not want “an entity lost in a list”: I wanted to visual supervision, immediately legible.

Principle:

  • Each Zigbee device appears as a small rectangle on a plane.
  • The number displayed in bulk = Linkquality.
  • The time at the bottom right = Last_seen relative (s/m/h/d).
  • The black frame = router (variable router: 1), to quickly recognize routers.

🧰 The decluttering card (reusable)

I use the magic of the decluttering _templates card that I described on this page three years ago:

Thus decluttering _templates + custom:decluttering-card to reuse the same component everywhere, changing only 4 variables.

I simply define four elements that are different for each device:

  • area: it is the sensor that will be processed
  • top: its position from the top
  • left: its position from the left
  • router: 1 if it is a router otherwise 0

Example: very simple Dashboard page

Type: picture-elements image: /local/house/1920x1000/off_0800.png elements: - type: custom:decluttering-card template: linkquality variables: - area: sensor.light_chambre_parents - top: 20% - left: 35% - router: 1 - type: custom:decluttering-card template: linkquality variables: - area: sensor.fenetre_de_la_chambre_des_parents - top: 15% - left: 37% - router: 0

The template linkquality (the “big piece”)

decluttering _templates: linkquality: default: - top: 5% - left: 95% - router: 0 elements: 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)'; // Green <20min if (seconds < 1800) return 'rgba(255,255,0,0.9)'; // Yellow 20-30min if (seconds < 7200) return 'rgba(255,165,0,0.9)'; // Orange <2h return 'rgba(255,0,0,0.9)'; // Red >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]]'

Simply, what does this code do?

This template defines a unique map template that you reuse for all your Zigbee devices, changing only the [[zone]] variable.

The entity property is built dynamically in JavaScript ([[[ … ]]) to point to the [[zone]] _linkquality entity, which automatically displays the link quality of the device (LQI value returned by Zigbee2MQTT or ZHA).

The name is also generated on the client side: the sensor prefix is removed, the _ is replaced by spaces and a trim() is made, which produces a readable label from the entity ID.

The lastseen custom field reads the entity [[zone]] _last _seen in states[], converts its value to Date object, and then calculates the difference with the current time to display a relative time (s, m, h, d). If the value is empty, unavailable, or an invalid date, it displays !! instead of showing an inconsistent value.

The lastseen badge style at the bottom right uses a JavaScript function that returns a different RGBA colour depending on the age of the last communication (grey if no value, then green → yellow → orange → red depending on the number of seconds elapsed), which visually encodes the freshness of the data.

The section states: the button-card defines several thresholds for the value of LinkQuality: unavailable, then slices >200, >150, >130, >=80, <80, each applying a card-specific color background, which turns a numeric value into an immediately readable radio quality indicator.

The formatting properties (flex, min-width, height, border-radius, absolute badge position, etc.) ensure a compact and uniform rendering, with an optional black border when [[router]] == 1 to visually identify Zigbee routers compared to simple terminals.

🧠 Useful additions: Zigbee2MQTT Global Health Info

Here is the code of this card

Type: picture-elements image: /local/house/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: Allow inclusion - entity: button.zigbee2mqtt _bridge _restart name: Restart - entity: select.zigbee2mqtt _bridge _log _level name: Log level - entity: binary_sensor.zigbee2mqtt _bridge _connection _state name: Connection status - type: custom:template-entity-row name: Launched from state: > {% set e = states.binary_sensor.zigbee2mqtt _bridge _connection _state %} {% if e is not none and e.state not in ['unavailable', 'unknown'] %} {{ ((now() - e.last _changed).total _seconds() / 3600) | round(1) }} h {% else %} Not available {% endif %} - entity: sensor.zigbee2mqtt _bridge _coordinator _version name: Coordinating version - entity: sensor.zigbee2mqtt _bridge _version name: Version Zigbee2MQTT - entity: sensor.number_de _sensors_linkq_en _error name: Unreachable icon: mdi:alert-circle - entity: sensor.number_de _sensors_linkq name: Total icon: mdi:devices - entity: sensor.zigbee _linkquality _mean name: Average LinkQuality icon: mdi:signal style: top: 35% left: 75% width: 450px

I added a sensor to calculate the average of Linkquality

This sensor is added to the templates.yaml file, here is its code:

- name: single _id "zigbee_linkquality_average": zigbee _linkquality _medium 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 %}

I added a sensor to calculate the number of sensors

- name: "Number of sensors Linkq" unique _id: sensor.number _sensors _linkq state: >- {% set lq _sensors = states.sensor | selectattr('entity _id', 'search', '_linkquality$') | list %} {{ lq _sensors | count }} icon: mdi:signal-variant unit_of_measurement: "sensors"

I added a sensor to calculate the number of devices in error

- name: "Number of Linkq sensors in error" unique _id: sensor.number _sensors _linkq _ error 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: "sensors"

I added an indicator on the main dasboard to report the number in error

First of all this template to display xx in error / yy in total

- name: "zigbee _unreachable _synthese" unique _id: sensor.zigbee _reachable _synthese state: >- {{ states('sensor.number_de _sensors _linkq _en _error') }}/{ states('sensor.number_de _sensors _linkq') }} icon: mdi:alert-circle

And in my picture-elements, I add:

- type: conditional conditions: - condition: state entity: sensor.number _de _sensors _linkq _en _en error state _not: "0" elements: - type: custom:button-card entity: sensor.zigbee _reachable _synthese icon: mdi:zigbee show_name: false show_state: true style: top: 5% left: 5% transforms: 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

This block is used to display a small red indicator "Zigbee in error" on the dashboard plan only when there is at least one problematic sensor.

This is a conditional element in a picture-elements card: it shows its contents only if sensor.number _sensors _linkq _en _ error is different from "0" (so if at least one sensor is unreachable or in error from LinkQuality).

When the condition is true, it displays a custom:button-card based on sensor.zigbee _unreachable _synthese, which probably contains "3 in error" or similar text.

The button is placed at the top left of the plane (5 % / 5 %), centered through the transform: translate(-50%, -50%), with a transparent background and no shadows to keep only the Zigbee icon and the text.

The Zigbee icon and status are styled in red, bold and size 16 px, with a border-radius: 999px to give a lozenge/label appearance, making it a discreet but clearly visible visual warning as long as there are sensors in error.

To find out who's wrong

To have a look at the list of equipment in error, here are the necessary codes.

Declaration of templates

- name: "Number of Linkq sensors in error" unique _id: sensor.number _sensors _linkq _ error 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: "sensors" - name: Single _id "Linkquality Sensor List in Error": sensor.liste _sensors _linkquality _error 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 %} None {% 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('None') }} icon: mdi:alert-circle

And on the Dasboard, always in the picture-elements

Type: picture-elements image: /local/house/1920x1000/off_0800.png elements: - type: custom:mod-card card: Type: entities title: Zigbee sensors in error entities: - type: custom:template-entity-row entity: sensor.liste _sensors _linkquality _en _error name: List state: > {% set s = states('sensor.liste _sensors _linkquality _en _error') %} {% if s not in ['unknown', 'unavailable', ''] %} {{ s.replace(',', '\\\\n') } {% else %} None {% endif %} style: top: 80% left: 75% width: 450px

✅ Simple summary (the 3 essential blocks)

1. Zigbee2MQTT: activate last_seen

advanced: last_seen: ISO_8601 _local

2. Zigbee2MQTT: enable by default in HA (optional but convenient)

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

3. Home Assistant: not registering these entities in the DB

recorder: exclude: entity_globs: - sensor.*_last _seen - sensor.*_linkquality

🎯 What it changes on a daily basis

  • I see immediately weak areas (linkquality low) and I know what to reinforce (router, placement, channel, etc.).
  • I know exactly When a mermaid stopped talking (last _seen), so I no longer “suppose”.
  • I keep a healthy database (no unnecessary history on sensors that move all the time).



Need clarification ? questions ?

This discussion (if still open) is available:

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