Intro

Zigbee is a wireless protocol, like WiFi, but developed specifically for (battery powered) Internet-of-Things devices. Devices connect in a mesh network, where mains powered devices (e.g. light bulbs and smart plugs) act as repeaters to extend the range of the network. The Zigbee coordinator is at the heart of the Zigbee network and allows interconnectivity with non-Zigbee devices.

Home Assistant offers 2 Zigbee integrations: the built-in ZHA, and deCONZ which works via an add-on. Another popular alternative is Zigbee2MQTT which makes use of Home Assistant’s MQTT Discovery to create devices and entities in Home Assistant.

Which coordinator you use is up to you. I advise to check the Zigbee Device Compatibility Repository by Blakadder to check which coordinator supports the device you plan to buy.

This post will discuss the installation of Zigbee2MQTT as it makes use off a Docker container for the coordinator (we already installed the MQTT broker).

Note: This post got delayed somewhat because I had to wait for my USB extension cable to arrive. Without it, my Zigbee controller was experiencing interference from my 2.4GHz WiFi on my miniPC.

Install Zigbee2MQTT container

Locate Zigbee coordinator

In our Docker compose config, we’ll need to assign the Zigbee coordinator, which’ll be connected via USB, to the Docker container.

Plug in your Zigbee coordinator (e.g. Conbee II, Sonof Zigbee 3.0 Plus, …) into your machine. Then run the following command to get the unique path to the device.

ls -l /dev/serial/by-id/
usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_eexxxxxxxxxxxxxxxxxxxxxxxxxxxx52-if00-port0 -> ../../ttyUSB0

We’ll use this unique by-id path in our config later instead of the typical /dev/ttyUSB0 bevause there’s always a risk of the device getting a new TTY assigned after a reboot, espcially with other devices pluggin in as well.

Docker-compose

We expand our docker-compose.yaml with the config for the Zigbee2MQTT container.

services:
  [...]

  zigbee2mqtt:
    container_name: zigbee2mqtt
    image: koenkk/zigbee2mqtt
    restart: unless-stopped
    ports:
      - "8099:8099/tcp"
    environment:
      - TZ=Europe/Brussels
    volumes:
      - /opt/zigbee2mqtt/data:/app/data
      - /run/udev:/run/udev:ro
    devices:
      - /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_eexxxxxxxxxxxxxxxxxxxxxxxxxxxx52-if00-port0:/dev/ttyACM0

Note that I changed the port mapping for this container. Port 8080 is already in use by adminer so I changed the Zigbee2MQTT front-end port to 8099 (see below) and mapped that one in my Docker configuration.

We won’t start our container just yet, as we first want to setup our configuration.

Create MQTT user

When we set up the Mosquitto MQTT broker, I said I prefer to create a MQTT user per service. So let’s create a user for our Z2M container.

docker exec -it mosquitto mosquitto_passwd /mosquitto/config/mqttuser z2m_mqtt
Password:          # Enter a password
Reenter password:  # Repeat the password

cat mosquitto/config/mqttuser
[...]
z2m_mqtt:$7$101$1xcB1yrF********$O$XR******************2/N************************************************************g==

Zigbee2MQTT config

To get a copy of the default config, run sudo wget https://raw.githubusercontent.com/Koenkk/zigbee2mqtt/master/data/configuration.yaml in the folder where the config will be stored (/opt/zigbee2mqtt/data). Then open and edit it using sudo nano /opt/zigbee2mqtt/data/configuration.yaml.

We’ll tell Z2M to connect to our MQTT broker, use the syntax Home Assistant device detection understands, set up the webserver, and let Z2M generate some keys used to setup the Zigbee network.

More info on what the settings in this config do, can be found in the Z2M docs.

# Adapter settings
serial:
  port: /dev/ttyACM0

# MQTT
mqtt:
  base_topic: zigbee2mqtt
  server: '!secret server'
  user: '!secret user'
  password: '!secret password'
  client_id: zigbee

# Zigbee network
permit_join: false # Do not allow random devices to connect automatically

# Webserver
frontend:
  port: 8099 # Custom port
  url: 'http://<ip.of.our.box>:8099' # Update IP here

# Devices and groups
# Extract config to separate files
devices: devices.yaml
groups: groups.yaml

# Home Assistant integration
homeassistant: true

advanced:
  # Zigbee network - auto-generate new keys
  pan_id: GENERATE
  network_key: GENERATE
  # Zigbee network - set channel to avoid interference with 2.4GHz WiFi
  channel: 24

With this configuration in place, we can start the container using docker-compose up -d zigbee2mqtt.

If you check the logs via Portainer or docker-compose logs zigbee2mqtt, you’ll see our container start up nicely:

Using '/app/data' as data directory
Zigbee2MQTT:info  2022-10-04 12:09:47: Logging to console and directory: '/app/data/log/2022-10-04.12-09-47' filename: log.txt
Zigbee2MQTT:info  2022-10-04 12:09:47: Starting Zigbee2MQTT version 1.28.0 (commit #03ba647)
Zigbee2MQTT:info  2022-10-04 12:09:47: Starting zigbee-herdsman (0.14.62)
Zigbee2MQTT:info  2022-10-04 12:09:48: zigbee-herdsman started (resumed)
Zigbee2MQTT:info  2022-10-04 12:09:48: Coordinator firmware version: '{"meta":{"maintrel":1,"majorrel":2,"minorrel":7,"product":1,"revision":20220219,"transportrev":2},"type":"zStack3x0"}'
Zigbee2MQTT:info  2022-10-04 12:09:48: Currently 0 devices are joined:
Zigbee2MQTT:info  2022-10-04 12:09:48: Zigbee: disabling joining new devices.
Zigbee2MQTT:info  2022-10-04 12:09:48: Connecting to MQTT server at mqtt://<ip.of.our.box>:1883
Zigbee2MQTT:info  2022-10-04 12:09:48: Connected to MQTT server
Zigbee2MQTT:info  2022-10-04 12:09:48: MQTT publish: topic 'zigbee2mqtt/bridge/state', payload '{"state":"online"}'
Zigbee2MQTT:info  2022-10-04 12:09:48: Started frontend on port 0.0.0.0:8099
Zigbee2MQTT:info  2022-10-04 12:09:48: MQTT publish: topic 'zigbee2mqtt/bridge/state', payload '{"state":"online"}'

Home Assistant

Repeating what we did in Part 3, Part 5, Part 6, Part 7, and Part 9, we add an entry for ESPHome to the sidebar of our Home Assistant dashboard using panel-iframe.

Add the following lines to configuration.yaml and restart Home Assistant.

panel_iframe:
  portainer:     # part 3
    [...]
  nodered:       # part 5
    [...]
  configurator:  # part 6
    [...]
  duplicati:     # part 7
    [...]
  esphome:       # part 9
    [...]
  zigbee2mqtt:
    title: Zigbee2MQTT
    icon: mdi:zigbee
    url: http://192.168.10.106:8099
    require_admin: true
Zigbee2MQTT dashboard
Zigbee2MQTT dashboard without devices

Pressing the Permit join button on the top-right will temporarily allow any Zigbee device set to pairing mode to be picked up by our coordinator.

Permit Zigbee to join
Permit Zigbee to join

Controlling Z2M

Since Zigbee2MQTT’s configuration can be adapted over MQTT, we can control Z2M not only from the Frontend we just exposed, but also from within our dashboard. This is also explained in the Z2M Home Assistant integration docs.

First, we add some more YAML to our configuration.yaml via the Configurator. Copy and paste this code add the end of the file. If you’re used to splitting up your config and/or working with packages, you can put it in a zigbee2mqtt.yaml package file as well.

# Input select for Zigbee2MQTT debug level
input_select:
  zigbee2mqtt_log_level:
    name: Zigbee2MQTT Log Level
    options:
      - debug
      - info
      - warn
      - error
    initial: info
    icon: mdi:format-list-bulleted

# Input number for joining time remaining (in minutes)
input_number:
  zigbee2mqtt_join_minutes:
    name: "Zigbee2MQTT join minutes"
    initial: 2
    min: 1
    max: 5
    step: 1
    mode: slider

# Input text to input Zigbee2MQTT friendly_name for scripts
input_text:
  zigbee2mqtt_old_name:
    name: Zigbee2MQTT Old Name
    initial: ""
  zigbee2mqtt_new_name:
    name: Zigbee2MQTT New Name
    initial: ""
  zigbee2mqtt_remove:
    name: Zigbee2MQTT Remove
    initial: ""

# Input boolean to set the force remove flag for devices
input_boolean:
  zigbee2mqtt_force_remove:
    name: Zigbee2MQTT Force Remove
    initial: false
    icon: mdi:alert-remove

# Scripts for renaming & removing devices
script:
  zigbee2mqtt_rename:
    alias: Zigbee2MQTT Rename
    sequence:
      service: mqtt.publish
      data_template:
        topic: zigbee2mqtt/bridge/request/device/rename
        payload_template: >-
          {
            "from": "{{ states.input_text.zigbee2mqtt_old_name.state | string }}",
            "to": "{{ states.input_text.zigbee2mqtt_new_name.state | string }}"
          }          
  zigbee2mqtt_remove:
    alias: Zigbee2MQTT Remove
    sequence:
      service: mqtt.publish
      data_template:
        topic: zigbee2mqtt/bridge/request/device/remove
        payload_template: >-
          {
            "id": "{{ states.input_text.zigbee2mqtt_remove.state | string }}",
            "force": {% if states.input_boolean.zigbee2mqtt_force_remove.state == "off" %}false{% else %}true{% endif %}
          }          

# Timer for joining time remaining (254 sec)
timer:
  zigbee_permit_join:
    name: Time remaining
    duration: 254

mqtt:
  sensor:
    # Sensor for monitoring the bridge state
    - name: Zigbee2MQTT Bridge state
      unique_id: zigbee2mqtt_bridge_state_sensor
      state_topic: "zigbee2mqtt/bridge/state"
      icon: mdi:router-wireless
    # Sensor for Showing the Zigbee2MQTT Version
    - name: Zigbee2MQTT Version
      unique_id: zigbee2mqtt_version_sensor
      state_topic: "zigbee2mqtt/bridge/info"
      value_template: "{{ value_json.version }}"
      icon: mdi:zigbee
    # Sensor for Showing the Coordinator Version
    - name: Zigbee2MQTT Coordinator Version
      unique_id: zigbee2mqtt_coordinator_version_sensor
      state_topic: "zigbee2mqtt/bridge/info"
      value_template: "{{ value_json.coordinator.meta.revision }}"
      icon: mdi:chip
    - name: Zigbee2mqtt Networkmap
      unique_id: zigbee2mqtt_networkmap_sensor
      # if you change base_topic of Zigbee2mqtt, change state_topic accordingly
      state_topic: zigbee2mqtt/bridge/networkmap/raw
      value_template: >-
                {{ now().strftime('%Y-%m-%d %H:%M:%S') }}
      # again, if you change base_topic of Zigbee2mqtt, change json_attributes_topic accordingly
      json_attributes_topic: zigbee2mqtt/bridge/networkmap/raw
    
  # Switch for enabling joining
  switch:
    - name: "Zigbee2MQTT Main join"
      unique_id: zigbee2mqtt_main_join_switch
      state_topic: "zigbee2mqtt/bridge/info"
      value_template: '{{ value_json.permit_join | lower }}'
      command_topic: "zigbee2mqtt/bridge/request/permit_join"
      payload_on: "true"
      payload_off: "false"

automation:
  # Automation for sending MQTT message on input select change
  - alias: Zigbee2MQTT Log Level
    initial_state: "on"
    trigger:
      platform: state
      entity_id: input_select.zigbee2mqtt_log_level
    action:
      - service: mqtt.publish
        data:
          payload_template: "{{ states('input_select.zigbee2mqtt_log_level') }}"
          topic: zigbee2mqtt/bridge/request/config/log_level
  # Automation to start timer when enable join is turned on
  - id: zigbee_join_enabled
    alias: Zigbee Join Enabled
    trigger:
      platform: state
      entity_id: switch.zigbee2mqtt_main_join
      to: "on"
    action:
      service: timer.start
      entity_id: timer.zigbee_permit_join
      data_template:
        duration: "{{ '00:0%i:00' % (states('input_number.zigbee2mqtt_join_minutes') | int ) }}"
  # Automation to stop timer when switch turned off and turn off switch when timer finished
  - id: zigbee_join_disabled
    alias: Zigbee Join Disabled
    trigger:
      - platform: event
        event_type: timer.finished
        event_data:
          entity_id: timer.zigbee_permit_join
      - platform: state
        entity_id: switch.zigbee2mqtt_main_join
        to: "off"
    action:
      - service: timer.cancel
        data:
          entity_id: timer.zigbee_permit_join
      - service: switch.turn_off
        entity_id: switch.zigbee2mqtt_main_join
  - id: "zigbee2mqtt_create_notification_on_successful_interview"
    alias: Zigbee Device Joined Notification
    trigger:
      platform: mqtt
      topic: 'zigbee2mqtt/bridge/event'
    condition:
      condition: template
      value_template: '{{trigger.payload_json.type == "device_interview" and trigger.payload_json.data.status == "successful" and trigger.payload_json.data.supported}}'
    action:
      - service: persistent_notification.create
        data_template:
          title: Device joined the Zigbee2MQTT network
          message: "Name: {{trigger.payload_json.data.friendly_name}},
                    Vendor: {{trigger.payload_json.data.definition.vendor}},
                    Model: {{trigger.payload_json.data.definition.model}},
                    Description: {{trigger.payload_json.data.definition.description}}"

Check your YAML config in the developer tools and restart Home Assistant.

Once that’s done, you can add a custom card to your Dashboard containing the following entries:

title: Zigbee2MQTT
type: entities
show_header_toggle: false
entities:
  - entity: sensor.zigbee2mqtt_bridge_state
  - entity: sensor.zigbee2mqtt_version
  - entity: sensor.zigbee2mqtt_coordinator_version
  - entity: input_select.zigbee2mqtt_log_level
  - type: divider
  - entity: switch.zigbee2mqtt_main_join
  - entity: input_number.zigbee2mqtt_join_minutes
  - entity: timer.zigbee_permit_join
  - type: divider
  - entity: input_text.zigbee2mqtt_old_name
  - entity: input_text.zigbee2mqtt_new_name
  - entity: script.zigbee2mqtt_rename
  - type: divider
  - entity: input_text.zigbee2mqtt_remove
  - entity: input_boolean.zigbee2mqtt_force_remove
  - entity: script.zigbee2mqtt_remove
Manage Zigbee2MQTT from Home Assistant
Manage Z2M from Home Assistant

Network map

Another nice thing to add to Home Assistant is a map of your connect Zigbee devices.

Since we don’t have any devices connected yet, this map will be fairly empty. However, you’ll enjoy seeing your network grow each time you add a new device :)

This will require you to add a custom card, either manually or using HACS. I suggest you have a look at the documentation of both to decide which method you prefer.

Zigbee2MQTT network map
Zigbee2MQTT network map

Don’t see the map or got an error about the custom card not existing? Clear your cache and refresh the page before trying again.