ZigBee network map when using only the ZigBee Binding

thanks @ndasyras for the reply. I tested it with latest modification from @abol3z

Below you can find my modified version to make it work with openHAB 3.x (which by default requires the use of an authentication token with REST API). As such I’ve replaced wget with curl (and added 3 variables to set the openHAB host, port and APIToken).

One question: Any idea when and how often the binding updates the routes and neighbor info?

#/bin/bash

openHABHost="192.168.XXX.XXX"
openHABPort="8080"
APIToken="qbmkfBVCTc2VL6g4-1kM1chuD5vZNDd38w....."

dir=$(mktemp -d)
#dir=.

curl -s -X GET "http://$openHABHost:$openHABPort/rest/things" -H "accept: application/json" -H "Authorization: Bearer $APIToken" -o $dir/things.json

# Replace with the "strict" version if it's too dense.
#echo "digraph G {" >> $dir/network.dot
echo "strict digraph G {" >> $dir/network.dot

echo "edge [decorate=true]" >> $dir/network.dot
echo "node [shape=box]" >> $dir/network.dot
echo "0 [label=<0<br/>COORDINATOR> color=blue]" >> $dir/network.dot

jq -r '.[] |
  select(.thingTypeUID | startswith("zigbee:")) |
  .properties.zigbee_networkaddress + " " +
  "[" +
    "label=<" +
    .properties.modelId + "<br/>" +
    .properties.zigbee_networkaddress + "<br/>" +
    (.properties.zigbee_macaddress // .configuration.zigbee_macaddress) + "<br/>" +
    .properties.zigbee_logicaltype + "<br/>" +
    .label + "<br/>" +
    .location +
    ">" +
    "color=" +
    if .statusInfo.status == "ONLINE" then "green"
    elif .statusInfo.status == "OFFLINE" then "orange"
    elif .statusInfo.status == "UNKNOWN" then "red"
    else "black" end +
  "]"
' $dir/things.json >> $dir/network.dot

nodes=$(jq -r '.[] |
  select(.properties.zigbee_networkaddress != null) |
  .properties.zigbee_networkaddress
' $dir/things.json)

for node in ${nodes}; do
  jq -r --arg node $node '.[] |
    select(.properties.zigbee_networkaddress == $node) |
    select(.properties.zigbee_routes != "[]" and .properties.zigbee_routes != null) |
    .properties.zigbee_routes
  ' $dir/things.json |
  jq -r --arg node $node '.[] |
    $node + " -> " +
    if .destination != .next_hop then .next_hop + " -> " else "" end +
    .destination +
    " [" +
    "color=" +
    if .state == "ACTIVE" then "green"
    elif .state == "DISCOVERY_UNDERWAY" then "orange"
    elif .state == "DISCOVERY_FAILED" then "red"
    elif .state == "INACTIVE" then "blue"
    else "yellow" end + " " +
    "]"
  ' >> $dir/network.dot

  jq -r --arg node $node '.[] |
    select(.properties.zigbee_networkaddress == $node) |
    select(.properties.zigbee_neighbors != "[]" and .properties.zigbee_neighbors != null) |
    .properties.zigbee_neighbors
  ' $dir/things.json |
  jq -r --arg node $node '.[] |
    $node + " -> " + .address + " " +
    "[" +
      "fontcolor=" +
      if .joining == "ENABLED" then "green"
      elif .joining == "DISABLED" then "black"
      elif .joining == "UNKNOWN" then "red"
      else "blue" end + " " +
      "label=<" +
      .lqi + "/" + .depth +
      ">" +
    "]"
  ' >> $dir/network.dot
done

echo "label=\"`date +%Y-%m-%d\ %H:%M`\";" >> $dir/network.dot

echo "}" >> $dir/network.dot

# draw graph using dot or circo
/usr/bin/circo -Tpng $dir/network.dot -o ./zigbee_network.png
dot -Tpng $dir/network.dot -o ./zigbee_network.png

rm -rf $dir
1 Like

In 2.5.8 zigbee Thing information seems to be updated once per day. Check in openhab events log for a line like this: “Thing ‘zigbee:coordinator_xxx’ has been updated.”

1 Like

I think in OH3 it’s even possible to configure this update. I will try now with 5min (just for testing)

Same for me. Only with a update of 5 minutes, I get the data between the zigbee things. Otherwise not. Is it a bad thing for battery live to set this to 5 minutes permanently?

Hi Folks,
based on the scripts and ideas of this thread, I’ve created a completely new script that is not affected by the problems described here (like update cycle, etc.). My approach also gets rid of the problem with some coordinators that are not showing the neighbor data.

My new script does not use the REST Api, but instead uses the built-in zigbee commands in the openhab console. The script is written in python and only needs standard plugins that should be available everywhere.

Cons: As the script accesses the console several times for 1 complete run, it takes some seconds to complete, depending on the number of nodes in the network. In my case it takes about 20s.

This the output, that comes out on my zigbee network (Engine “neato”):
zigbee_map_neato_240114-1116

Engine “dot”:
zigbee_map_dot_240114-1401

Here is the script source:

#!/usr/bin/env python3

#import json # Only for debugging purposes
import re
import subprocess
import graphviz
from datetime import datetime
import os

output_filename = 'zigbee_map'
output_dir = '/etc/openhab/html/'
output_format = 'svg' #png, svg, dot
output_node_shape = 'box' # box, ellipse, circle, diamond, house, hexagon, octagon
output_line_shape = 'curved' # none, line, polyline, curved, ortho, spline
output_engine = 'neato' # dot, fdp, neato, circo
pwd = "Enter your console password here"   # -------------------- Change before usage! ----------------

zigbee_data = [];
cmd_basic = ["openhab-cli","console", "-p", pwd]
cmd_nodes = cmd_basic + ["zigbee", "nodes"]
cmd_neighbor = cmd_basic + ["zigbee", "neighbors"]
cmd_list_things = cmd_basic + ["things", "list"]
cmd_show_thing = cmd_basic + ["things", "show"]

# Zigbee properties
NETWORK_ADDR_SOURCE='network'
NETWORK_ADDR='network address'
MODEL = 'model'
IEEE_ADDR = 'ieee address'
LOGICAL_TYPE='logical type'
STATE = 'state'
LQI = 'lqi'
DEVICE_TYPE = 'device type'
RELATIONSHIP = 'relationship'
RELATIONSHIP_PARENT = 'parent'
RELATIONSHIP_SIBLING = 'sibling'

# Thing properties
LABEL = 'label'
STATUS = 'status'
ZB_NETWORK_ADDR = 'zigbee_networkaddress'

def export(filename, content):
    file = open(filename, 'w')
    file.write(content)
    file.close()
    return

def parse_nodes(raw_data):
    result_data =[];
    columns = []
    i = 0
    for line in raw_data.splitlines():
        if not line[0:8] == '        ':
            line = line.strip()                     # remove leading/trailing white spaces
            line = re.sub(r'\s\s+',r'\t', line)     # Replace multiple whitespaces by a tab as separator
            if line:
                if i == 0:
                    columns = re.sub(NETWORK_ADDR_SOURCE, NETWORK_ADDR, line.strip().lower()).split('\t')
                    if len(columns)>3:
                        i = i + 1
                else:
                    d = {}                          # dictionary to store file data (each line)
                    data = [item.strip() for item in line.split('\t')]
                    for index, elem in enumerate(data):
                        d[columns[index]] = data[index]
                    result_data.append(d)           # append dictionary to list
    return result_data

def parse_neighbor(raw_data):
    result_data = []
    columns = [];
    i = 0
    for line in raw_data.splitlines():
        line = line.strip()                     # remove leading/trailing white spaces
        line = re.sub(r'\s\s+',r'\t', line)     # Replace multiple whitespaces by a tab as separator
        if i == 0:
            columns = line.lower().split('\t')
            if len(columns)>3:
                i = i + 1
        else:
            d = {}                              # dictionary to store file data (each line)
            data = [item.strip() for item in line.split('\t')]
            for index, elem in enumerate(data):
                d[columns[index]] = data[index]
            result_data.append(d)               # append dictionary to list
    return result_data

def get_nodes_raw_data():
    try:
        proc = subprocess.run(cmd_nodes, capture_output=True)
        nodes_raw_data = subprocess.check_output(cmd_nodes,stderr=subprocess.STDOUT).decode('utf-8')
        print("Nodes:\n" + nodes_raw_data)
    except subprocess.CalledProcessError as e:
        raise RuntimeError("command '{}' return with error (code {}): {}\nerror: {}".format(e.cmd, e.returncode, e.output, e.stderr))
    return nodes_raw_data

def get_neighbor_raw_data(network_ID):
    try:
        neighbors_raw_data = subprocess.check_output(cmd_neighbor+[network_ID], stderr=subprocess.STDOUT).decode('utf-8')
        #export('tmp/neighbor' + network_ID + '.txt',neighbors_raw_data)
    except subprocess.CalledProcessError as e:
        raise RuntimeError("command '{}' return with error (code {}): {}\nerror: {}".format(e.cmd, e.returncode, e.output, e.stderr))
    return neighbors_raw_data

def get_thing_raw_data(uid):
    try:
        thing_raw_data = subprocess.check_output(cmd_show_thing + [uid], stderr=subprocess.STDOUT).decode('utf-8')
        #export('tmp/things.txt',thing_raw_data)
    except subprocess.CalledProcessError as e:
        raise RuntimeError("command '{}' return with error (code {}): {}\nerror: {}".format(e.cmd, e.returncode, e.output, e.stderr))
    return thing_raw_data

def get_thingUIDs_raw_data():
    try:
        thingUIDs_raw_data = subprocess.check_output(cmd_list_things, stderr=subprocess.STDOUT).decode('utf-8')
        #export('tmp/things.txt',thingUIDs_raw_data)
    except subprocess.CalledProcessError as e:
        raise RuntimeError("command '{}' return with error (code {}): {}\nerror: {}".format(e.cmd, e.returncode, e.output, e.stderr))
    return thingUIDs_raw_data

def get_thingUIDs(raw_data):
    thingUIDs = []
    for line in raw_data.splitlines():
        if line.startswith('zigbee:coordinator') or line.startswith('zigbee:device'):
            thingUIDs.append(line.partition(' ')[0])
    return thingUIDs

def get_thing_information(thingUIDs):
    things = {}
    for thingUID in thingUIDs:
        print('Trying to get thing data for device: ' + thingUID)
        raw_data = get_thing_raw_data(thingUID)
        thing = {} # dictionary to store properties
        for line in raw_data.splitlines():
            elements = line.partition(':')
            key = elements[0].strip().lower()
            value = elements[2].strip()
            if (not key == '') and not key in thing:
                thing[key] = value
        if not ZB_NETWORK_ADDR in thing:
            thing[ZB_NETWORK_ADDR] = '0'    # Must be the coordinator
        things[thing[ZB_NETWORK_ADDR]] = thing
    return things

#============================================================================================
#                   Main
#============================================================================================
print('Retrieving and parsing all Zigbee nodes..')
zigbee_data = parse_nodes(get_nodes_raw_data())
print('Retrieving all Things..')
thingUIDs = get_thingUIDs(get_thingUIDs_raw_data())
things = get_thing_information(thingUIDs)
# for debugging purposes only:
#print(json.dumps(things, indent=4))

#for debugging purposes only:
#print(json.dumps(zigbee_data, indent=4))

dot = graphviz.Digraph(comment='Zigbee-Map', name='ZigBee-Map', engine=output_engine)
dot.format = output_format

for device in zigbee_data:
    network_ID = device[NETWORK_ADDR]
    caption=''
    if (MODEL in device):
        caption = device[MODEL] + "\n" + network_ID + "\n" + device[IEEE_ADDR] + "\n" + device[DEVICE_TYPE] + "\n" + things[network_ID][LABEL]
    else:
        caption = "\n" + network_ID + "\n" + device[IEEE_ADDR] + "\n" + things[network_ID][LABEL]
    boxcolor = 'darkgreen' if things[network_ID][STATUS] == 'ONLINE' else 'orange' if device[STATE]=='ONLINE' else 'red'
    dot.node(network_ID, caption, color=boxcolor)
    if (LOGICAL_TYPE in device) and ((device[LOGICAL_TYPE].lower()=="router") or (device[LOGICAL_TYPE].lower()=="coordinator")):
        print("Trying to find neighbors for Zigbee device with network Address: " + network_ID)
        neighbors = parse_neighbor(get_neighbor_raw_data(network_ID))
        print ('Found: {} neighbors'.format(len(neighbors)))
        device['Neighbors'] = neighbors

        for neighbor in neighbors:
            if (NETWORK_ADDR in neighbor):
                if neighbor[RELATIONSHIP].lower() == RELATIONSHIP_PARENT:
                    thickness = '2'
                elif neighbor[RELATIONSHIP].lower() == RELATIONSHIP_SIBLING:
                    thickness = '1'
                else:
                    thickness = '0.5'
                dot.node(neighbor[NETWORK_ADDR], shape=output_node_shape)
                dot.edge(network_ID, neighbor[NETWORK_ADDR], neighbor[LQI], color='red' if int(neighbor[LQI]) < 20 else 'black', penwidth=thickness)

dot.attr(label=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), root="0", splines=output_line_shape, overlap="scale")
dot.render(output_dir + output_filename)

I schedule the script via the executer addon in openhab.
Feedback would be nice, maybe we can improve it even more.
Have fun!

3 Likes

Hi! Have u evolved the project at all for OH4; im on a RPI4 and am looking to understand my network better.

Yes, the script is running well on openHAB 4.1.1

Hi,
many thanks for the script. I am a Python newbie. When I run the script on openhab 4.3, I get the following error message:

Retrieving and parsing all Zigbee nodes..
Traceback (most recent call last):
  File "/etc/openhab/scripts/zigbeenet.py", line 92, in get_nodes_raw_data
    nodes_raw_data = subprocess.check_output(cmd_nodes,stderr=subprocess.STDOUT).decode('utf-8')
  File "/usr/lib/python3.9/subprocess.py", line 424, in check_output
    return run(*popenargs, stdout=PIPE, timeout=timeout, check=True,
  File "/usr/lib/python3.9/subprocess.py", line 528, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '['openhab-cli', 'console', '-p', 'openhabian', 'zigbee', 'nodes']' returned non-zero exit status 1.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/etc/openhab/scripts/zigbeenet.py", line 150, in <module>
    zigbee_data = parse_nodes(get_nodes_raw_data())
  File "/etc/openhab/scripts/zigbeenet.py", line 95, in get_nodes_raw_data
    raise RuntimeError("command '{}' return with error (code {}): {}\nerror: {}".format(e.cmd, e.returncode, e.output, e.stderr))
RuntimeError: command '['openhab-cli', 'console', '-p', 'openhabian', 'zigbee', 'nodes']' return with error (code 1): b'\nSLF4J(W): No SLF4J providers were fo                 und.\nSLF4J(W): Defaulting to no-operation (NOP) logger implementation\nSLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.\nSLF4J                 (W): Class path contains SLF4J bindings targeting slf4j-api versions 1.7.x or earlier.\nSLF4J(W): Ignoring binding found at [jar:file:/usr/share/openhab/runti                 me/system/org/apache/karaf/org.apache.karaf.client/4.4.6/org.apache.karaf.client-4.4.6.jar!/org/slf4j/impl/StaticLoggerBinder.class]\nSLF4J(W): See https://ww                 w.slf4j.org/codes.html#ignoredBindings for an explanation.\nNo more authentication methods available\n'
error: None

How can I solve this? Thanks in advance!

Hi,
I have found the solution. It was simply the use of an incorrect password. After I used the correct one the script works fine. Also on OH 4.3.

1 Like

Hi,

after some debugging i found a smal issue:
If you are using a special device like RWL021 then you might get sometimes the error message that a thing was not found. To fix this i did:

Modied this line:

         if line.startswith('zigbee:coordinator') or line.startswith('zigbee:device'):

into:

        if line.startswith('zigbee:coordinator') or line.startswith('zigbee:device') or line.startswith('zigbee:philips_rwl021'):

another thing i’ve adjusted:
if you are using docker you might also run the script outside of the container by installing “sshpass” like

apt-get install sshpass

and change

#cmd_basic = ["openhab-cli","console", "-p", pwd]
cmd_basic = ["sshpass","-p", pwd, "ssh", "openhab@localhost", "-p", "8101", "-q"]

Hi all,

now i just have the following issue:

the script runs unless this is displayed:

Trying to find neighbors for Zigbee device with network Address: 54752
Found: 27 neighbors
Trying to find neighbors for Zigbee device with network Address: 57383
Found: 27 neighbors
Trying to find neighbors for Zigbee device with network Address: 60740
Found: 28 neighbors
Trying to find neighbors for Zigbee device with network Address: 61538
Found: 21 neighbors

But then the “dot” process takes 100% CPU at the moment for 14 minutes.
Anyone has an idea why?


top - 11:10:18 up 16:07,  2 users,  load average: 1.01, 1.08, 0.91
Tasks: 186 total,   2 running, 184 sleeping,   0 stopped,   0 zombie
%Cpu(s): 26.5 us,  1.1 sy,  0.0 ni, 72.4 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   7821.9 total,   2851.0 free,   2052.0 used,   3024.9 buff/cache
MiB Swap:    200.0 total,    200.0 free,      0.0 used.   5769.8 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 294592 frsc      20   0  176040  16256  11008 R  99.7   0.2  14:07.48 dot

Hi,

To ensure better performance than my Raspberry Pi, I downloaded the zigbee_map file and attempted to render it on my 11th Gen Core i7-11850H @ 2.50GHz with 16GB of RAM.

Result: After 6 hours, there was still no output.

Does it take this long in our setup as well?

I’ve attached the my file if someone wants to take a look

zigbee_map_anonymized_macs.txt (56.7 KB)

Hello friends,

Finally i got support from the graphviz team see here:
https://forum.graphviz.org/t/newbie-rendering-for-hours-with-no-results/

long story short:

output_line_shape = 'curved'

creates the long rendering time. and can be replayed with

output_line_shape = 'true'

Now it renders within a minute.