/** * Zigbee Monitor Driver * Version: See ChangeLog * Download: See importUrl in definition * Description: Provides insights into the performance of a Zigbee repeater. Also allows the publishing and merging of Hub based Zigbee information. * * Copyright 2023 Gary J. Milne * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the * implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * * License: * You are free to use this software in an un-modified form. Software cannot be modified or redistributed without prior consent of the author. * * Zigbee Repeater Monitor - CHANGELOG * Version 1.0.0 - Initial Release * Version 1.0.1 - Changed endpointId from static "0x01" to dynamic using "0x${device.endpointId}" * Version 1.0.2 - Updated getHubInfo() to support alternate path "/hub/zigbeeDetails/json" for Zigbee information being introduced in > "2.3.7.1" * Version 1.0.3 - Add function compareVersions() to do a precise version comparison between the current firmware version on the box and a reference version, in this case "2.3.7.1". * Version 1.0.4 - Updated logic for checking online\offline status so that a device is marked online as soon as any traffic is received. * Version 1.0.5 - Fixed error on line 502 wich incorrectly calls log.info(...) when it should be log(...). * Version 1.0.6 - Added logica to handle a change in the JSON structure from zbDevices to devices at line 540. * * Authors Notes: * For more information on the Zigbee Monitor Driver see: * Original posting on Hubitat Community forum: https://community.hubitat.com/t/release-zigbee-monitor-driver-like-xray-glasses-for-zigbee-repeaters-and-simple-switches/127676 * Zigbee Monitor Documentation: N/A * * Gary Milne - January 24th, 2023 @ 2:26 PM * Build Version 48 * **/ import groovy.json.JsonSlurper import groovy.transform.Field import java.text.SimpleDateFormat @Field def ZIGBEE_ERROR_MAP = ["00":"SUCCESS", "80":"INV_REQUESTTYPE", "81":"DEVICE_NOT_FOUND", "82":"INVALID_EP", "83":"NOT_ACTIVE", "84":"NOT_SUPPORTED", "85":"TIMEOUT", "86":"NO_MATCH", "88":"NO_ENTRY", "89":"NO_DESCRIPTOR", "8A":"INSUFFICIENT_SPACE", "8B":"NOT_PERMITTED", "8C":"TABLE_FULL", "8D":"NOT_AUTHORIZED", "8E":"DEVICE_BINDING_TABLE_FULL"] @Field def dataSeparatorMap = [0:",", 1:";", 2:":", 3:"|"] @Field static final driverVersion = "Zigbee Monitor Driver v1.0.6 (1/24/24)" @Field static final driverBuild = 48 metadata { definition (name: "Zigbee Monitor Driver", namespace: "garyjmilne", author: "Gary J. Milne", singleThreaded:true, importUrl: "https://raw.githubusercontent.com/GaryMilne/Hubitat-Zigbee/main/Zigbee_Monitor_Driver.groovy",) { capability "Actuator" capability "Switch" capability "HealthCheck" capability "SignalStrength" //Commands command "configure", [ [name:"⚙️ Configure: Retrieves basic information from the device and populates the Driver Details Data area. This is optional."]] command "getDeviceInfo", [ [name:"🔍 Get Device Neighbors and Routes tables from the device and display information based upon the the preferences section below. Note: Not all device support retrieval of this data. See Help in preferences section."]] command "getHubInfo", [ [name:"📜 Get Hub Info: This command initiates two http requests to the hub. 1️⃣ Get JSON data from: /hub2/zigbeeInfo or /hub/zigbeeDetails/json. 2️⃣ Get JSON data from: /hub/zigbee/getChildAndRouteInfoJson. \ Information received is displayed according to the preferences section below."]] command "off", [ [name:"🔴 Switch Off: Turns the Zigbee switch OFF -- OR -- Turns OFF the Zigbee device logging. See preferences."]] command "on", [ [name:"🟢 Switch On: Turns the Zigbee switch ON -- OR -- Turns ON the Zigbee device logging. See preferences."]] command "ping", [ [name:"📡 Ping: Sends a command to the Zigbee device. If a response is received it resets the HealthCheck timeout. See preferences."]] command "initialize", [ [name:"❗Initialize:Run this command first. On first run it does the following: 1️⃣Clears all settings and jobs that may exist from the previous driver. 2️⃣Configures the default values for the driver settings. \ 3️⃣Creates the default state data structures. 🔄 On subesequent runs it does the following: 🅰Clears any scheduled jobs and rebuilds them based on current settings. 🅱️Initializes any required data structures that may be absent or empty."]] command "wipe", [ [name:"🧽 Wipe: This command wipes data from the device driver. 💣No parameter deletes ALL data categories. Optional parameters: 1️⃣Deletes ALL State data. 2️⃣Deletes ALL Current State data. \ 3️⃣Deletes ALL Preferences\\Settings. 4️⃣Deletes ALL scheduled jobs. 5️⃣Deletes ALL Device Details Data (Configure Data). 6️⃣Deletes ALL State and Current State data. ⚠️Use wipe before switching this device to a different device driver. \ If you want to re-use this driver after a wipe you must perform Initialize() again.", type: "NUMBER", description: "The category to delete (1 - 6)."]] //command "test" //Attributes attribute "Status", "string" //Health attribute "healthStatus", "enum", ["offline", "online", "unknown"] attribute "checkInterval", "number" //Device Attributes //attribute "diagnostic", "string" attribute "deviceChildren", "string" attribute "deviceChildCount", "number" attribute "deviceDataCollectionMode", "string" attribute "deviceLastUpdate", "string" attribute "deviceNeighborCount", "number" attribute "deviceNeighbors", "string" attribute "deviceParent", "string" attribute "deviceRoutes", "string" attribute "deviceRouteCount", "number" attribute "deviceRepeaters", "string" attribute "deviceRepeatersCount", "number" attribute "switch", "string" //Hub Attributes attribute "hubChildCount", "number" attribute "hubChildren", "string" attribute "hubDataCollectionMode", "string" attribute "hubDeviceCount", "number" attribute "hubLowestLqiValue", "number" attribute "hubLowestLqiName", "string" attribute "hubLastUpdate", "string" attribute "hubNeighbors", "string" attribute "hubNeighborCount", "number" attribute "hubRoutesActive", "string" attribute "hubRouteCountActive", "number" attribute "hubRouteCountTotal", "number" attribute "hubRouteCountUnused", "number" //Zigbee Attributes attribute "zigbeePanID", "number" attribute "zigbeeExtPanID", "string" attribute "zigbeeNetworkState", "string" attribute "zigbeeChannel", "number" attribute "zigbeePowerLevel", "number" attribute "zigbeeJoinMode", "boolean" } } section("Configure the Inputs"){ //Hub input name: "hublink", type: "hidden", title: bold("Hub: ") + "Community Thread Link", description: italic("This driver has multiple modes of operation. It can read a variety of Zigbee information from the hub and the device as an aid to monitoring and troubleshooting. The above link will take you to the community thread where you will find documentation and support.") input name: "hubDataCollectionMode", type: "enum", title: bold("Hub: ") + dodgerBlue("Data Collection Mode:"), description: italic("This setting determines which types of data will be collected from Hub.)"), options: [ [0:"No Data Collection"],[1:"Collect Only Zigbee Address Information (for name resolution)"],[2:"Collect All Zigbee Information"] ], defaultValue: 1, required: true input name: "hubPollInterval", type: "enum", title: bold("Hub: ") + dodgerBlue("Data Polling Interval:"), description: italic("The time in minutes between requests sent to the hub for updated Zigbee information. (Default - 24 hours)."), options: [ [0:" Never"],[1:" 1 minute"],[2:" 2 minutes"],[5:" 5 minutes"],[10:"10 minutes"],[15:"15 minutes"],[30:"30 minutes"],[60:" 1 hour"],[120:" 2 hours"],[180:" 3 hours"],[360:" 6 hours"],[720:" 12 hours"],[1440:" 24 hours"] ], defaultValue: 1440, required: true //Device input name: "devicelink", type: "hidden", title: bold("Device: ") + "Device Compatibility Link", description: italic("This driver uses 'generic' zigbee calls and will work on any repeater/switch device that has implemented support for Zigbee management requests. The above link will take you to the community thread where any specific device compatibility is discussed.") input name: "deviceDataCollectionMode", type: "enum", title: bold("Device: ") + dodgerBlue("Data Collection Mode:"), description: italic("This setting determines which types of data will be collected from the device.)"), options: [ [0:"No Data Collection"],[1:"Collect Only Neighbor Data"],[2:"Collect Only Routing Data"],[3:"Collect Neighbor & Routing Data"]], defaultValue: 1, required: true input name: "devicePollInterval", type: "enum", title: bold("Device: ") + dodgerBlue("Data Polling Interval."), description: italic("The time between requests sent to the device for updated Neighbor and Routing information. (Default - 60 Minutes)"), options: [ [0:" Never"],[1:" 1 minute"],[2:" 2 minutes"],[5:" 5 minutes"],[10:"10 minutes"],[15:"15 minutes"],[30:"30 minutes"],[60:" 1 hour"],[120:" 2 hours"],[180:" 3 hours"] ], defaultValue: 60, required: true //Both input name: "neighborSortOrder", type: "enum", title: bold("Hub & Device: ") + dodgerBlue("Neighbor Sort Order."), description: italic("The order the device neighbors are displayed in the xxxNeighbors attribute.."), options: [ [0:"Highest LQI - Neighbor"], [1:"Lowest LQI - Neighbor"], [2:"Neighbor(A-Z) - LQI"]], defaultValue: 0, required: true input name: "routeSortOrder", type: "enum", title: bold("Hub & Device: ") + dodgerBlue("Route Sort Order."), description: italic("The order the device routes are displayed in the xxxRoutes Attribute."), options: [ [0:"Next Hop ➡ Device"], [1:"Device via Next Hop"] ], defaultValue: 0, required: true input name: "bothblank", type: "hidden", title: " ", description: "" //Misc input name: "switchBehaviour", type: "enum", title: bold("Switch: ") + dodgerBlue("Switch Behaviour: "), description: italic("Determine what actions the On\\Off buttons will perform."), options: [ [0:"Normal On\\Off behaviour. Typically used when the Zigbee router is also a switch."],[1:"On\\Off controls will start\\stop the scheduled data collection. Typically used for a dedicated Zigbee router."] ], defaultValue: 0 input name: "inactivityLimit", type: "enum", title: bold("Health: ") + dodgerBlue("Inactivity Limit:"), description: italic("The amount of time that must elapse in seconds, with no Zigbee traffic received, in order for the device to be marked offline. When the remaining time drops under 5 minutes a ping command will be issued as a final test."), options: [ [0:" Never"],[15:"15 minutes"],[30:"30 minutes"],[60:" 1 hour"], [120:" 2 hours"], [180:" 3 hours"], [360:" 6 hours"] ], defaultValue: 60, required: true input name: "miscblank", type: "hidden", title: " ", description: "" //Advanced input name: "dataSeparator", type: "enum", title: bold("Advanced: ") + dodgerBlue("Record Delimiter:"), description: italic("The character(s) used to separate each record for display and data feed purposes. These separators can be processed by Tile Builder Advanced to provide enhanced formatting. (Default - 0 (Comma,) )"), options: [ [0:"Comma ,"],[1:"Semi-Colon ;"],[2:"Colon :"],[3:"Pipe |"]], defaultValue: 0, required: true input name: "deviceExtendedInfo", type: "enum", title: bold("Advanced: ") + dodgerBlue("Include Extended Information:"), description: italic("Normally extended information is turn off to reduce space. Turning it on adds multiple Neighbor and Routing fields to the state display that may be of interest in troubleshooting."), options: [ [0:"False"],[1:"True"] ], defaultValue: 0, required: true input name: "deviceShowRoutes", type: "enum", title: bold("Advanced: ") + dodgerBlue("Show Routes:"), description: italic("Choose whether to show only Active Device routes or All routes. Inactive Routes are usually route table entries that are waiting to be reallocated and not of interest."), options: [ [0:"Active Routes Only"],[1:"All Routes, Any State"] ], defaultValue: 0, required: true input name: "deviceAppendAddress", type: "enum", title: bold("Advanced: ") + dodgerBlue("Append Network Address."), description: italic("You can choose to append a network address to the device name for troubleshooting purposes."), options: [ [0:"Do not append an address to the device name."],\ [1:"Append the Hubitat 4 digit DNI."], [2:"Append the last 6 digits of the Zigbee ID as used by the XBEE Network Assistant."] ], defaultValue: 0 input name: "loglevel", type: "enum", title: bold("Advanced:") + dodgerBlue("Log Level"), description: italic("The log level dictates how much information goes to the Hubitat log. Higher numbers result in more logging. At the default value of Normal, only errors and bulk operations are logged.(Default: 0.)"), options: [ [0:" Normal"],[1:" Trace"],[2:" Debug"] ], defaultValue: 0, required: true } def test(){ if (state.data.driverVersion == null) state.data.driverVersion = driverVersion if (state.data.driverBuild == null) state.data.driverBuild = driverBuild } //********************************************************************************************************************************************************************* //****** //****** Start of Basic System Functions //****** //********************************************************************************************************************************************************************* //Installed gets run when the device driver is selected and saved def installed(){ log ("Installed", "Installed with settings: ${settings}", 0) } //Updated gets run when the "Save Preferences" button is clicked def updated(){ log ("Update", "Updated with settings: ${settings}", 0) updateDisplay() //Recreate the jobs based on the new values. unschedule() createJobs() } //Uninstalled gets run when called from a parent app??? def uninstalled() { log ("Uninstall", "Device uninstalled", 0) } // Configure the driver data area based on information from the device. def configure() { List cmds = [] // Configure Zigbee reporting in the event it is a switch cmds.addAll zigbee.configureReporting(0x0006, 0x0000, 0x10, 300, 600, 0x00) // Report On/Off status every 5 to 10 minutes // Query Zigbee attributes of interest cmds.addAll zigbee.readAttribute(0x0000, 0x0000) // ZCLVersion cmds.addAll zigbee.readAttribute(0x0000, 0x0001) // ApplicationVersion cmds.addAll zigbee.readAttribute(0x0000, 0x0002) // Stack Version cmds.addAll zigbee.readAttribute(0x0000, 0x0003) // HWVersion cmds.addAll zigbee.readAttribute(0x0000, 0x0004) // ManufacturerName cmds.addAll zigbee.readAttribute(0x0000, 0x0005) // ModelIdentifier // Send the commands in batch to the device //cmds.add "he raw ${device.deviceNetworkId} 0x0000 0x0000 0x0005 {00 ${zigbee.swapOctets(device.deviceNetworkId)}} {0x0000}" hubitat.device.HubMultiAction hubMultiAction = new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE) sendHubCommand(hubMultiAction) } //Cleans up the attributes used to display the status of data collection. It is called from Updated() and Initialise() def updateDisplay(){ log ("updateDisplay", "Setting Data Collection Mode attributes.", 0) if (hubDataCollectionMode == null ) sendEvent(name: "hubDataCollectionMode", value: red("Refresh & Save Preferences") ) if (hubDataCollectionMode == "0" ) sendEvent(name: "hubDataCollectionMode", value: red("Off") ) if (hubDataCollectionMode == "1" ) sendEvent(name: "hubDataCollectionMode", value: green("Only Addresses") ) if (hubDataCollectionMode == "2" ) sendEvent(name: "hubDataCollectionMode", value: green("All Zigbee Information") ) if (deviceDataCollectionMode == null ) sendEvent(name: "deviceDataCollectionMode", value: red("Refresh & Save Preferences") ) if (deviceDataCollectionMode == "0" ) sendEvent(name: "deviceDataCollectionMode", value: red("Not Configured") ) if (deviceDataCollectionMode == "1" ) sendEvent(name: "deviceDataCollectionMode", value: green("Only Neighbor Data") ) if (deviceDataCollectionMode == "2" ) sendEvent(name: "deviceDataCollectionMode", value: green("Only Routing Data") ) if (deviceDataCollectionMode == "3" ) sendEvent(name: "deviceDataCollectionMode", value: green("All Neighbor & Routing Data") ) //log.info ("A: $deviceDataCollectionMode B: $hubDataCollectionMode") } //******************************************************************************************************************************************************************** //****** //****** End of System Required functions //****** //******************************************************************************************************************************************************************** //********************************************************************************************************************************************************************* //****** //****** Start Interface Commands //****** //********************************************************************************************************************************************************************* //Sends out a Zigbee command to the device. We don't care what the response is as long as something comes back to the parse function. def ping(){ log("Ping","Issuing command: DNI-${device.deviceNetworkId} ID-{${device.zigbeeId}}",1) //We use this version of the command as it works on both XBee and regular devices. cmd = "zdo bind ${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0000 {${device.zigbeeId}} {}" return cmd } //Turns on the Switch or Starts data collection def on() { //For some reason zigbee.on() does not work consistently. if (settings.switchBehaviour == "0" ) return "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0006 1 {}" if (settings.switchBehaviour == "1" ) { createJobs(); sendEvent(name: "switch", value: "on"); log("on","Data collection enabled at configured intervals.",0) } } //Turns off the Switch or Stops data collection def off() { //For some reason zigbee.off() does not work consistently. if (settings.switchBehaviour == "0" ) return "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0006 0 {}" if (settings.switchBehaviour == "1" ) { wipe(4); sendEvent(name: "switch", value: "off"); log("off","Data collection disabled if configured.",0) } } //Initialize the required data structures. def initialize(){ log("initialize", "Initializing Device.", 0) //Configure the default settings //We need this because the install routine runs on device installation, but not when a device driver is changed. if (hubDataCollectionMode == null) device.updateSetting("hubDataCollectionMode", [value:"1", type:"enum"]) if (hubPollInterval == null) device.updateSetting("hubPollInterval", [value:"1440", type:"enum"]) if (deviceDataCollectionMode == null) device.updateSetting("deviceDataCollectionMode", [value:"1", type:"enum"]) if (devicePollInterval == null) device.updateSetting("devicePollInterval", [value:"60", type:"enum"]) if (neighborSortOrder == null) device.updateSetting("neighborSortOrder", [value:"0", type:"enum"]) if (routeSortOrder == null) device.updateSetting("routeSortOrder", [value:"0", type:"enum"]) if (deviceExtendedInfo == null) device.updateSetting("deviceExtendedInfo", [value:"0", type:"enum"]) if (deviceShowRoutes == null) device.updateSetting("deviceShowRoutes", [value:"0", type:"enum"]) if (deviceAppendAddress == null) device.updateSetting("deviceAppendAddress", [value:"0", type:"enum"]) if (inactivityLimit == null) device.updateSetting("inactivityLimit", [value:"60", type:"enum"]) if (dataSeparator == null) device.updateSetting("dataSeparator", [value:"0", type:"enum"]) if (loglevel == null) device.updateSetting("loglevel", [value:"0", type:"enum"]) if (switchBehaviour == null) device.updateSetting("switchBehaviour", [value:"0", type:"enum"]) //App data storage if (state.data == null) state.clear() if (state.data == null) state.data = [:] state.Comment = "How to Reset: 1) Wipe, 2) Initialize, 3) Refresh Browser (F5), 4) Modify Preferences and Save, 5) Perform Get Hub Info, 6) Perform Get Device Info. You can repeat this process as many times as you wish." if (state.data.isInitialized != true) state.data.isInitialized = true //Initialize driver version information if (state.data.driverVersion == null) state.data.driverVersion = driverVersion if (state.data.driverBuild == null) state.data.driverBuild = driverBuild //This is a one time flag set the very first time initialize() is run. if (state.device == null) state.device = [:] //This group is for information taken from this device if (state.device == null) state.device = [:] if (state.device.neighbors == null) state.device.neighbors = [:] if (state.device.routes == null) state.device.routes = [:] //This group is for information taken from this URL:/hub/zigbee/getChildAndRouteInfoJson if (state.hubChildandRouteInfo == null) state.hubChildandRouteInfo = [:] if (state.hubChildandRouteInfo.routes == null) state.hubChildandRouteInfo.routes = [:] if (state.hubChildandRouteInfo.neighbors == null) state.hubChildandRouteInfo.neighbors = [:] if (state.hubChildandRouteInfo.children == null ) state.hubChildandRouteInfo.children = [:] if (state.hubChildandRouteInfo.devices == null ) state.hubChildandRouteInfo.devices = [:] //This group is for information taken from /hub2/zigbeeInfo or /hub/zigbeeDetails/json if (state.hubZigbeeInfo == null) state.hubZigbeeInfo = [:] if (state.hubZigbeeInfo.zbDevices == null ) { state.hubZigbeeInfo.zbDevices = [:] } if (state.hubZigbeeInfo.zbProperties == null ) { state.hubZigbeeInfo.zbProperties = [:] } //Schedule data updates and Health Check runInMillis(2000, 'createJobs') //This is the specified attribute for use with the healthCheck attribute. In this release it is not configurable. sendEvent(name: "checkInterval", value: 15) //Update the Data Collection Attributes with the current settings. updateDisplay() myString = mark("Device Initialized - Refresh Browser!") sendEvent(name: "Status", value: myString) } //Remove all State Data, Device Preferences and Scheduled Tasks def wipe(category){ if (category == null ) log("wipe:", "Performing wipe of all categories.", 0) else log("wipe:", "Perform wipe with category $category.", 0) //Delete All State Data if (category == 1 || category == null){ state.clear() log ("Wipe:","State is: $state",0) } //Delete All Current State data if (category == 2 || category == null){ device.currentStates.each { entry -> name = entry.name device.deleteCurrentState("$name") } log ("Wipe:","Current States are: $device.currentStates", 0) } // Delete all of the Preferences\Settings. if (category == 3 || category == null){ settings.each { key, value -> device.removeSetting("$key") } log ("Wipe:","Preferences\\Settings are: $settings", 0) } //Delete All scheduled tasks for this device if (category == 4 || category == null){ unschedule("") log ("Wipe:","All scheduled jobs have been removed.", 0) } //Delete the contents of the Driver Data area. Does not affect the endpointId field. if (category == 5 || category == null){ //Make a copy of the data names because we can't iterate the list and modify it at the same time. dataCopy = [] device.data.each { entry -> // Split the string at the '=' sign and extract the name to the left of the '=' sign def parts = entry.toString().split('=') def name = parts[0] dataCopy.add (name) } //Now go through the list and delete all of the items. dataCopy.each { entry -> device.removeDataValue("$entry") } } //Remove all State and Current State but leave settings and scheduled jobs alone. if (category == 6 ){ state.clear() device.currentStates.each { entry -> name = entry.name device.deleteCurrentState("$name") } log ("Wipe:","All State and Current State variables have been wiped.", 0) //This group is for information taken from this URL:/hub/zigbee/getChildAndRouteInfoJson state.hubChildandRouteInfo = [:]; state.hubChildandRouteInfo.routes = [:]; state.hubChildandRouteInfo.neighbors = [:]; state.hubChildandRouteInfo.children = [:] ; state.hubChildandRouteInfo.devices = [:] //This group is for information taken from /hub2/zigbeeInfo or /hub/zigbeeDetails/json state.hubZigbeeInfo = [:]; state.hubZigbeeInfo.zbDevices = [:]; state.hubZigbeeInfo.zbProperties = [:] //This group is for information taken from the device state.device = [:]; state.device.routes = [:]; state.device.neighbors = [:]; state.device.controlN = [:]; state.device.controlR = [:] } } //********************************************************************************************************************************************************************* //****** //****** End Interface Commands //****** //********************************************************************************************************************************************************************* //********************************************************************************************************************************************************************* //****** //****** Start Health Commands //****** //********************************************************************************************************************************************************************* //Performs a periodic check to see if there has been recent activity. When it gets within checkInterval minutes of the inactivityLimit a ping() command is issued to test the operation. //Note: This command needs a def vs void because the ping() command has a return value and will not work when using the latter. def healthCheck(){ int checkIntervalms = device.currentValue('checkInterval') * 60 * 1000 maxInactivity = inactivityLimit.toInteger() * 60 * 1000 currentInactivity = now() - state.data.deviceLastZigbeeActivityms log("healthCheck","Time since last activity is $currentInactivity ms.", 1) //If the currentInactivity plus the checkInterval is less than maxInactivity we don't need to do anything except mark it online. if ( (currentInactivity + checkIntervalms ) < maxInactivity ) { sendEvent(name: "healthStatus", value: "online") log("healthCheck","There is more than $checkIntervalms ms remaining. Do nothing.", 2) } //If the currentInactivity plus the checkInterval is greater than maxInactivity we will initiate a ping test and the parse() command will mark the device online. if ( ( currentInactivity + checkIntervalms ) >= maxInactivity ) { log("healthCheck","There is less than $checkIntervalms ms remaining. Issuing 'ping' command.", 2) ping() } //Test to see if we have exceeded the Inactivity Limit. if (currentInactivity >= maxInactivity ) { sendEvent(name: "healthStatus", value: "offline") log("healthCheck","This device has had no Zigbee activity for a period that exceeds the inactivity limit of $interval minutes. The Health Status of the device is being marked as offline. It will be changed to online when Zigbee traffic is detected.", 0) } } //********************************************************************************************************************************************************************* //****** //****** End Health Commands //****** //********************************************************************************************************************************************************************* //********************************************************************************************************************************************************************* //****** //****** Start Scheduling Commands //****** //********************************************************************************************************************************************************************* //Creates CRON jobs to perform data collection and Health Checks. void createJobs(){ //Schedule data updates log("createJobs","Creating scheduled jobs",1) unschedule() //Data Collection if ( hubDataCollectionMode != null && hubDataCollectionMode != "0" && ( hubPollInterval != null && hubPollInterval.toInteger() > 0) ) scheduleJob(hubPollInterval, "getHubInfo") if ( deviceDataCollectionMode != null && deviceDataCollectionMode != "0" && ( devicePollInterval != null && devicePollInterval.toInteger() > 0) ) scheduleJob(devicePollInterval, "getDeviceInfo") //Health Check interval = device.currentValue('checkInterval') scheduleJob(interval.toInteger(), healthCheck) } //Schedule a job for some time in the future //Use https://cronmaker.com for format. void scheduleJob(interval, command){ int minutes = interval.toInteger() switch(minutes){ case [0]: return break case [1,2,5,10,15,30]: //Example: Every 15 Minute: 0 0/15 * 1/1 * ? * cronJob = "0 0/" + minutes.toString() + " * 1/1 * ? *" break case [60, 120, 180, 360, 720]: //Example: Every 2 Hours: 0 0 0/2 1/1 * ? * hours = minutes/60 cronJob = "0 0 0/" + hours.toString() + " 1/1 * ? *" break case [1440]: case 1440: //Example: Every Day start at 03:00; 0 0 3 1/1 * ? * cronJob = "0 0 3 1/1 * ? *" } log ("scheduleJob:","Created cronJob ${cronJob} for ${command}", 1) schedule(cronJob, command) } //********************************************************************************************************************************************************************* //****** //****** End Scheduling Commands //****** //********************************************************************************************************************************************************************* //********************************************************************************************************************************************************************* //****** //****** Start Hub Related Commands //****** //********************************************************************************************************************************************************************* //I am using a synchronous call because we are hitting the hub in two places and I want them to happen sequentially to not overload the hub. //Also, the call uses the hub loopback address so the network is not involved therefore asynchronous has little advantage. def getHubInfo() { log ("getHubInfo", "Information request initiated.", 1) def myJson = new JsonSlurper() //Start Get data from /hub2/zigbee or /hub/zigbeeDetails/json and put it in hubData1. This contains the long and short Zigbee ID's along with the device name and is used for name resolution. if (hubDataCollectionMode == "1" || hubDataCollectionMode == "2"){ try{ def loopback = "http://127.0.0.1:8080" def URI = "" if ( compareVersions(location.hub.firmwareVersionString, "2.3.7.1") >= 0 ) URI = loopback + "/hub/zigbeeDetails/json" else URI = loopback + "/hub2/zigbeeInfo" log ("getHubInfo", "Using URI: $URI",1) def requestParams = [ uri:URI, contentType: "application/json" ] httpGet(requestParams) { response -> if (response?.status == 200){ //200 is an OK. log ("getHubInfo", "Information received from ${URI}: ${response?.data}", 2) //Now pass this json data to get saved to state. hubInfoZigbeeResponse1(response?.data) } else { log ("getHubInfo", "Error retrieving data from ${URI}: ${response?.status}.", 0) } } } catch(Exception e) { log("getHubInfo","Error $e encountered retrieving data from: ${URI}", 0) } } //End Get data from /hub2/zigbee or /hub/zigbeeDetails/json //Start Get data from hub/zigbee/getChildAndRouteInfoJson and put it in hubData2. This contains the Hubs Zigbee Child info and Route table. Only has the short Zigbee id. if (hubDataCollectionMode == "2"){ try { def URI = "http://127.0.0.1:8080/hub/zigbee/getChildAndRouteInfoJson" def requestParams = [ uri:URI, contentType: "application/json" ] httpGet(requestParams) { response -> if (response?.status == 200){ //200 is an OK. log ("getHubInfo", "Information received from hub/zigbee/getChildAndRouteInfoJson: ${response?.data}", 2) //Now pass this json data to get saved to state. hubInfoZigbeeResponse2(response?.data) } else { log ("getHubInfo", "Error retrieving data from /hub/zigbee/getChildAndRouteInfoJson: ${response?.status}.", 0) } } } catch(Exception e) { log("getHubInfo","Error $e encountered retrieving data from: http://127.0.0.1:8080/hub/zigbee/getChildAndRouteInfoJson", 0) } } //End Get data from hub/zigbee/getChildAndRouteInfoJson } //Receives the json data requested from /hub2/zigbeeInfo or /hub/zigbeeDetails/json. This contains the full Zigbee ID's and is used for name resolution. void hubInfoZigbeeResponse1(myData) { def tmpDevices //Handles a change in the JSON structure between different Hubitat versions. if ( compareVersions(location.hub.firmwareVersionString, "2.3.7.1") >= 0 ) tmpDevices = myData.devices else tmpDevices = myData.zbDevices //Remove some unwanted fields tmpDevices.each{ entry -> entry.remove('type') entry.remove('skipPing') if (hubDataCollectionMode == "1" ){ entry.remove('lastMessage'); entry.remove('messageCount') } } //Now save the shortened list to State. if (hubDataCollectionMode == "1" || hubDataCollectionMode == "2" ) state.hubZigbeeInfo.zbDevices = tmpDevices if ( hubDataCollectionMode == "2" ) { if ( state.hubZigbeeInfo.zbProperties.pan != myData.pan ) state.hubZigbeeInfo.zbProperties.pan = myData.pan if ( state.hubZigbeeInfo.zbProperties.epan != myData.epan ) state.hubZigbeeInfo.zbProperties.epan = myData.epan if ( state.hubZigbeeInfo.zbProperties.networkState != myData.networkState ) state.hubZigbeeInfo.zbProperties.networkState = myData.networkState if ( state.hubZigbeeInfo.zbProperties.channel != myData.channel ) state.hubZigbeeInfo.zbProperties.channel = myData.channel if ( state.hubZigbeeInfo.zbProperties.powerLevel != myData.powerLevel ) state.hubZigbeeInfo.zbProperties.powerLevel = myData.powerLevel if ( state.hubZigbeeInfo.zbProperties.inJoinMode != myData.inJoinMode ) state.hubZigbeeInfo.zbProperties.inJoinMode = myData.inJoinMode } updateHubAttributes(1) } //Receives the json data requested from /hub/zigbee/getChildAndRouteInfoJson void hubInfoZigbeeResponse2(myData) { if (hubDataCollectionMode != "2" ) state.hubChildandRouteInfo = [:] if (hubDataCollectionMode == "2" ) state.hubChildandRouteInfo.devices = myData.devices if (hubDataCollectionMode == "2" ) state.hubChildandRouteInfo.children = myData.children if (hubDataCollectionMode == "2" ) state.hubChildandRouteInfo.neighbors = myData.neighbors if (hubDataCollectionMode == "2" ) state.hubChildandRouteInfo.routes = myData.routes updateHubAttributes(2) } //Updates the Device Attributes for Hub info void updateHubAttributes(int screen){ def myText = "" def myMap def date = new Date() def formattedDate = date.format("E @ HH:mm:ss") def separator = dataSeparatorMap[settings.dataSeparator.toInteger()] sendEvent(name: "hubLastUpdate", value:formattedDate) //Process info supplied by hubInfoZigbeeResponse1 if (screen == 2){ //************** Start of Hub Neighbors ********************** if (neighborSortOrder == "0"){ myMap = state.hubChildandRouteInfo.neighbors.sort { a, b -> b.lqi <=> a.lqi } } if (neighborSortOrder == "1"){ myMap = state.hubChildandRouteInfo.neighbors.sort { a, b -> a.lqi <=> b.lqi } } if (neighborSortOrder == "2"){ myMap = state.hubChildandRouteInfo.neighbors.sort { a, b -> a.id <=> b.id } } processedNeighbors = [:] // Initialize an empty map to track processed neighbors // Loop through the myMap which has been sorted and generate text output for the attribute. log("updateHubAttributes:neighbors", "myMap is: $myMap", 2) myMap.each { map -> // Construct a unique key for each neighbor based on the sorting criteria def neighborKey if (neighborSortOrder != "2") { neighborKey = "${map.lqi}-${map.id}" } if (neighborSortOrder == "2") { neighborKey = "${map.id}-${map.lqi}" } // Check if the neighbor with the same key has already been processed if (!processedNeighbors.containsKey(neighborKey)) { // Add the neighbor to the processedNeighbors map to mark it as processed processedNeighbors[neighborKey] = true // Show LQI first and then device name if (neighborSortOrder != "2") { myText = myText + "${map.lqi}" + " - " + getDeviceName("${map.id}") + separator } // Show device name first and then LQI if (neighborSortOrder == "2") { myText = myText + getDeviceName("${map.id}") + " - " + "${map.lqi}" + separator } } } // Remove the trailing separator if (myText.endsWith(separator)) { myText = myText[0..-(separator.length() + 1)] } log("updateHubAttributes:neighbors", "deviceNeighbors is: $myText", 1) if (myText != "") sendEvent(name: "hubNeighbors", value:myText) else sendEvent(name: "hubNeighbors", value:"None") sendEvent(name: "hubNeighborCount", value:processedNeighbors.size()) //************** End of Hub Neighbors ********************** //This is the only attribute that gets update when we are only in address collection mode. if (state.hubChildandRouteInfo.devices != null && state.hubChildandRouteInfo.devices.size() != null && ( hubDataCollectionMode == "1" || hubDataCollectionMode == "2" ) ) sendEvent(name: "hubDeviceCount", value:state.hubChildandRouteInfo.devices.size() ) //Update the Current State. We only do this if the Data Collection Mode is All. if ( hubDataCollectionMode == "2") { if (state.hubChildandRouteInfo.children.size() != null ) sendEvent(name: "hubChildCount", value:state.hubChildandRouteInfo.children.size()) if (state.hubChildandRouteInfo.neighbors.size() != null ) sendEvent(name: "hubNeighborCount", value:state.hubChildandRouteInfo.neighbors.size()) if (state.hubChildandRouteInfo.devices.size() != null ) sendEvent(name: "hubDeviceCount", value:state.hubChildandRouteInfo.devices.size()) if (state.hubChildandRouteInfo.routes.size() != null ) sendEvent(name: "hubRouteCountTotal", value:state.hubChildandRouteInfo.routes.size()) //************** Start of Hub Children ********************** myText = "" state.hubChildandRouteInfo.children.each{ child -> netAddr = getDeviceName(child.id) lqi = child.lqi ?: "?" //Show LQI if known and the device name myText = myText + "(${lqi})" + " - " + "${netAddr}" + separator } if (myText.endsWith(separator)) { myText = myText[0..-(separator.length() + 1)] } log("updateHubAttributes:children", "hubChildren is: $myText", 1) if (myText != "") sendEvent(name: "hubChildren", value:myText) else sendEvent(name: "hubChildren", value:"None") //************** End of Hub Children ********************** //************** Start of Hub Routes ********************** //Get the number of Active Routes def activeRouteCount = 0 def unusedRouteCount = 0 // Iterate through the Route map and count Active entries myText = "" state.hubChildandRouteInfo.routes.each { entry -> if (entry.status == 'Unused') { unusedRouteCount++ } if (entry.status == 'Active') { activeRouteCount++ ; myText += "[" + getDeviceName (entry.id) + ", " + entry.id + "] via [" + getDeviceName(entry.nextHopId) + ", " + entry.nextHopId + "]" + separator } } // Remove the trailing separator if (myText.endsWith(separator)) { myText = myText[0..-(separator.length() + 1)] } log("updateHubAttributes:Routes", "hubRoutesActive is: $myText", 1) if (myText != "") sendEvent(name: "hubRoutesActive", value:myText) sendEvent(name: "hubRouteCountActive", value:activeRouteCount) sendEvent(name: "hubRouteCountUnused", value:unusedRouteCount) //************** Start of Hub Routes ********************** //************** Start of Hub Lowest LQI ********************** // Iterate through the Neighbor map and get lowest LQI def lowestLqiValue = 256 def lowestLqiName = "" // Iterate through the map and count Active entries and get lowest LQI state.hubChildandRouteInfo.neighbors.each { entry -> def lqi = entry.lqi as int if (lqi < lowestLqiValue) { lowestLqiValue = lqi log("updateHubAttributes", "entry.id is: $entry.id", 2) lowestLqiName = getDeviceName(entry.id) } } sendEvent(name: "hubLowestLqiValue", value:lowestLqiValue) sendEvent(name: "hubLowestLqiName", value:lowestLqiName) //************** End of Hub Lowest LQI ********************** } } //Process info supplied by hubInfoZigbeeResponse2 if (screen == 2 && hubDataCollectionMode == "2"){ sendEvent(name: "zigbeePanID", value:state.hubZigbeeInfo.zbProperties.pan) sendEvent(name: "zigbeeExtPanID", value:state.hubZigbeeInfo.zbProperties.epan) sendEvent(name: "zigbeeNetworkState", value:state.hubZigbeeInfo.zbProperties.networkState) sendEvent(name: "zigbeeChannel", value:state.hubZigbeeInfo.zbProperties.channel) sendEvent(name: "zigbeePowerLevel", value:state.hubZigbeeInfo.zbProperties.powerLevel) sendEvent(name: "zigbeeJoinMode", value:state.hubZigbeeInfo.zbProperties.inJoinMode) } myString = mark("Hub Info Updated - Refresh Browser!") sendEvent(name: "Status", value: myString) } //Extract messageCount, lastMessage\ElapsedTime from state.hubZigbeeInfo.zbDevices. def hubExtractData(){ // Initialize an empty list to store the extracted data def extractedData = [] // Create a SimpleDateFormat object and set the input pattern def inputFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") // Iterate through the zbDevices list and extract the desired fields state.hubZigbeeInfo.zbDevices.each { device -> Date date = inputFormat.parse(device.lastMessage) // Convert the Date to a long timestamp in milliseconds and calculate how long ago it was. long timestamp = date.time elapsedTime = now() - timestamp def extractedDeviceInfo = [ 'name': device.name, 'lastMessage': device.lastMessage, 'messageCount': device.messageCount, 'elapsedTime': elapsedTime ] extractedData.add(extractedDeviceInfo) } //DO NOT DELETE THESE LINES - THEY WILL BE USED LATER FOR DIRECT APP QUERIES// log("hubExtractData", "Extracted data is: $extractedData", 2) //def sortedData = extractedData.sort { a, b -> b.messageCount <=> a.messageCount } //Sorted by messageCount High to low. //def sortedData = extractedData.sort { a, b -> a.messageCount <=> b.messageCount } //Sorted by messageCount low to high. //def sortedData = extractedData.sort { a, b -> a.name <=> b.name } //Sorted by name. //def sortedData = extractedData.sort { a, b -> a.lastMessage <=> b.lastMessage } //Sorted by lastMessage, oldest first. //def sortedData = extractedData.sort { a, b -> b.lastMessage <=> a.lastMessage } //Sorted by lastMessage, newest first. //def sortedData = extractedData.sort { a, b -> b.elapsedTime <=> a.elapsedTime } //Sorted by elaspsedTime since lastMessage, longest first. def sortedData = extractedData.sort { a, b -> a.elapsedTime <=> b.elapsedTime } //Sorted by elaspsedTime since lastMessage, longest first. log("hubExtractData", "sortedData is: $sortedData", 2) } //********************************************************************************************************************************************************************* //****** //****** End Hub Related Commands //****** //********************************************************************************************************************************************************************* //********************************************************************************************************************************************************************* //****** //****** Start Data Lookup Related Commands //****** //********************************************************************************************************************************************************************* //Top level command used to call the Neighbors and Routing commands. void getDeviceInfo(){ log("getDeviceInfo", "Starting refresh of device neighbors and routes.", 1) if (deviceDataCollectionMode == "1" || deviceDataCollectionMode == "3") getNeighbors() if (deviceDataCollectionMode == "2" || deviceDataCollectionMode == "3") getRoutes() } //Retrieve the Device Name if found. def getDeviceName(String address){ log("getDeviceName", "Received Address: $address", 2) //Set the default return name as the provided address which will be used if we don't find a match String myDeviceName = address String myAddr6 = "" //Test to see if it is the Coordinator Address (Hub) if (address == "0000") return "HUB" //Iterate the list of Zigbee devices and try to find a match state.hubZigbeeInfo.zbDevices.each { device -> if (device.shortZigbeeId == address) { myAddr6 = device.zigbeeId.substring(device.zigbeeId.length() - 6) myDeviceName = device.name } } if (deviceAppendAddress == "0") return myDeviceName if (deviceAppendAddress == "1") return myDeviceName + " ❨" + address + "❩" if (deviceAppendAddress == "2") return myDeviceName + " ❨" + myAddr6 + "❩" } //********************************************************************************************************************************************************************* //****** //****** Start Device Related Commands - Neighbors //****** //********************************************************************************************************************************************************************* //Initiates a refresh of the Zigbee neighbor LQI data. def getNeighbors(){ log("getNeighbors", "Refreshing Device Neighbors", 1) //Clear old Neighbor Data state.device.neighbors = [:] state.device.controlN = [:] state.device.controlN.startIndex = 0 getNeighborsData("00") } //This function places calls to the Zigbee network at a given startIndex. def getNeighborsData(startIndex){ cmd = "he raw $device.deviceNetworkId 0x${device.endpointId} 0x00 0x0031 { 01 $startIndex$device.deviceNetworkId } { 0000 }" hubitat.device.HubAction hubAction = new hubitat.device.HubAction(cmd, hubitat.device.Protocol.ZIGBEE) sendHubCommand(hubAction) } //This function parses the response to the Device Neighbors Query and also includes LQI information. def parseDeviceNeighborsQuery(hexString){ log("parseDeviceNeighborsQuery", "Response received", 1) def parsed = [:] int i = 0 int offset = 10 myString = mark("Device Neighbors Updated - Refresh Browser!") sendEvent(name: "Status", value: myString) //Check the hexStatus and get result from table. hexStatus = hexString.substring(2, 4) def errorText = ZIGBEE_ERROR_MAP[hexStatus] if ( "0x${hexStatus}" == "0x84" ) { sendEvent(name: "deviceNeighbors", value: markred("Error: $errorText") ) log ("parseDeviceRoutingQuery","This device does not support Neighbor requests: $errorText", 1) return } //If we get anything other than 00 it is an error. if ( "0x${hexStatus}" != "0x00" ) { log("parseDeviceRoutingQuery","An error was encountered retrieving the Neighbor table. $errorText", 0) return } parsed.status = Integer.parseInt(hexString.substring(2, 4), 16) parsed.totalEntries = Integer.parseInt(hexString.substring(4, 6), 16) parsed.startIndex = Integer.parseInt(hexString.substring(6, 8), 16) parsed.listCount = Integer.parseInt(hexString.substring(8, 10), 16) state.device.controlN.totalEntries = parsed.totalEntries for (i = 0; i < parsed.listCount; i++) { def entry = [:] myPanId = reverseByteOrder(hexString.substring(offset, offset+16)) ;offset += 16 myExtAddr= reverseByteOrder(hexString.substring(offset, offset+16)) ;offset += 16 entry.netAddr = reverseByteOrder(hexString.substring(offset, offset+4)) ;offset += 4 //myInfo is an Octet with multiple bit fields myInfo = hexString.substring(offset, offset+2) ; offset += 2 myResult = decodeNeighborInfo( myInfo ) entry.relationship = myResult.relationship //Only include this extended information if explicity selected if (deviceExtendedInfo == "1"){ entry.extPanId = myPanId entry.extAddr = myExtAddr entry.deviceType = myResult.deviceType entry.rxOnWhenIdle = myResult.rxOnWhenIdle } //We ignore 'Permit Joining and Reserved' and they are not useful. offset += 2 //We ignore tree depth as it has no meaning in a mesh. offset += 2 entry.lqi = Integer.parseInt(hexString.substring(offset, offset+2),16) ; offset += 2 //If Device Names are configured and enabled then we will use those. deviceName = getDeviceName(entry.netAddr) entry.netAddr = deviceName log("parseDeviceNeighborsQuery", "entry is: $entry", 2) state.device.neighbors."${state.device.controlN.startIndex}" = entry state.device.controlN.startIndex = state.device.controlN.startIndex + 1 } log("parseDeviceNeighborsQuery", "startIndex: $state.device.controlN.startIndex - Entries: $parsed.totalEntries - ListCount: $parsed.listCount", 2) //If the ListCount is greater than 0 then there is more information to get. if (parsed.listCount.toInteger() != 0 ){ //def hex_string = Integer.parseInt(state.device.controlN.startIndex, 16).padLeft(2, '0') def hex_Index = String.format("%02X", state.device.controlN.startIndex) log("parseDeviceNeighborsQuery","Requesting data at startIndex $hex_Index",1) getNeighborsData(hex_Index) } else { //Now refresh the attributes log("parseDeviceNeighborsQuery","Completed receiving data.",1) updateDeviceAttributes("neighbors") } } //Decodes the interesting bit fields for a Neighbor request def decodeNeighborInfo(hexValue) { log("decodeNeighborInfo", "Decoding Neighbor Extended Info: $hexValue", 2) def data = Integer.parseInt(hexValue, 16) //Pad the binaryValue to make sure it has at least eight places in its string form. def binaryValue = "00000000" + Integer.toBinaryString(Integer.parseInt(hexValue, 16)) def result = [:] deviceType = binaryValue[-2..-1] // Decode Device Type (bits 1 & 2) switch (deviceType) { case "00": result.deviceType = "Coordinator"; result.deviceTypeIcon = "🕸️"; break case "01": result.deviceType = "Router"; result.deviceTypeIcon = "🔀"; break case "10": result.deviceType = "End Device"; result.deviceTypeIcon = "📍"; break case "11": result.deviceType = "Unknown"; result.deviceTypeIcon = "❓"; break } // Decode RxOnWhenIdle (bits 3 and 4) def rxOnWhenIdle = binaryValue[-4..-3] switch (rxOnWhenIdle) { case "00": result.rxOnWhenIdle = "Off"; result.rxOnWhenIdleIcon = "🎧"; break case "01": result.rxOnWhenIdle = "On"; result.rxOnWhenIdleIcon = "👂🏼"; break case "10": result.rxOnWhenIdle = "Unknown"; result.rxOnWhenIdleIcon = "❓"; break } // Decode Relationship (bits 5 - 7) def relationship = binaryValue[-7..-5] switch (relationship) { case "000": result.relationship = "Parent"; result.relationshipIcon = "🚹"; break case "001": result.relationship = "Child"; result.relationshipIcon = "🚼"; break case "010": result.relationship = "Sibling"; result.relationshipIcon = "👯"; break case "011": result.relationship = "None of the above"; result.relationshipIcon = "❓"; break case "100": result.relationship = "Previous child"; result.relationshipIcon = "❓"; break } if (result.deviceType == "End Device" && result.rxOnWhenIdle == "Off" ) { result.deviceType = "Sleepy End Device" ; result.deviceTypeIcon = "💤" } return result } //********************************************************************************************************************************************************************* //****** //****** End Device Related Commands - Neighbors //****** //********************************************************************************************************************************************************************* //********************************************************************************************************************************************************************* //****** //****** Start Device Related Commands - Routing //****** //********************************************************************************************************************************************************************* //Initiates a refresh of the Zigbee Routing data. def getRoutes(){ log("getRoutes", "Refreshing Device Routes", 1) //Clear the old routing info state.device.routes = [:] state.device.controlR = [:] state.device.controlR.startIndex = 0 getDeviceRoutingData("00") } //This function places calls to the device for routing information at a given startIndex. def getDeviceRoutingData(startIndex){ cmd = "he raw $device.deviceNetworkId 0x${device.endpointId} 0x00 0x0032 { 01 $startIndex$device.deviceNetworkId } { 0000 }" hubitat.device.HubAction hubAction = new hubitat.device.HubAction(cmd, hubitat.device.Protocol.ZIGBEE) sendHubCommand(hubAction) } //This function processes the Zigbee response and parses out the data. def parseDeviceRoutingQuery(hexString){ log("parseDeviceRoutingQuery", "Response received", 1) def parsed = [:] def routingTable = [] int i = 0 int offset = 10 myString = mark("Device Routing Updated - Refresh Browser!") sendEvent(name: "Status", value: myString) //Check the hexStatus and get result from table. hexStatus = hexString.substring(2, 4) def errorText = ZIGBEE_ERROR_MAP[hexStatus] if ( "0x${hexStatus}" == "0x84" ) { sendEvent(name: "deviceRoutes", value: markred("Error: $errorText") ) log ("parseDeviceRoutingQuery","This device does not support Routing requests: $errorText", 1) return } //If we get anything other than 00 it is an error. if ( "0x${hexStatus}" != "0x00" ) { log("parseDeviceRoutingQuery","An error was encountered retrieving the routing table. $errorText", 0) return } //If we get this far we should have properly formed data. parsed.status = Integer.parseInt(hexString.substring(2, 4), 16) parsed.startIndex = Integer.parseInt(hexString.substring(4, 6), 16) parsed.totalEntries = Integer.parseInt(hexString.substring(6, 8), 16) state.device.controlR.totalEntries = parsed.totalEntries parsed.listCount = Integer.parseInt(hexString.substring(8, 10), 16) //parsed.unknown = Integer.parseInt(hexString.substring(8, 10), 16) log("parseDeviceRoutingQuery", "startIndex: $parsed.startIndex - Status: $parsed.status - Entries: $parsed.totalEntries - Index: $parsed.startIndex - ListCount: $parsed.listCount", 2) for (i = 0; i < parsed.listCount; i++) { def entry = [:] entry.destAddr = reverseByteOrder(hexString.substring(offset, offset+4)) ; offset += 4 myExtendedInfo = hexString.substring(offset, offset+2) ; offset += 2 result = decodeDeviceRoutingInfo( myExtendedInfo ) entry.routeStatus = result.routeStatus //Only include this extended information if explicity selected if (deviceExtendedInfo == "1"){ entry.memoryConstrained = result.memoryConstrained entry.manyToOne = result.manyToOne entry.routeRecordRequired = result.routeRecordRequired } entry.nextHop = reverseByteOrder(hexString.substring(offset, offset+4)) ; offset += 4 //If Device Names are configured and enabled then we will use those. entry.destAddr = getDeviceName(entry.destAddr) entry.nextHop = getDeviceName(entry.nextHop) log("parseDeviceRoutingQuery", "entry is: $entry with startIndex: $state.device.controlR.startIndex", 2) state.device.routes."${state.device.controlR.startIndex}" = entry state.device.controlR.startIndex = state.device.controlR.startIndex + 1 } log("parseDeviceRoutingQuery", "startIndex: $state.device.controlR.startIndex - Entries: $parsed.totalEntries - ListCount: $parsed.listCount", 2) //If the ListCount is greater than 0 then there is more information to get. if (parsed.listCount.toInteger() != 0 ){ def hex_Index = String.format("%02X", state.device.controlR.startIndex) log("parseDeviceRoutingQuery","Requesting data at startIndex $hex_Index",1) getDeviceRoutingData(hex_Index) } else { //Now refresh the attributes log("parseDeviceRoutingQuery","Completed receiving data.",1) updateDeviceAttributes("routing") } } //Decodes the interesting bit fields for a Neighbor request def decodeDeviceRoutingInfo(hexValue) { def binaryValue = Integer.toBinaryString(Integer.parseInt(hexValue, 16)) //Pad the binaryValue to make sure it has at least eight places in its string form. if (binaryValue.length() < 8) { binaryValue = '0' * (8 - binaryValue.length()) + binaryValue } def result = [:] routeStatus = binaryValue[-3..-1] // Route Status (bits 1 - 3) switch (routeStatus) { case "000":result.routeStatus = "Active"; break //⚡ case "001": result.routeStatus = "Discovery Underway"; break //🔍 case "010": result.routeStatus = "Discovery Failed"; break //🚫 case "011": result.routeStatus = "Inactive"; break //💤 case "100": result.routeStatus = "Validation Underway"; break //📝 case "101" | "110" | "111": result.routeStatus = "Reserved"; break //❓ } // Decode memoryConstrained (bit 4) def memoryConstrained = binaryValue[-4] switch (memoryConstrained) { case "0": result.memoryConstrained = "False"; break case "1": result.memoryConstrained = "True"; break } // Decode manyToOne (bit 5) def manyToOne = binaryValue[-5] switch (manyToOne) { case "0": result.manyToOne = "False"; break case "1": result.manyToOne = "True"; break } // Decode Route Record Required (bit 6) def routeRecordRequired = binaryValue[-6] switch (routeRecordRequired) { case "0": result.routeRecordRequired = "False"; break case "1": result.routeRecordRequired = "True"; break } return result } //********************************************************************************************************************************************************************* //****** //****** End Device Related Commands - Routing //****** //********************************************************************************************************************************************************************* //********************************************************************************************************************************************************************* //****** //****** Start Parse //****** //********************************************************************************************************************************************************************* // Parsing incoming messages def parse(String description) { log("parse", "Message received.", 1) //Update state to indicate the last time a Zigbee message was received. This is used in conjunction with checking online status. def date = new Date() state.data = [deviceLastZigbeeActivityms: now(), deviceLastZigbeeActivity: date.format("E @ HH:mm:ss")] //If the Repeater was marked as offline then change it to online. if ( device.currentValue('healthStatus') == "offline" ) sendEvent(name: "healthStatus", value: "online") //Get the basic components of the Zigbee data. def map = zigbee.parseDescriptionAsMap(description) log("parse", "Zigbee Data is: ${map}", 2) def cluster = map.cluster def hexValue = map.value def attrId = map.attrId if (map?.clusterInt == 0x0000) { // Basic Cluster Response switch (map.attrInt) { case 0x0000: updateDataValue ("ZCLVersion", map?.value) break case 0x0001: updateDataValue ("application", map?.value) break case 0x0002: updateDataValue ("stackVersion", map?.value) break case 0x0003: updateDataValue ("hardwareVersion", map?.value) break case 0x0004: updateDataValue ("manufacturer", map?.value) break case 0x0005: updateDataValue ("model", map?.value) break } log("parse", "Received Response to Basic query", 1) } if (map?.clusterInt == 0x8031) { // ZDO Management LQI Response def hexString = map.data.join("") log("parse", "Received Response to Neighbor query", 1) log("parse", "Map Data is: ${map.data}", 2) parseDeviceNeighborsQuery(hexString) } if (map?.clusterInt == 0x8032) { // ZDO Management Routing Response def hexString = map.data.join("") log("parse", "Received Response to Routing query", 1) log("parse", "Map Data is: ${map.data}", 2) parseDeviceRoutingQuery(hexString) } if (map?.clusterInt == 0x0006) { // Switch Response if (map.attrInt == 0) { //0 Indicates the status as normal log("parse", "Received Response to Switch Action", 1) if (map.data != null) log("parse", "Map Data is: ${map.data}", 2) rawValue = Integer.parseInt(map.value, 16) String switchValue switchValue = (rawValue == 0) ? "off" : "on" sendEvent(name: "switch", value: switchValue, descriptionText: "${device.displayName} switch is ${switchValue}") } } } //********************************************************************************************************************************************************************* //****** //****** End Parse //****** //********************************************************************************************************************************************************************* //Takes the information saved to state and makes it available in Attributes according to the rules. void updateDeviceAttributes(section){ def myText = "" def myMap def separator = dataSeparatorMap[settings.dataSeparator.toInteger()] def date = new Date() def formattedDate = date.format("E @ HH:mm:ss") //Update the attribute to reflect a successful update. sendEvent(name: "deviceLastUpdate", value:formattedDate) if (section == "neighbors"){ //************** Start of Neighbors ********************** if (neighborSortOrder == "0"){ myMap = state.device.neighbors.entrySet().sort { a, b -> b.value.lqi <=> a.value.lqi } } if (neighborSortOrder == "1"){ myMap = state.device.neighbors.entrySet().sort { a, b -> a.value.lqi <=> b.value.lqi } } if (neighborSortOrder == "2"){ myMap = state.device.neighbors.entrySet().sort { a, b -> a.value.netAddr <=> b.value.netAddr } } processedNeighbors = [:] // Initialize an empty map to track processed neighbors // Loop through the myMap which has been sorted and generate text output for the attribute. log("updateDeviceAttributes:neighbors", "myMap is: $myMap", 2) myMap.each { map -> // Construct a unique key for each neighbor based on the sorting criteria def neighborKey if (neighborSortOrder != "2") { neighborKey = "${map.value.lqi}-${map.value.netAddr}" } if (neighborSortOrder == "2") { neighborKey = "${map.value.netAddr}-${map.value.lqi}" } // Check if the neighbor with the same key has already been processed if (!processedNeighbors.containsKey(neighborKey)) { // Add the neighbor to the processedNeighbors map to mark it as processed processedNeighbors[neighborKey] = true // Show LQI first and then device name if (neighborSortOrder != "2") { myText = myText + "${map.value.lqi}" + " - " + "${map.value.netAddr}" + separator } // Show device name first and then LQI if (neighborSortOrder == "2") { myText = myText + "${map.value.netAddr}" + " - " + "${map.value.lqi}" + separator } } } // Remove the trailing separator if (myText.endsWith(separator)) { myText = myText[0..-(separator.length() + 1)] } log("updateDeviceAttributes:neighbors", "deviceNeighbors is: $myText", 1) if (myText != "") sendEvent(name: "deviceNeighbors", value:myText) else sendEvent(name: "deviceNeighbors", value:"None") sendEvent(name: "deviceNeighborCount", value:processedNeighbors.size()) //************** End of Neighbors ********************** //************** Start of Children ********************** // Filter the neighbors based on the 'relationship' being 'Child' //Loop through the myMap which has been sorted and generate text output for the attribute. def children = state.device.neighbors.findAll { entry -> entry.value instanceof Map && entry.value.relationship == 'Child' } //Sort the results. if (neighborSortOrder == "0"){ myMap = children.entrySet().sort { a, b -> b.value.lqi <=> a.value.lqi } } if (neighborSortOrder == "1"){ myMap = children.entrySet().sort { a, b -> a.value.lqi <=> b.value.lqi } } if (neighborSortOrder == "2"){ myMap = children.entrySet().sort { a, b -> a.value.netAddr <=> b.value.netAddr } } myText = "" processedChildren = [] // Initialize an empty list to track processed children //Eliminate any duplicates from the list myMap.each { child -> if (!processedChildren.contains(child.value.netAddr)) { // Show LQI first and then device name if (neighborSortOrder != "2") { myText = myText + "${child.value.lqi}" + " - " + "${child.value.netAddr}" + separator } // Show device name first and then LQI if (neighborSortOrder == "2") { myText = myText + "${child.value.netAddr}" + " - " + "${child.value.lqi}" + separator } // Add the processed child to the list processedChildren.add(child.value.netAddr) } } if (myText.endsWith(separator)) { myText = myText[0..-(separator.length() + 1)] } log("updateDeviceAttributes:neighbors", "deviceChildren is: $myText", 1) if (myText != "") sendEvent(name: "deviceChildren", value:myText) else sendEvent(name: "deviceChildren", value:"None") sendEvent(name: "deviceChildCount", value:children.size()) //************** End of Children ********************** //************** Start of Repeaters (Siblings & Parent) ********************** // Filter the neighbors based on the 'relationship' NOT being 'Child'. This captures both Siblings and Parent. //Loop through the myMap which has been sorted and generate text output for the attribute. def repeaters = state.device.neighbors.findAll { entry -> entry.value instanceof Map && entry.value.relationship != 'Child' } //Sort the results. if (neighborSortOrder == "0"){ myMap = repeaters.entrySet().sort { a, b -> b.value.lqi <=> a.value.lqi } } if (neighborSortOrder == "1"){ myMap = repeaters.entrySet().sort { a, b -> a.value.lqi <=> b.value.lqi } } if (neighborSortOrder == "2"){ myMap = repeaters.entrySet().sort { a, b -> a.value.netAddr <=> b.value.netAddr } } myText = "" processedRepeaters = [] // Initialize an empty list to track processed repeaters //Eliminate any duplicates from the list myMap.each { repeater -> isParent = false if (!processedRepeaters.contains(repeater.value.netAddr)) { //Identify if it is the Parent in which case we will append an *. if (repeater.value.relationship == "Parent") isParent = true // Show LQI first and then device name if (neighborSortOrder != "2") { if (isParent == true ) myText = myText + "${repeater.value.lqi}" + " - " + "${repeater.value.netAddr}*" + separator else myText = myText + "${repeater.value.lqi}" + " - " + "${repeater.value.netAddr}" + separator } // Show device name first and then LQI if (neighborSortOrder == "2") { if (isParent == true ) myText = myText + "${repeater.value.netAddr}*" + " - " + "${repeater.value.lqi}" + separator else myText = myText + "${repeater.value.netAddr}" + " - " + "${repeater.value.lqi}" + separator } // Add the processed repeaters to the list processedRepeaters.add(repeater.value.netAddr) } } if (myText.endsWith(separator)) { myText = myText[0..-(separator.length() + 1)] } log("updateDeviceAttributes:neighbors", "deviceRepeaters is: $myText", 1) if (myText != "") sendEvent(name: "deviceRepeaters", value:myText) else sendEvent(name: "deviceRepeaters", value:"None") sendEvent(name: "deviceRepeatersCount", value:processedRepeaters.size()) //************** End of Repeaters (Siblings & Parent) ********************** //************** Start of Parent ********************** // Initialize variables to store the Parent lqi and netAddr def parentLqi = null def parentNetAddr = null myText = "None" // Iterate through the 'neighbors' map to find the entry with 'relationship' = 'Parent' state.device.neighbors.each { index, entry -> if (entry.relationship == 'Parent') { parentLqi = entry.lqi; parentNetAddr = entry.netAddr } } // Show LQI first and then device name if (neighborSortOrder != "2" && parentNetAddr != null) { myText = "${parentLqi}" + " - " + "${parentNetAddr}" } // Show device name first and then LQI if (neighborSortOrder == "2" && parentNetAddr != null) { myText = "${parentNetAddr}" + " - " + "${parentLqi}" } log("updateDeviceAttributes:neighbors", "Parent is: $myText", 1) if (myText != "") sendEvent(name: "deviceParent", value:myText) else sendEvent(name: "deviceParent", value:"None") //************** End of Parent ********************** } //End of Neighbors //************** Start of Routing ********************** if (section == "routing"){ myText = "" routeCount = 0 if (routeSortOrder == "0") { myMap = state.device.routes.entrySet().sort { a, b -> a.value.destAddr <=> b.value.destAddr } } if (routeSortOrder == "1") { myMap = state.device.routes.entrySet().sort { a, b -> b.value.nextHop <=> a.value.nextHop } } // Loop through the myMap which has been sorted and generate text output for the attribute. log("updateDeviceAttributes:routing", "myMap is: $myMap", 2) myMap.each { map -> // Show only active routes if ((deviceShowRoutes == "0") && (map.value.routeStatus == "Active")) { routeCount = routeCount + 1 if (routeSortOrder == "0") myText = myText + "${map.value.nextHop}" + " ➡ " + "${map.value.destAddr}" + separator if (routeSortOrder == "1") myText = myText + "${map.value.destAddr}" + " via " + "${map.value.nextHop}" + separator } // Show All Routes if (deviceShowRoutes == "1") { routeCount = routeCount + 1 if (routeSortOrder == "0") myText = myText + "${map.value.nextHop}" + " ➡ " + "${map.value.destAddr}(${map.value.routeStatus})" + separator if (routeSortOrder == "1") myText = myText + "${map.value.destAddr}" + " via " + "${map.value.nextHop} (${map.value.routeStatus})" + separator } } // Remove the trailing separator if (myText.endsWith(separator)) { myText = myText[0..-(separator.length() + 1)] } log("updateDeviceAttributes:routing", "deviceRoutes is: $myText", 1) if (myText != "") sendEvent(name: "deviceRoutes", value:myText) else sendEvent(name: "deviceRoutes", value:"None") sendEvent(name: "deviceRouteCount", value:routeCount) } //************** End of Routing ********************** } //********************************************************************************************************************************************************************* //****** //****** Start of Utility Functions //****** //********************************************************************************************************************************************************************* //Reverses the Byte order of a string. Zigbee.swapOctets() only handles 2 octets String reverseByteOrder(String input) { if (input.length() % 2 != 0) { throw new IllegalArgumentException("Input string should have an even number of characters.") } def output = [] for (int i = input.length() - 2; i >= 0; i -= 2) { output << input[i..i+1] } return output.join('') } //Compare two firmware versions by comparing each part of the version number def compareVersions(version1, version2) { def v1 = version1.tokenize('.').collect { it as Integer } def v2 = version2.tokenize('.').collect { it as Integer } // Compare each level of the version numbers for (int i = 0; i < Math.min(v1.size(), v2.size()); i++) { def compareResult = v1[i] <=> v2[i] if (compareResult != 0) { return compareResult } } // If all levels are equal, compare the length of version numbers return v1.size() <=> v2.size() } //Log status messages private log(name, message, int loglevel){ int threshold = 0 if ( settings.loglevel == "1" ) threshold = 1 if ( settings.loglevel == "2" ) threshold = 2 //This is a quick way to filter out messages based on loglevel if ( loglevel > threshold) {return} if ( loglevel == 0 ) { log.info ( "${name}(): " + message ) } if ( loglevel == 1 ) { log.trace ( "${name}(): " + message ) } if ( loglevel == 2) { log.debug ( "${name}(): " + message ) } } //Functions to enhance text appearance String bold(s) { return "$s" } String italic(s) { return "$s" } String underline(s) { return "$s" } String dodgerBlue(s) { return '' + s + ''} String red(s) { return '' + s + ''} String green(s) { return '' + s + ''} String mark(s) { return '' + s + ''} String markred(s) { return '' + red(s) + "" }