Z-Wave graph (without the python)

Hi folks. If this is considered bad etiquette, please feel free to kill post.

I really liked having the ZWave Graph that @OmenWild had setup, but when I moved my setup to docker last week (hey, I was on vacation and felt the itch to change something). I didn’t feel like trying to figure out why it had broken, and hadn’t liked the external python script method anyway (personal preference, just more things to break).

So I satisfied multiple itches at once, and converted what he had written to a standard panel that can be added simply, and no mucking about with command lines.

I’ve started adding little bits to the output (trying to work on displaying a legend so I know what the different shapes mean). Anyway, all that said, here’s the code. I’m not finished with it yet, there’s still stuff I want to implement for myself, and some cleanup is needed. What I could use, is someone with a larger ZWave network than mine to let me know how it works for them.

Save this file in the panels directory of your HomeAssistant configuration as zwavegraph2.html

<dom-module id='ha-panel-zwavegraph2'>
  <template>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        #mynetwork {
            border: 1px solid lightgray;
            height: 90%;
        }
        #mygraph { height:90% }
    </style>

    <div id="mynetwork">
      <div id="configuration"></div>
      <div id="mygraph"></div>
    </div>

  </template>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>

</dom-module>

<script>
class HaPanelZWave extends Polymer.Element {
  static get is() { return 'ha-panel-zwavegraph2'; }

  static get properties() {
    return {
      // Home Assistant object
      hass: Object,
      // If should render in narrow mode
      narrow: {
        type: Boolean,
        value: false,
      },
      // If sidebar is currently shown
      showMenu: {
        type: Boolean,
        value: false,
      },
      // Home Assistant panel info
      // panel.config contains config passed to register_panel serverside
      panel: Object,
    };
  }


  ready() {
    super.ready();
    let data = this.listNodes(this.hass);

    data.nodes.splice(0,0,
	{id: "legendBox",
         label: "Battery\nPowered\nDevice",
         x:50, y:50, physics: false, shape: "box", fixed: true, group: "legend"
        },{id: "legendCircle",
         label: "Mains\nPowered\nDevice",
         x:150, y:150, physics: false, shape: "circle", fixed:true, group: "legend"
        });


    var options = {
      height: '100%',        
      configure: {
        enabled: true,
        filter: function (option, path) {
          if (option === "nodeDistance") {
            return true;
          }
          return false;
        },
        container: this.$.configuration,
        showButton: false
      },
      layout: {
        hierarchical: {
          enabled: true,
          direction: "DU",
          nodeSpacing: 25,
          sortMethod: "directed"
        }
      },
      interaction: {dragNodes: true, hover: true},
      physics: {
        hierarchicalRepulsion: {
          centralGravity: 0.25,
          springLength: 100,
          springConstant: 0.01,
          nodeDistance: 100
        },
        minVelocity: 0.75,
        solver: "hierarchicalRepulsion"
      },
      nodes: {
        borderWidth: 1,
        scaling: {
          label: false
        }
      }
    };
    // create a network
    var container = this.$.mygraph;
    // initialize your network!
    var network = new vis.Network(container, data, options);

  }


  listNodes(hass) {
    let states=new Array();
    for (let state in hass.states)
    {
      states.push({name:state, entity:hass.states[state]});
    }
    let zwaves = states.filter((s) => {return s.name.indexOf("zwave.") ==0});
    let result= {"edges":[], "nodes":[]};

    let hubNode=0;
    let neighbours={};

    for (let b in zwaves)
    {
       let id=zwaves[b].entity.attributes["node_id"]; 
       let node = zwaves[b].entity;
       if (node.attributes["capabilities"].filter(
			(s) => {return s =="primaryController"}).length > 0) 
       {
         hubNode=id;
       }
       neighbours[id]=node.attributes['neighbors'];

       let entities = states.filter((s) => {
                    return ((s.name.indexOf("zwave.") == -1) &&
                            (s.entity.attributes["node_id"] == id)) });
       let batlev=node.attributes.battery_level;       
       let entity={"id":  id,
                   "label": (node.attributes["node_name"] + " (" + node.attributes["averageResponseRTT"]+"ms)").replace(/ /g, "\n"),
                   "group": "unset",
                   "shape": batlev != undefined ? "box" : "circle",
                   "title": "<b>"+node.attributes["node_name"]+"</b>" +
                            "<br />Node: " + id + (node.attributes["is_zwave_plus"] ? "+" : "") +
                            "<br />Product Name: " + node.attributes["product_name"] +
                            "<br />Average Request RTT: " + node.attributes["averageResponseRTT"]+"ms" + 
                            "<br />Power source: " + (batlev != "undefined" ? "battery (" + batlev +"%)" : "mains") +
                            "<br />" + entities.length + " entities",
                   "forwards": (node.attributes.is_awake && node.attributes.is_ready && !node.attributes.is_failed &&
                                node.attributes.capabilities.includes("listening"))
                  };

       if (node.attributes["is_failed"])
       {
         entity["title"]="<b>FAILED: </b>"+entity.title;
         entity["group"]="Failed";
       }

       if (hubNode == id)
       {
         entity.label="ZWave Hub";
         entity.borderWidth= 2;
         entity.fixed=true;
       }

       result.nodes.push(entity);
    }

    
    if (hubNode > 0)
    {
      let layer=0;
      let previousRow=[hubNode];
      let mappedNodes=[hubNode];
      while (previousRow.length > 0)
      {
        layer = layer+1;
        let nextRow=[];
        for (let target in previousRow)
        {
          result.nodes.filter((n) => {return ((n.id ==previousRow[target]) && (n.group="unset"))})
                      .every((d) => {d.group="Layer " + layer;})

          if (result.nodes.filter((n) => {return ((n.id == previousRow[target]) && (n.forwards))}).length > 0)
          {
            let row=neighbours[previousRow[target]];
            for (let node in row)
            {

              if (!mappedNodes.includes(row[node]))
              {
                result.edges.push({"from":row[node], "to":previousRow[target]});
                nextRow.push(row[node]);
              }
            }
          }
        }

        for (let idx in nextRow)
        {
          mappedNodes.push(nextRow[idx]);
        }
        previousRow = nextRow;
      }
    }
    return result;
   }

  
}
customElements.define(HaPanelZWave.is, HaPanelZWave);
</script>

To add this to your configuration, add the following entry into your configuration.yaml file.

panel_custom:
  - name: zwavegraph2
    sidebar_title: ZWave Graph2
    sidebar_icon: mdi:access-point-network
    url_path: zwave

Important note: If you change anything, the value of the name node in the configuration.yaml code, must match the filename of the html file, and be the end of the dom-module HTML element. And the dom-module.html element has to match the value of the is() property in the javascript class.

33 Likes

Back to it again I see! :grinning:

I just installed it.

I’ll try it out and see what I think. I don’t have a very big network so that part I can’t help with, tho.

I originally thought you meant to put the file in the panel_iframe directory. But I got an error that directed me to look at the docs.

Interestingly, the docs are wrong (or at least vague and confusing…but what’s new?) on where to put the html file tho.

It says something about putting them in the www folder. I got an error again and looking at the home-assistant.log file then I realized I needed to create a new “panels” directory and put it in there.

Working now tho.

A coupe of things I noticed:

  1. I have a couple of failed nodes (light bulbs with the light switches off). They still show up but it would be nice if they could be designated as failed without having to hover over them to get the info pop-up.

  2. on the pop-up info window everything shows “Power source: battery” and if they are not battery powered then the battery level part is “(undefined%)”

This is just what I was looking for, thanks! I have migrated from a Vera Z-Wave controller and was wondering how my mesh looked. I just had to create the panels directory as it didn’t exist. It seems to show Power source: battery (undefined %) for all the mains powered devices though. Here is my network:

Hi guys,

Yes @finity, I’m back at it :slight_smile:

I will add an indicator for the failed devices (I thought I had one, but I don’t). And sory about the battry level on non battery powered devices. Of course, I checked to make sure it worked on battery powered devices, but not that it didn’t show up on mains devices :blush: Oh well, that’s what I get for trying to QA my own work.

I am also planning to look at some other graphing libraries to see how well they work, there’s a couple of things in this one I’m not thrilled by (but if I can’t find another one I like, this one will do).

Okay, here’s an updated panel, the power source is shown properly now, and if a device is failed it gets shown on the graphic without having to hover over it.

<dom-module id='ha-panel-zwavegraph2'>
  <template>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        #mynetwork {
            border: 1px solid lightgray;
            height: 90%;
        }
        #mygraph { height:90% }
    </style>

    <div id="mynetwork">
      <div id="configuration"></div>
      <div id="mygraph"></div>
    </div>

  </template>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>

</dom-module>

<script>
class HaPanelZWave extends Polymer.Element {
  static get is() { return 'ha-panel-zwavegraph2'; }

  static get properties() {
    return {
      // Home Assistant object
      hass: Object,
      // If should render in narrow mode
      narrow: {
        type: Boolean,
        value: false,
      },
      // If sidebar is currently shown
      showMenu: {
        type: Boolean,
        value: false,
      },
      // Home Assistant panel info
      // panel.config contains config passed to register_panel serverside
      panel: Object,
    };
  }


  ready() {
    super.ready();
    let data = this.listNodes(this.hass);

    data.nodes.splice(0,0,
	{id: "legendBox",
         label: "Battery\nPowered\nDevice",
         x:50, y:50, physics: false, shape: "box", fixed: true, group: "legend"
        },{id: "legendCircle",
         label: "Mains\nPowered\nDevice",
         x:150, y:150, physics: false, shape: "circle", fixed:true, group: "legend"
        });


    var options = {
      height: '100%',        
      configure: {
        enabled: true,
        filter: function (option, path) {
          if (option === "nodeDistance") {
            return true;
          }
          return false;
        },
        container: this.$.configuration,
        showButton: false
      },
      layout: {
        hierarchical: {
          enabled: true,
          direction: "DU",
          nodeSpacing: 25,
          sortMethod: "directed"
        }
      },
      interaction: {dragNodes: true, hover: true},
      physics: {
        hierarchicalRepulsion: {
          centralGravity: 0.25,
          springLength: 100,
          springConstant: 0.01,
          nodeDistance: 100
        },
        minVelocity: 0.75,
        solver: "hierarchicalRepulsion"
      },
      nodes: {
        borderWidth: 1,
        scaling: {
          label: false
        }
      }
    };
    // create a network
    var container = this.$.mygraph;
    // initialize your network!
    var network = new vis.Network(container, data, options);

  }


  listNodes(hass) {
    let states=new Array();
    for (let state in hass.states)
    {
      states.push({name:state, entity:hass.states[state]});
    }
    let zwaves = states.filter((s) => {return s.name.indexOf("zwave.") ==0});
    let result= {"edges":[], "nodes":[]};

    let hubNode=0;
    let neighbours={};

    for (let b in zwaves)
    {
       let id=zwaves[b].entity.attributes["node_id"]; 
       let node = zwaves[b].entity;
       if (node.attributes["capabilities"].filter(
			(s) => {return s =="primaryController"}).length > 0) 
       {
         hubNode=id;
       }
       neighbours[id]=node.attributes['neighbors'];

       let entities = states.filter((s) => {
                    return ((s.name.indexOf("zwave.") == -1) &&
                            (s.entity.attributes["node_id"] == id)) });
       let batlev=node.attributes.battery_level;       
       let entity={"id":  id,
                   "label": (node.attributes["node_name"] + " (" + node.attributes["averageResponseRTT"]+"ms)").replace(/ /g, "\n"),
                   "group": "unset",
                   "shape": batlev != undefined ? "box" : "circle",
                   "title": "<b>"+node.attributes["node_name"]+"</b>" +
                            "<br />Node: " + id + (node.attributes["is_zwave_plus"] ? "+" : "") +
                            "<br />Product Name: " + node.attributes["product_name"] +
                            "<br />Average Request RTT: " + node.attributes["averageResponseRTT"]+"ms" + 
                            "<br />Power source: " + (batlev != undefined ? "battery (" + batlev +"%)" : "mains") +
                            "<br />" + entities.length + " entities",
                   "forwards": (node.attributes.is_awake && node.attributes.is_ready && !node.attributes.is_failed &&
                                node.attributes.capabilities.includes("listening"))
                  };

       if (node.attributes["is_failed"])
       {
         entity.label = "FAILED: "+entity.label;
         entity["font.multi"]=true;
         entity["title"]="<b>FAILED: </b>"+entity.title;
         entity["group"]="Failed";
       }

       if (hubNode == id)
       {
         entity.label="ZWave Hub";
         entity.borderWidth= 2;
         entity.fixed=true;
       }

       result.nodes.push(entity);
    }

    
    if (hubNode > 0)
    {
      let layer=0;
      let previousRow=[hubNode];
      let mappedNodes=[hubNode];
      while (previousRow.length > 0)
      {
        layer = layer+1;
        let nextRow=[];
        for (let target in previousRow)
        {
          result.nodes.filter((n) => {return ((n.id ==previousRow[target]) && (n.group="unset"))})
                      .every((d) => {d.group="Layer " + layer;})

          if (result.nodes.filter((n) => {return ((n.id == previousRow[target]) && (n.forwards))}).length > 0)
          {
            let row=neighbours[previousRow[target]];
            for (let node in row)
            {

              if (!mappedNodes.includes(row[node]))
              {
                result.edges.push({"from":row[node], "to":previousRow[target]});
                nextRow.push(row[node]);
              }
            }
          }
        }

        for (let idx in nextRow)
        {
          mappedNodes.push(nextRow[idx]);
        }
        previousRow = nextRow;
      }
    }
    return result;
   }

  
}
customElements.define(HaPanelZWave.is, HaPanelZWave);
</script>

So how does this work? It just updates the graph in real time vs having to call the python script in the former method?

That is the big difference. I found when I moved from one style to another of Homeassistant, external scripts were the biggest hassle. I liked having the graph, but didn’t like the script idea, and also preferred the idea of the graph getting the data directly from the HA system

Here’s the newest update to the panel code. I’ve swapped out the graph library, and added a couple of Legends to the graph (explaining colors and shapes). I’m not done yet, I still need to fix the connections, and add the ability to move nodes around.

If a zwave node is failed, it is now colored Red.

Let me know what you think if you use it.

<dom-module id='ha-panel-zwavegraph2'>
  <template>
    <style include="ha-style">
      .node.Layer1 > rect, .node.Layer1 > circle
      {
        fill: lightblue;
      } 

      .node.Layer2 > rect, .node.Layer2 > circle
      {
        fill: yellow;
      }

      .node.Layer3 > rect, .node.Layer3 > circle
      {
        fill: green;
      }

      .node.Layer4 > rect, .node.Layer4 > circle
      {
        fill: orange;
      }

      .node.Layer5 > rect, .node.Layer5 > circle
      {
        fill: grey;
      }

      .node.Error > rect, .node.Error > circle
      {
        fill: red;
      }

      .node.unset > rect, .node.unset > circle
      {
        fill: white;
      }

      .node > rect, .node > circle
      {
        stroke: black;
      }

      .node text
      {
        font-size: 12px;
      }

      .edgePath path {
        stroke: #333;
        fill: #333;
        stroke-width: 1.5px;
      }

    </style>

    <app-header-layout has-scrolling-region>
      <app-header slot="header" fixed>
        <app-toolbar>
          <ha-menu-button narrow='[[narrow]]' show-menu='[[showMenu]]'></ha-menu-button>
          <div main-title>Zwave Graph</div>
        </app-toolbar>
      </app-header>

      <div class="content">
        <svg id="svg"></svg>
      </div>
    </app-header-layout>

  </template>

</dom-module>
<script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre-d3/0.6.1/dagre-d3.js"></script>
<script>
class HaPanelZWave extends Polymer.Element {
  static get is() { return 'ha-panel-zwavegraph2'; }

  static get properties() {
    return {
      // Home Assistant object
      hass: Object,
      // If should render in narrow mode
      narrow: {
        type: Boolean,
        value: false,
      },
      // If sidebar is currently shown
      showMenu: {
        type: Boolean,
        value: false,
      },
      // Home Assistant panel info99
      // panel.config contains config passed to register_panel serverside
      panel: Object,
    };
  }


  ready() {
    super.ready();

    var data=this.listNodes(this.hass);


    var g = new dagreD3.graphlib.Graph().setGraph({});
    g.graph().rankDir="BT";
    g.graph().nodeSep=15;

    for (var i = 0; i < data["nodes"].length; i++) {
      var node=data["nodes"][i];
      g.setNode(node.id, node);
    }

    for (var i =0; i< data["edges"].length; i++)
    {
      g.setEdge(data["edges"][i].from, data["edges"][i].to, {label:"", arrowhead: "undirected"})
    }

    // Create the renderer
    var render = new dagreD3.render();

    var svg=d3.select(this.$.svg);
    var inner = svg.append("g").attr("transform", "translate(20,120)scale(1)");

    g.graph().minlen = 0;

    // Run the renderer. This is what draws the final graph.
    render(inner, g);


    // Add the title element to be used for a tooltip (SVG functionality)
    inner.selectAll("g.node")
        .append("title").html(function(d) {return g.node(d).title;});
    svg.attr('height', g.graph().height + 140);
    svg.attr('width', g.graph().width + 140);

    var legends=[{shape: "rect", color:"lightblue", text:"Hub"},
                 {shape: "rect", color:"yellow", text:"1 hop"},
                 {shape: "rect", color:"green", text:"2 hops"},
                 {shape: "rect", color:"orange", text:"3 hops"},
                 {shape: "rect", color:"grey", text:"4 hops"},
                 {shape: "rect", color:"red", text:"Failed Node"},
                 {shape: "rect", color:"white", text:"Unconnected"}];

    this.addLegend(svg, legends, 5, 20);


    legends = [{shape: "circle", text: "Mains power", color: "black"},
               {shape: "rect", text: "Battery power", color: "black"}]
    
    this.addLegend(svg, legends, 5 + g.graph().width/4, 20)



  }

  addLegend(svg, legends, startX, startY)
  {
    for(var counter=0;counter < legends.length;counter++)
    {
      if (legends[counter].shape == "circle")
      {
        svg.append(legends[counter].shape)
          .attr('cx', startX + 5 )
          .attr('cy',startY + 5 + 20 * counter)
          .attr('r', 5)
          .style("stroke", "black")
          .style("fill", legends[counter].color);
      }
      else
      {
        svg.append(legends[counter].shape)
          .attr('x',startX)
          .attr('y',startY + 20 * counter)
          .attr('width', 10)
          .attr('height', 10)
          .style("stroke", "black")
          .style("fill", legends[counter].color);
      }
      svg.append('text')
        .attr("x", startX + 20)
        .attr("y", startY + 10 + 20*counter)
        .text(legends[counter].text)
        .attr("class", "textselected")
        .style("text-anchor", "start")
        .style("font-size", 15);

    }

  }

  listNodes(hass) {
    let states=new Array();
    for (let state in hass.states)
    {
      states.push({name:state, entity:hass.states[state]});
    }
    let zwaves = states.filter((s) => {return s.name.indexOf("zwave.") ==0});
    let result= {"edges":[], "nodes":[]};

    let hubNode=0;
    let neighbours={};

    for (let b in zwaves)
    {
       let id=zwaves[b].entity.attributes["node_id"]; 
       let node = zwaves[b].entity;
       if (node.attributes["capabilities"].filter(
			(s) => {return s =="primaryController"}).length > 0) 
       {
         hubNode=id;
       }
       neighbours[id]=node.attributes['neighbors'];

       let entities = states.filter((s) => {
                    return ((s.name.indexOf("zwave.") == -1) &&
                            (s.entity.attributes["node_id"] == id)) });
       let batlev=node.attributes.battery_level;       
       let entity={"id":  id,
                   "label": (node.attributes["node_name"] + " (" + node.attributes["averageResponseRTT"]+"ms)").replace(/ /g, "\n"),
                   "class": "unset",
                   "shape": batlev != undefined ? "rect" : "circle",
                   "title": "<b>"+node.attributes["node_name"]+"</b>" +
                            "<br />Node: " + id + (node.attributes["is_zwave_plus"] ? "+" : "") +
                            "<br />Product Name: " + node.attributes["product_name"] +
                            "<br />Average Request RTT: " + node.attributes["averageResponseRTT"]+"ms" + 
                            "<br />Power source: " + (batlev != undefined ? "battery (" + batlev +"%)" : "mains") +
                            "<br />" + entities.length + " entities",
                   "forwards": (node.attributes.is_awake && node.attributes.is_ready && !node.attributes.is_failed &&
                                node.attributes.capabilities.includes("listening"))
                  };

       if (node.attributes["is_failed"])
       {
         entity.label = "FAILED: "+entity.label;
         entity["font.multi"]=true;
         entity["title"]="<b>FAILED: </b>"+entity.title;
         entity["group"]="Failed";
       }

       if (hubNode == id)
       {
         entity.label="ZWave Hub";
         entity.borderWidth= 2;
         entity.fixed=true;
       }

       result.nodes.push(entity);
    }

    
    if (hubNode > 0)
    {
      let layer=0;
      let previousRow=[hubNode];
      let mappedNodes=[hubNode];
      while (previousRow.length > 0)
      {
        layer = layer+1;
        let nextRow=[];
        for (let target in previousRow)
        {
          result.nodes.filter((n) => {return ((n.id ==previousRow[target]) && (n.group="unset"))})
                      .every((d) => {d.class="Layer" + layer;})

          if (result.nodes.filter((n) => {return ((n.id == previousRow[target]) && (n.forwards))}).length > 0)
          {
            let row=neighbours[previousRow[target]];
            for (let node in row)
            {

              if (!mappedNodes.includes(row[node]))
              {
                result.edges.push({"from":row[node], "to":previousRow[target]});
                nextRow.push(row[node]);
              }
            }
          }
        }

        for (let idx in nextRow)
        {
          mappedNodes.push(nextRow[idx]);
        }
        previousRow = nextRow;
      }
    }
    return result;
   }


}
customElements.define(HaPanelZWave.is, HaPanelZWave);
</script>
1 Like

That sounds great. So with each and every update to any of the nodes, the graph will represent this immediately? Hate to be dense, I just want to be clear on my expectations. BTW, I’m going to try this out now.

Yes, it should update automatically (I hadn’t actually tried that, but it should work. Please let me know).

You might need to run a ZWave repair to to fully update the graph.

I’ll test it out and I’m leaving the python version up so I can do a side by side. The way I had it before was the python version would run after zwave was finished starting up on a restart. So this will be cool to see the differences.

Getting this error. All I did was copy your latest version and added the yaml config just how you have it. I’m guessing the naming is wrong?

/api/panel_custom/zwavegraph2:128:17 Uncaught TypeError: Cannot set property ‘x’ of undefined

Sorry, that was my fault. I had a test in the code I posted.

If you look for these two lines (at lines 128 and 129)

g.node(19).x=750;
g.node(19).y=20;

and delete those, you should be good to go. I’ve updated the code in my previous post.

Still getting same error. Both of those lines removed.

api/panel_custom/zwavegraph2:128:17 Uncaught TypeError: Cannot set property 'x' of undefined```

I did a restart as well.

You might need to press Ctrl-F5 (force a full refresh of that page).

hmmm, tried that and even tried opening the dev tools and doing the refresh and still getting the frontend.js errors. Opened it up on Safari and it loads.

I’m a web developer, and I hate browsers :smiley:

True that! Tried my external URL and now I get this…

[frontend.js.latest.201808161] :0:0 Script error.

You’ll have better luck with the error message in the console of the browser.

dagre-d3.js:1000 Uncaught TypeError: Cannot set property 'label' of undefined
    at dagre-d3.js:1000
    at Array.forEach (<anonymous>)
    at preProcessGraph (dagre-d3.js:998)
    at fn (dagre-d3.js:921)
    at HTMLElement.ready (zwavegraph2:129)
    at HTMLElement._enableProperties (properties-changed.js:315)
    at HTMLElement.connectedCallback (properties-mixin.js:203)
    at HTMLElement.connectedCallback (element-mixin.js:538)
    at Object.then (ha-panel-custom.js:75)```