We begin by assuming some familiarity with the Hubitat Elevation development environment and the Groovy programming language that it uses, though expert proficiency is by no means required. Read the Developer Overview if you have not already.
Familiarity with the Zigbee protocol, including the Zigbee Home Automation (ZHA) 1.2 or Zigbee 3.0 profiles, is necessary to write Zigbee drivers. Specifications are available from the Connectivity Standards Alliance, previously named the Zigbee Alliance. Nevertheless, we will cover the basics here, which should be enough to get you started. Users who are already familiar with Zigbee may skip this section.
Zigbee groups related functionality into clusters, which contain related commands and attributes. Each cluster is assigned a numeric identifier (traditionally written in hex). Examples of common clusters and their IDs include:
0x0006
On/Off0x0008
Level Control0x0402
Temperature MeasurementClusters will differ by device, generally depending on real-world functionality. For example, a smart plug or on/off switch will typically implement On/Off (0x0006
), among others. Zigbee devices may also offer multiple endpoints, and each endpoint may utilize different clusters; in this example, we'll assume a device with only a single endpoint (ignoring the special endpoint 0).
Clusters may include commands or attributes. Commands are generally actions that can be performed on a device. These are also assigned numeric identifiers and conventionally written in hex. For example, the 0x0006
On/Off cluster requires at least the following commands:
0x00
Off0x01
On0x02
ToggleAttributes are generally states or properties that can be read. Continuing our examination of the 0x0006
On/Off cluster, we see that it offers at least the following attribute:
0x0000
On/OffThis can be read to determine the current on/off state of the switch. The driver can also "bind" the hub to this attribute or use reporting configuration so changes are sent back to the hub on their own, if supported by the device. For switch, dimmer, bulb, etc. drivers, this is a common technique.
NOTE: The above describes standard use of clusters, commands, and attributes. We have also listed only the "mandatory" commands and attributes required by the specification; several clusters, including the one examined above, offer additional "optional" commands and attributes (though they are not relevant for our use case).
Further, manufacturers are allowed to use manufacturer specific clusters or add manufacturer specific commands or attributes to standard clusters. Some may not even follow the standard at all, though certified ZHA 1.2 and Zigbee 3.0 devices should. Manufacturer documentation or other means of determining device behavior may be necessary in these cases.
Before writing your driver, you will need to:
For the purposes of this simple driver, we'll assume we are working with a Zigbee smart plug/outlet that supports the following cluster (and standard commands and attributes, as described above):
0x0006
On/OffWe might begin with a basic driver template, similar to that found in Driver Overview. However, one important difference between virtual drivers like that example and Zigbee drivers (and Z-Wave drivers and some LAN drivers) is that data can be sent to the hub from the device. For Zigbee, this is often from attributes we sent a "read" to or have bound to and were changed (in this example, likely a change in on/off state). The parse()
method is where raw data from the device comes into drivers, so we will include one here and explain what to do with it later.
metadata {
definition (name: "My Zigbee Switch", namespace: "MyNamespace", author: "My Name") {
capability "Actuator"
capability "Switch"
}
preferences {
// None for now -- but for Zigbee devices that offer attributes that
// can be written to set preferences, they are often included here.
// Later, we will add conventional Hubitat logging preferences here.
}
}
def installed() {
log.debug "installed()"
}
def updated() {
log.debug "updated()"
}
def parse(String description) {
// This is where incoming data from the device will be sent.
// For now, just log the raw data (we will discuss ways to handle
// this later):
log.debug "parse: $description"
}
def on() {
// TO DO: Required command from Switch capability, logging for now:
log.warn "on() not yet implemented"
}
def off() {
// TO DO: Required command from Switch capability, logging for now:
log.warn "off() not yet implemented"
}
As you can see, we have written stubs for a few methods that will need to be properly written in order to build a fully functional driver — but as-is, the driver is syntactically correct (so can be saved) and will log when commands are sent or information is received from the device. To complete the driver, we will need to:
parse()
on()
and off()
commands to perform the appropriate actions, i.e., turning the switch on or off by sending Zigbee commandsThe parse()
method includes a single String
parameter containing the "raw" Zigbee data. The Hubitat environment provides a method, parseDescriptionAsMap()
, that will parse most such data into key/value pairs for you, including data such as the cluster ID and attribute ID. Typically, you will then check the cluster ID and perform appropriate actions based on the attribute and other values. We demonstrate a possible implementation for our device below:
def parse(String description) {
// Can be helpful for debugging; for now we'll just always log:
log.debug "parse description: ${description}"
def descMap = zigbee.parseDescriptionAsMap(description)
// Parses hex (base 16) string data to Integer -- perhaps easier to work with:
def rawValue
switch (descMap.clusterInt) {
case 0x0006: // On/Off
if (descMap.attrInt == 0) {
rawValue = Integer.parseInt(descMap.value, 16)
String switchValue
// attribute value of 0 means off, 1 (only other valid value) means on
switchValue = (rawValue == 0) ? "off" : "on"
// for now, always log -- normally we would offer a preference:
log.info "${device.displayName} switch is ${switchValue}"
// this is what actually generates the event:
sendEvent(name: "switch", value: switchValue, descriptionText: "${device.displayName} switch is ${switchValue}")
}
else {
// some atribute besides 0, which we don't care about but will log
// for now -- could also leave out if you know it's not needed:
log.debug "0x0006:${descMap.attrId}"
}
// In other drivers, you may have other cases here
// For example, case 0x0008 for level, etc.
default:
// Probably not needed in most drivers but might be helpful for
// debugging -- always logging for now:
log.debug "ignoring {descMap.clusterId}:${descMap.attrId}"
break
}
}
Now, we need to make the on()
and off()
methods in our driver actually work – i.e., send an appropriate Zigbee command to the device. Hubitat has a built-in method to generate the Zigbee on and off commands in the required format, making our implementations potentially very simple:
def on() {
// returns a string (in Hubitat Zigbee command format) representing
// the On command for the On/Off cluster:
return zigbee.on()
}
and:
def off() {
// returns a string (in Hubitat Zigbee command format) representing
// the Off command for the On/Off cluster:
return zigbee.off()
}
NOTE: We are taking advantage here of the fact if the Groovy method corresponding to the Hubitat command returns a string or List of strings containing properly formatted Zigbee commands (such as the ones generated by the built-in Zigbee object methods above), that Zigbee command will be automatically sent upon return from the method. This is an easy way to send commands and is the method demonstrated here.
Some authors prefer to use
sendHubCommand()
instead, which requires aHubAction
object to be constructed and passed, along with other information. The method would then not need to return any value, as you are "manually" sending the command. This approach is generally more verbose, aruably more diffcult, and not demonstrated here. However, as one possible example:sendHubCommand(new hubitat.device.HubAction(zigbee.on(), hubitat.device.Protocol.ZIGBEE))
Tip: Recall that the return
keyword is optional in Groovy, and Groovy will by default return the value of the last statement in the method. Therefore, the above could be written more idiomatically as:
def on() {
zigbee.on()
}
The off()
method could be re-written similarly, except using zigbee.off()
.
In some cases, there will not be a built-in method to generate the Hubitat Zigbee command string you need. In most of these cases, you can use the zigbee.command()
method to construct a command yourself. For example, the return value of zigbee.on()
is equvalent to the following:
zigbee.command(0x0006, 0x01)
If needed, you can also construct a command string manually. The general format for commands is:
"he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x${clusterId} ${commandId} {}"
where clusterId
and commandId
are provided by you (the device
object and properties referenced above are available for drivers in use by Zigbee devices, and this is a typical way of retrieving these values for use in commands). The {}
at the end of the string above sometimes contains additional payload data, but is empty in this case; however, it is always required. Putting this all together, yet another way of writing the above Zigbee "on" command would be:
"he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0006 1 {}"
NOTE: You can also return a list of commands if needed. The delayBetween()
method is often used in conjunction with this approach. For example, to send the On command, wait a second, and then read the attribute to see if it changed (as might be necessary if the device is not configured to or cannot report this value back on its own; this also demonstrates a manually constructed "read attribute" command, although the zigbee
object again has methods to construct these for you):
return delayBetween([
zigbee.on(),
"he rattr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0006 0 {}")
], 1000)
With the above changes, our driver more or less works. However, unless we take the approach just mentioned of "manually" reading the attribute after sending a command, we won't get any data when the device changes state — i.e., if our "on" or "off" command were successful. For some devices, this might be necessary; for many devices, binding — briefly mentioned above — provides a solution. Let's assume ours is one of these, as most are likely to be.
A Zigbee binding command can be sent by constructing a string in a format similar to:
"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0006 {${device.zigbeeId}} {}"
The various parts are:
zdo bind
: identifies this command (Zigbee Device Object Bind)0x${device.deviceNetworkId}
: DNI (using this format will retrieve the correct value from the Hubitat device object for you)0x${device.endpointId}
: source (device) endpoint ID, often 1 (using this format will retrieve the value from the Hubitat device details data, or you can specify it via other means if needed)0x01
: destination (hub) endpoint ID0x0006
: cluster ID (here, 0x0006
for On/Off){${device.zigbeeId}}
: Contains Zigbee IEEE address of the device (using this format will retrieve the value from the Hubitat device object) inside curly braces{}
: payload data (empty curly braces in this example; still required if none)Commonly, this will be done in a configure()
method. If your driver uses capability "Configuration"
, this method will be called when the device is initially installed, so this is a great combination to use. (Users are also encouraged to run the "Configure" command when switching drivers for reasons including this; nothing happens on its own as a result of a driver change.)
In our driver, this might look like:
// ...
capability "Configuration"
// ...
def configure() {
// Using a List here even though we are only returning one command because in real
// drivers, you are likely to have multiple commands you could add to this List:
List<String> cmds = []
cmds.add "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0006 {${device.zigbeeId}} {}"
return cmds // or delayBetween(cmds)
}
In more complex drivers, you may also need to send "configure reporting" commands, and configure()
is also generally a good place to do so. You can use zigbee.configureReporting()
to generate these commands, or you can manually construct the strings, starting with "he cr"
, as demonstrated in several example drivers (below).
Using everything we've discussed so far, our driver might look like:
metadata {
definition (name: "My Zigbee Switch", namespace: "MyNamespace", author: "My Name") {
capability "Actuator"
capability "Configuration"
capability "Switch"
}
preferences {
// None for now -- but for Zigbee devices that offer attributes that
// can be written to set preferences, they are often included here.
// Later, we will add conventional Hubitat logging preferences here.
}
}
def installed() {
log.debug "installed()"
}
def updated() {
log.debug "updated()"
}
def parse(String description) {
// Can be helpful for debugging; for now we'll just always log:
log.debug "parse description: ${description}"
def descMap = zigbee.parseDescriptionAsMap(description)
def rawValue
switch (descMap.clusterInt) {
case 0x0006: // On/Off
if (descMap.attrInt == 0) {
// Parses hex (base 16) string data to Integer -- perhaps easier to work with:
rawValue = Integer.parseInt(descMap.value, 16)
String switchValue
// attribute value of 0 means off, 1 (only other valid value) means on
switchValue = (rawValue == 0) ? "off" : "on"
// for now, always log -- normally we would offer a preference:
log.info "${device.displayName} switch is ${switchValue}"
// this is what actually generates the event:
sendEvent(name: "switch", value: switchValue, descriptionText: "${device.displayName} switch is ${switchValue}")
}
else {
// some atribute besides 0, which we don't care about but will log
// for now -- could also leave out if you know it's not needed:
log.debug "0x0006:${descMap.attrId}"
}
// In other drivers, you may have other cases here
// For example, case 0x0008 for level, etc.
default:
// Probably not needed in most drivers but might be helpful for
// debugging -- always logging for now:
log.debug "ignoring {descMap.clusterId}:${descMap.attrId}"
break
}
}
def configure() {
List<String> cmds = []
cmds.add "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0006 {${device.zigbeeId}} {}"
return cmds // or delayBetween(cmds)
}
def on() {
zigbee.on()
}
def off() {
zigbee.off()
}
Built-in Hubitat drivers generally follow some conventions, and we may wish to make our driver do the same so as to meet most users' expectations. These include:
parse()
, possibly parts of how it is (and isn't) handled within parse()
, and often when commands like on()
are calledparse()
Making our driver follow these conventions, we may end up with something like:
metadata {
definition (name: "My Zigbee Switch", namespace: "MyNamespace", author: "My Name") {
capability "Actuator"
capability "Configuration"
capability "Switch"
}
preferences {
// For some Zigbee devices, you may want to offer additional options for reporting configuration or some
// manufacturer-specific options, etc. here
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
}
}
def installed() {
log.debug "installed()"
}
def updated() {
log.debug "updated()"
log.warn "debug logging is: ${logEnable == true}"
log.warn "description logging is: ${txtEnable == true}"
if (logEnable) runIn(1800, "logsOff") // 1800 seconds = 30 minutes
// In drivers that offer preferences for configuration, you might also iterate over
// these here and send Zigbee commands as needed here (or call configure() and do it there)
}
// handler method for scheduled job to disable debug logging:
def logsOff(){
log.warn "debug logging disabled..."
device.updateSetting("logEnable", [value:"false", type:"bool"])
}
def parse(String description) {
if (logEnable) log.debug "parse description: ${description}"
def descMap = zigbee.parseDescriptionAsMap(description)
// Parses hex (base 16) string data to Integer -- perhaps easier to work with:
def rawValue
switch (descMap.clusterInt) {
case 0x0006: // On/Off
if (descMap.attrInt == 0) {
rawValue = Integer.parseInt(descMap.value, 16)
String switchValue
// attribute value of 0 means off, 1 (only other valid value) means on
switchValue = (rawValue == 0) ? "off" : "on"
if (txtEnable) log.info "${device.displayName} switch is ${switchValue}"
// this is what actually generates the event:
sendEvent(name: "switch", value: switchValue, descriptionText: "${device.displayName} switch is ${switchValue}")
}
else {
if (logEnable) log.debug "0x0006:${descMap.attrId}"
}
// In other drivers, you may have other cases here
// For example, case 0x0008 for level, etc.
default:
if (logEnable) log.debug "ignoring {descMap.clusterId}:${descMap.attrId}"
break
}
}
def configure() {
if (logEnable) log.debug "configure()"
List<String> cmds = []
cmds.add "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0006 {${device.zigbeeId}} {}"
return cmds // or delayBetween(cmds)
}
def on() {
if (logEnable) log.debug "on()"
zigbee.on()
}
def off() {
if (logEnable) log.debug "off()"
zigbee.off()
}
Many drivers offer a refresh()
method (usually as part of capability "Refresh")
to fetch current states from the device. For devices that report changes back on their own, users should rarely need to call this command. However, it is often a good idea to include (and may find some internal use in your driver, e.g., to retrieve current states upon installation).
For a Zigbee device, implementation of this command will often include sending one or more "Zigbee read attributes" commands (the readings for which should eventually come into your parse()
method where this information can be handled appropriately). The example drivers, below, show some implementations of this command.
Congratulations! You have a fully functional, albeit basic, Zigbee switch or outlet driver.
For a more complicated example, see the published Zigbee RGBW bulb example: https://github.com/hubitat/HubitatPublic/blob/master/examples/drivers/GenericZigbeeRGBWBulb.groovy (or any of the other Zigbee drivers to see other kinds of devices).
You may wish to continue reading the remaining Developer Docs, particularly those for drivers and Common Methods (available to both apps and drivers). If you are also interested in Z-Wave drivers, see the analogous Building a Z-Wave Driver document.