Realtime Zigbee Channel Monitoring

One of the challenges faced with creating a stable zigbee installation is potential channel interference in the crowded 2.4GHz band.

There is the often reference Metageek Zigbee-Wifi coexistence article which is helpful in understanding where channels sit within that band. If you have ZHA installed there is also the option to perform an energy-scan to get a rudimentary view of RF energy within each zigbee channel. This is useful but is just a point in time snapshot. There is also the zigpy-cli utility which enables you to run the same energy-scan from the command line.

A much better understanding of channel interference can be gained if the 2.4GHz band could be continuously monitored. This is what this project attempts to achieve.

This is a bit hacky and has a number of issues to be aware of but the end result looks like this.

zigbee

Prerequisites.

A spare zigbee co-ordinator. I am using a SONOFF Zigbee 3.0 USB Dongle Plus-E but a conbee II or CC2652 based co-ordinator will be fine, basically anything that zigpy supports.

Overview.

The process involves the following.

  1. Install ZHA to create a zigbee network.

  2. A shell script that runs zigpy to perform an energy-scan. This is passed into awk to format the output as json

  3. A template sensor that executes the shell script and stores the return values for each zigbee channel as an attribute.

  4. For ease of graphing a template sensor for each channel is then created.

  5. Each channel is then displayed using bar-card

Hereโ€™s the hacky bit. Shell Command runs within the homeassistant container context. That means the zigpy package needs to be added to this container. It also means that when Home Assistant is updated the zigpy package needs to be re-installed.

Detail.


zigpy installation.

Plug in your spare coordinator and check it appears on your system.
To perform an energy-scan you have to create a zigbee network. I used ZHA for this as I use zigbee2mqtt for my actual zigbee network. You should be able to use Z2M for this but I have not tested this.
Install ZHA, specifying the spare coordinator. Once installed you should disable ZHA as it is no longer required and if it is running zigpy will not be able to connect to the coordinator.

Open a terminal windows on your HA server and create the following file called energy_scan.sh in the config directory -

zigpy radio ezsp /dev/serial/by-id/usb-ITEAD_SONOFF_Zigbee_3.0_USB_Dongle_Plus_V2_20230508163412-if00 energy-scan -n 0 | \
   awk ' BEGIN { print "{" } \
         ( NR > 11 && NR < 28) { gsub(/[^0-9. ]/ ,"") ;  print "\x22"$1"\x22" ": " , "\x22"$2"\x22" ","  } \
         END { print "\x22"0"\x22" ": " "\x22"0"\x22" "}" }'

Edit the script to select the correct type of coordinator (ezsp|deconz|xbee|zigate|znp) and the correct reference in /dev/serial/by-id
Ensure the script is executable by executing the command chmod a+x energy_scan.sh

To test this within the terminal window you have to install zigpy as follows -
pip install zigpy-cli
Running the script produces the following json formatted output (Your numbers will be different) -

{
"11":  "15.32",
"12":  "75.96",
"13":  "73.51",
"14":  "13.71",
"15":  "88.70",
"16":  "43.06",
"17":  "65.26",
"18":  "36.83",
"19":  "28.30",
"20":  "13.71",
"21":  "4.15",
"22":  "4.15",
"23":  "4.15",
"24":  "4.15",
"25":  "4.15",
"26":  "87.33",
"0": "0"}

This is a channel-power pair showing each channel and the RF energy detected. This is described as a %utilisation but I have no idea how this is derived. However for our purposes it is just a number for comparing different channels.
Be aware, If you run this script after you have set all this up it will fail because it will not be able to get exclusive access to your spare zigbee coordinator.

Next, whilst in the terminal window, we need to install zigpy in the homeassistant container. Type the following -

docker exec -it homeassistant bash This will log you into the homeassistant docker container.
pip install zigpy-cli which installs the zigpy package in the homeassistant which is where our shell command (energy_scan.sh) will run from.

YOU WILL HAVE TO REDO THIS EACH TIME YOU UPGRADE HOMEASSISTANT

As before, execute energy_scan.sh in the config directory to check zigpy is installed and working in the homeassistant container.

exit out of the homeassistant container. That completes the setup of zigpy.


Home Assistant configuration.

Edit your configuration.yaml file and add the following -

shell_command:
  energy_scan: bash energy_scan.sh

After restarting Home Assistant this will create a service in Home Assistant called Shell Command: energy_scan

You can execute this service from the Developer Tools to check it is producing the same output as running energy_scan.sh from the terminal window.
Be aware, If you call this service after you have set all this up it will fail because it will not be able to get exclusive access to your spare zigbee coordinator.

We now need to create some template sensors. Add the following to your configuration.yaml file -

template:
  - trigger:
      - platform: homeassistant
        event: start
      - platform: time_pattern
        seconds: /10
    action:
      - service: shell_command.energy_scan
        data: {}
        response_variable: return_response
    sensor:
      - name: Zigbee Channels
        unique_id: zigbee_channels
        state: "{{ return_response['returncode'] }}"
        attributes:
          11: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['11'] }}
          12: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['12'] }}
          13: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['13'] }}
          14: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['14'] }}
          15: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['15'] }}
          16: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['16'] }}
          17: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['17'] }}
          18: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['18'] }}
          19: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['19'] }}
          20: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['20'] }}
          21: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['21'] }}
          22: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['22'] }}
          23: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['23'] }}
          24: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['24'] }}
          25: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['25'] }}
          26: >
            {% set channels = (return_response['stdout'] | from_json) %}
            {{ channels['26'] }}

  - sensor:
      - name: Zigbee Channel 11
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_11
        state: "{{ state_attr('sensor.zigbee_channels','11') }}"

      - name: Zigbee Channel 12
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_12
        state: "{{ state_attr('sensor.zigbee_channels','12') }}"

      - name: Zigbee Channel 13
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_13
        state: "{{ state_attr('sensor.zigbee_channels','13') }}"

      - name: Zigbee Channel 14
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_14
        state: "{{ state_attr('sensor.zigbee_channels','14') }}"

      - name: Zigbee Channel 15
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_15
        state: "{{ state_attr('sensor.zigbee_channels','15') }}"

      - name: Zigbee Channel 16
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_16
        state: "{{ state_attr('sensor.zigbee_channels','16') }}"

      - name: Zigbee Channel 17
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_17
        state: "{{ state_attr('sensor.zigbee_channels','17') }}"

      - name: Zigbee Channel 18
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_18
        state: "{{ state_attr('sensor.zigbee_channels','18') }}"

      - name: Zigbee Channel 19
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_19
        state: "{{ state_attr('sensor.zigbee_channels','19') }}"

      - name: Zigbee Channel 20
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_20
        state: "{{ state_attr('sensor.zigbee_channels','20') }}"

      - name: Zigbee Channel 21
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_21
        state: "{{ state_attr('sensor.zigbee_channels','21') }}"

      - name: Zigbee Channel 22
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_22
        state: "{{ state_attr('sensor.zigbee_channels','22') }}"

      - name: Zigbee Channel 23
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_23
        state: "{{ state_attr('sensor.zigbee_channels','23') }}"

      - name: Zigbee Channel 24
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_24
        state: "{{ state_attr('sensor.zigbee_channels','24') }}"

      - name: Zigbee Channel 25
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_25
        state: "{{ state_attr('sensor.zigbee_channels','25') }}"

      - name: Zigbee Channel 26
        unit_of_measurement: "%"
        state_class: measurement
        unique_id: zigbee_channel_26
        state: "{{ state_attr('sensor.zigbee_channels','26') }}"

This will create a template sensor called sensor.zigbee_channels which will store each channel power % as an attribute. It calls shell_command: energy_scan every 10 seconds which is the fastest update you can achieve. The extra load on my RPi4 was ~5%
This is then broken out into individual sensors called sensor.zigbee_channel_11 to sensor.zigbee_channel_26. I chose to do this as it makes examining sensor history and displaying the output easier.

Now I am sure there is a better way to extract the channel information in the template sensors but I will leave that for a jinja ninja to comment on.

I implemented the execution of the shell_command service as a template trigger sensor but it may be better as an automation because it can be disabled if not required. You would however lose sensor history in that case.


Display.

To display the output I used the excellent bar card to show each channel as a vertical bar.

Create a new card in and add the following -

entities:
  - entity: sensor.zigbee_channel_11
    name: '11'
  - entity: sensor.zigbee_channel_12
    name: '12'
  - entity: sensor.zigbee_channel_13
    name: '13'
  - entity: sensor.zigbee_channel_14
    name: '14'
  - entity: sensor.zigbee_channel_15
    name: '15'
  - entity: sensor.zigbee_channel_16
    name: '16'
  - entity: sensor.zigbee_channel_17
    name: '17'
  - entity: sensor.zigbee_channel_18
    name: '18'
  - entity: sensor.zigbee_channel_19
    name: '19'
  - entity: sensor.zigbee_channel_20
    name: '20'
  - entity: sensor.zigbee_channel_21
    name: '21'
  - entity: sensor.zigbee_channel_22
    name: '22'
  - entity: sensor.zigbee_channel_23
    name: '23'
  - entity: sensor.zigbee_channel_24
    name: '24'
  - entity: sensor.zigbee_channel_25
    name: '25'
  - entity: sensor.zigbee_channel_26
    name: '26'
title: Zigbee Channel Utilisation
direction: up
height: 200px
stack: horizontal
type: custom:bar-card

This produces the bar graph shown before that gives you readings for each zigbee channel updated every 10 seconds. You can access each channels historical data by clicking on the icon at the bottom of each channel.

As I mentioned this is a bit hacky and I am sure it can be improved on. It feels like it could be converted to an add-on but thatโ€™s not a skill I currently have.

~Brian

5 Likes

@beaj FYI, puddly who is one of the lead developers of zha and zigpy is working in adding a new โ€œAdvanced energy scanโ€ feature/function to zigpy-cli (command line interface for zigpy radios used by zha) as well as โ€œimplement a standard interface for network scanningโ€ for zigpy. Maybe you could look into creating and contributing a nice user frontend interface for that new as part of the UI for the ZHA integration to make it more accessible to all ZHA users? See:

and

Also see related development discussion here:

As well as these for Silicon Labs EmberZNet EZSP and Texas Instruments Z-Stack ZNP zigpy radios respectively:

A better / standard way to get the scanning data out of zigpy would be good as the script I use is a bit of a fudge.

I wanted to get the data into HA as sensors because apart from the better display options it opens the possibility to add intelligence via automation and gather long term stats.

~B

1 Like