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 Z-Wave protocol is necessary to write Z-Wave drivers. A full lesson is beyond the scope of this document; sources for learning more include the Silicon Labs specifications. But we will cover the basics here, which should be enough to get you started. Users who are already familiar with Z-Wave may skip this section.
Z-Wave communication is divided into command classes, which group together related functionality. Each command class is assigned a numeric identifier. Some command classes (and their numeric identifier, conventionally written in hexadecimal format) include:
0x20
Basic0x25
Switch Binary0x30
Sensor Binary0x32
Meter0x80
BatterySupported command classes will differ by device, depending on real-world functionality. For example, a smart plug or on/off switch will implement Switch Binary (0x25
), among others — including Meter (0x32
) if it supports power or energy monitoring. A battery-powered motion sensor is likely to implement Sensor Binary (0x30)
, Battery (0x80
), and others.
All Z-Wave devices implement Basic (
0x20
) to enable at least simple communication with other Z-Wave devices that may not be able to work directly with specific device capabilities. This usually represents the primary function of a device (e.g., the on/off state for a switch or the open/closed state of a contact sensor). This state is likely also reported or able to be commanded via a more specific command class, and the use of this more specific command class is generally recommended over Basic whenever possible.
For a list of Z-Wave command classes supported by Hubitat, see: Z-Wave Classes. Note that many command classes have multiple versions, generally adding new data or additional commands in newer versions. All versions are backwards compatible, so your driver may use the earliest version that supports all needed features (or the newest version supported by both the hub and device, or anything in between). It is recommended to specify in your driver what versions your driver targets for parsing (more details below).
To conserve power, battery powered Z-Wave devices are "sleeping" nodes, divided into two types: "frequently listening and routing" (FLiRS) and "reporting sleeping" (RSS). FLiRS devices generally include battery-powered devices like door locks, thermostats, or motors (e.g., window blinds) that need to respond to commands from the controller (hub) with low latency. RSS devices generally include sensor-only devices like motion, contact, temperature, etc. sensors. From a driver standpoint, RSS devices require special care (e.g., sending commands like a Configuration Set only when a wake-up notification is received from the device), whereas FLiRS devices can largely be dealt with in the same way as a regular powered (non-sleeping) device. We will deal only with a non-sleeping device in this example.
Z-Wave supports two versions of security, S0 and S2 (as well as none). All hub models support S0, while the C-7 and newer also support S2. The Hubitat driver environment provides a built-in method, zwaveSecureEncap()
, to automatically detect security and encapsulate commands as needed. We use this in our driver below. (This is not necessary for older devices that do not support security, though there is no harm in including it regardless.)
S2 devices will send many commands encapsulated in a Supervision Get message (for multi-channel devices, these will be further encapsulated in multi-channel). It is critical that you send a supervision response in reply to this or the device will assume the communication was a failure. We demonstrate this below. You may also optionally encapsulate outbound actuator commands (not demonstrated in this driver).
For more on security and supervision, see: https://community.hubitat.com/t/guide-writing-z-wave-drivers-for-s2/71237#supervision-get-handling-3
Before writing your driver, you will need to:
For the purposes of this simple driver, we'll assume we are working with a Z-Wave Plus in-wall on/off switch that supports the following command classes (and versions, indicated if more than one version is available):
0x20
Basic V20x70
Configuration V40x9F
Security S20x6C
Supervision0x25
Switch Binary V20x86
Version V3We 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 Z-Wave drivers (and Zigbee drivers and some LAN drivers) is that data can be sent to the hub from the device. For Z-Wave, this is most commonly a "report," in our case, information like the on/off state of the switch in a Switch Binary Report. 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 Z-Wave Switch", namespace: "MyNamespace", author: "My Name") {
capability "Actuator"
capability "Switch"
}
preferences {
// none for now -- but for Z-Wave devices, this would often
// include preferences to set configuration parameters in addition
// to conventional Hubitat logging preferences, etc.
}
}
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 via Z-WaveThe parse()
method includes a single String
parameter containing the "raw" Z-Wave data. The Hubitat environment provides a method, zwave.parse()
, to parse this data into an object, a hubitat.zwave.Command
subclass (see: Z-Wave Classes). Most commonly, you will pass the object returned by this method into one of several overloaded methods in your driver, conventionally called zwaveEvent()
, each of which handles a specific class. Here is one example:
def parse(String description) {
def cmd = zwave.parse(description)
zwaveEvent(cmd)
}
def zwaveEvent(hubitat.zwave.commands.basicv1.BasicReport cmd) {
if (enableDebug) log.debug "BasicReport: ${cmd}"
// TO DO: handle this case (or maybe not, as Basic is likely
// duplicated in another report)
}
def zwaveEvent(hubitat.zwave.commands.switchbinaryv1.SwitchBinaryReport cmd) {
log.debug "SwitchBinaryReport: ${cmd}"
// TO DO: handle this case
}
// Additional implementations of zwaveEvent() here for all cases
// we need to handle
// ...
// Catches anything not handled by the above; can be
// helpful for debugging:
def zwaveEvent(hubitat.zwave.Command cmd){
log.debug "not handling: ${cmd}"
}
To actually complete this driver, we will need to access properties on objects like the SwitchBinaryReport
to determine what information the report contains about the switch state (on or off) and generate an event if needed. Here is one possible implementation of such a method:
def zwaveEvent(hubitat.zwave.commands.switchbinaryv1.SwitchBinaryReport cmd) {
String value = (cmd.value ? "on" : "off")
// for now, always log -- normally we would offer a preference:
log.info "${device.displayName} switch is ${value}"
// this is what actually generates the event:
sendEvent(name: "switch", value: value)
}
Consult the Z-Wave Classes document for information on specific properties, methods, etc. available on different Z-Wave classes. The capability documentation has information on the attributes required by a certain capability, like the "switch"
attribute (event names correspond to attributes in a driver) above, as well as valid possible values for these attributes.
Recall that multiple versions of a Z-Wave command class may exist. Hubitat offers a separate class for each supported version. To ensure the best match between zwave.parse()
and your driver, it is recommended to specify which versions your driver handles. This is done with an optional second argument to zwave.parse()
. This parameter is a Map
containing a numeric key representing the command class and a numeric value representing the desired version (which should be equal to or less than the newest version supported by both the hub and your device).
A common approach is to define a field variable in the driver noting which versions of each command class it handles, then passing that variable as the second parameter in zwave.parse()
. For example:
import groovy.transform.Field
// ...
@Field static final Map commandClassVersions = [
0x20: 1, // Basic
0x25: 1, // SwitchBinary
0x6C: 1, // Supervision
0x70: 1, // Configuration
0x86: 2, // Version
0x9F: 1 // Security S2
]
// ...
def parse(String description) {
def cmd = zwave.parse(description, commandClassVersions)
// While we're making other improvements, consider the possibility that nothing was returned from
// zwave.parse(), e.g., an unsupported command class. Testing avoids throwing error:
if (cmd) {
zwaveEvent(cmd)
}
}
In our case, while the device supports Switch Binary v2, we are using v1 because it supports all features we need (but either approach would work).
As mentioned above, we will also need to handle incoming commands encapsulated with Supervision. This can be handled with yet another zwaveEvent()
implementation. For our driver, this method would work:
def zwaveEvent(hubitat.zwave.commands.supervisionv1.SupervisionGet cmd) {
hubitat.zwave.Command encapCmd = cmd.encapsulatedCommand(commandClassVersions) // we defined commandClassVersions above
if (encapCmd) {
zwaveEvent(encapCmd)
}
sendHubCommand(new hubitat.device.HubAction(zwaveSecureEncap(zwave.supervisionV1.supervisionReport(sessionID: cmd.sessionID, reserved: 0, moreStatusUpdates: false, status: 0xFF, duration: 0).format()), hubitat.device.Protocol.ZWAVE))
}
Now, we need to make the on()
and off()
methods in our driver actually work – i.e., send an appropriate Z-Wave command to the device. To do this, we will need to create an instance of the appropriate Hubitat Z-Wave class (represting the Z-Wave command class) with the appropriate properties set, call the format()
method on this object to convert it to a "raw" Z-Wave string, and then return that value from this method.
This is most commonly done in a manner similar to the following:
def on() {
def cmd = zwave.switchBinaryV1.switchBinarySet(switchValue: 0xFF)
def formattedCmd = cmd.format()
return formattedCmd
}
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 formatted Z-Wave commands, that command will be automatically sent upon return from the method. This is an easy way to send Z-Wave 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. This is actually how we handled Z-Wave supervision response above without fully explaining what we did, but we won't otherwise demonstrate it here in favor of the simpler approach we've otherwise used.
But remember the discussion about Z-Wave Security above? We know our device supports S2 because it supports command class 0x9F Security S2. Therefore, we should let the platform encapsulate this command if needed:
def on() {
def cmd = zwave.switchBinaryV1.switchBinarySet(switchValue: 0xFF)
def secCmd = zwaveSecureEncap(cmd.format()) // zwaveSecureEncap(cmd) would also work
return secCmd
}
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() {
def cmd = zwave.switchBinaryV1.switchBinarySet(switchValue: 0xFF)
zwaveSecureEncap(cmd.format())
}
The off()
method would be implemented similarly, except switchValue
is 0x00
in this case. The Z-Wave Classes documentation is, again, a resource you can consult to determine what the classes you need are and how they work (coupled with your knowledge of Z-Wave and, perhaps, the manufacturer's device documentation).
NOTE: You can also return a list of formatted commands if needed. The
delayBetween()
method is often used in conjunction with this approach. For example:
return delayBetween([
zwaveSecureEncap(zwave.switchMultilevelV1.switchMultilevelSet(value: 0x00)),
zwaveSecureEncap(zwave.basicV1.basicGet())
] ,200)
Using everything we've discussed so far, our driver might look like:
import groovy.transform.Field
@Field static final Map commandClassVersions = [
0x20: 1, // Basic
0x25: 1, // SwitchBinary
0x6C: 1, // Supervision
0x70: 1, // Configuration
0x86: 2, // Version
0x9F: 1 // Security S2
]
metadata {
definition (name: "My Z-Wave Switch", namespace: "MyNamespace", author: "My Name") {
capability "Actuator"
capability "Switch"
}
preferences {
// none for now -- but for Z-Wave devices, this would often
// include preferences to set configuration parameters in addition
// to conventional Hubitat logging preferences, etc.
}
}
def installed() {
log.debug "installed()"
}
def updated() {
log.debug "updated()"
}
def parse(String description) {
def cmd = zwave.parse(description, commandClassVersions)
if (cmd) {
zwaveEvent(cmd)
}
}
def zwaveEvent(hubitat.zwave.commands.basicv1.BasicReport cmd) {
if (enableDebug) log.debug "BasicReport: ${cmd}"
// Going to ignore this on our driver; let's say that for this device, it is
// also mapped to SwitchBinaryReport (as it often will be for switches/ouets)
}
def zwaveEvent(hubitat.zwave.commands.switchbinaryv1.SwitchBinaryReport cmd) {
String value = (cmd.value ? "on" : "off")
// for now, always log -- normally we would offer a preference:
log.info "${device.displayName} switch is ${value}"
sendEvent(name: "switch", value: value)
}
def zwaveEvent(hubitat.zwave.commands.supervisionv1.SupervisionGet cmd) {
hubitat.zwave.Command encapCmd = cmd.encapsulatedCommand(commandClassVersions)
if (encapCmd) {
zwaveEvent(encapCmd)
}
sendHubCommand(new hubitat.device.HubAction(zwaveSecureEncap(zwave.supervisionV1.supervisionReport(sessionID: cmd.sessionID, reserved: 0, moreStatusUpdates: false, status: 0xFF, duration: 0).format()), hubitat.device.Protocol.ZWAVE))
}
// Additional implementations of zwaveEvent() here for all cases
// we need to handle
// ...
// Catches anything not handled by the above; can be
// helpful for debugging:
def zwaveEvent(hubitat.zwave.Command cmd) {
// for now, always log -- should eventually offer a preference:
log.debug "not handling: ${cmd}"
}
def on() {
def cmd = zwave.switchBinaryV1.switchBinarySet(switchValue: 0xFF)
zwaveSecureEncap(cmd.format())
}
def off() {
def cmd = zwave.switchBinaryV1.switchBinarySet(switchValue: 0x00)
zwaveSecureEncap(cmd.format())
}
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()
to zwaveEvent()
and possibly when commands like on()
are calledzwaveEvent()
Making our driver follow these conventions, we may end up with something like:
import groovy.transform.Field
@Field static final Map commandClassVersions = [
0x20: 1, // Basic
0x25: 1, // SwitchBinary
0x6C: 1, // Supervision
0x70: 1, // Configuration
0x86: 2, // Version
0x9F: 1 // Security S2
]
metadata {
definition (name: "My Z-Wave Switch", namespace: "MyNamespace", author: "My Name") {
capability "Actuator"
capability "Switch"
}
preferences {
// Z-Wave devices will often include preferences for configuration parameters 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()"
// Sometimes you may wish to initialize attributes to default values here or
// call refresh() to fetch them (not implemented in this driver currently, but
// a reasonable command to implement for many devices)
}
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 parameters, you might also iterate over
// then and send ConfigurationSet commands as needed here (or in configure() if implemented)
}
// handler method for scheduled job to disable debug logging:
void 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 cmd = zwave.parse(description, commandClassVersions)
if (cmd) {
zwaveEvent(cmd)
}
}
def zwaveEvent(hubitat.zwave.commands.basicv1.BasicReport cmd) {
if (enableDebug) log.debug "BasicReport: ${cmd}"
// Going to ignore this on our driver; our device maps Basic to SwitchBinary
}
def zwaveEvent(hubitat.zwave.commands.switchbinaryv1.SwitchBinaryReport cmd) {
String value = (cmd.value ? "on" : "off")
if (txtEnable) log.info "${device.displayName} switch is ${value}"
sendEvent(name: "switch", value: value)
}
def zwaveEvent(hubitat.zwave.commands.supervisionv1.SupervisionGet cmd) {
if (logEnable) log.debug "SupervisionGet - SessionID: ${cmd.sessionID}, CC: ${cmd.commandClassIdentifier}, Command: ${cmd.commandIdentifier}"
hubitat.zwave.Command encapCmd = cmd.encapsulatedCommand(commandClassVersions)
if (encapCmd) {
zwaveEvent(encapCmd)
}
sendHubCommand(new hubitat.device.HubAction(zwaveSecureEncap(zwave.supervisionV1.supervisionReport(sessionID: cmd.sessionID, reserved: 0, moreStatusUpdates: false, status: 0xFF, duration: 0).format()), hubitat.device.Protocol.ZWAVE))
}
// Possible additional zwaveEvent() methods here
// ...
def zwaveEvent(hubitat.zwave.Command cmd) {
// Just noting that the data was parsed into something we aren't handling in this driver:
if (logEnable) log.debug "skip: ${cmd}"
}
def on() {
if (logEnable) log.debug "on()"
def cmd = zwave.switchBinaryV1.switchBinarySet(switchValue: 0xFF)
zwaveSecureEncap(cmd.format())
}
def off() {
if (logEnable) log.debug "off()"
def cmd = zwave.switchBinaryV1.switchBinarySet(switchValue: 0x00)
zwaveSecureEncap(cmd.format())
}
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 (some "classic" Z-Wave switches infamously may not for physical changes), 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 Z-Wave device, implementation of this command will often include sending one or more Z-Wave "get" commands (the reports for which should eventually come into your parse()
method where this information can be handled appropriately). The example drivers, below, shows one possible implementation of this command.
Congratulations! You have a fully functional Z-Wave switch driver.
For a more complicated example, see the published Z-Wave dimmer driver example: https://github.com/hubitat/HubitatPublic/blob/master/examples/drivers/genericZWaveCentralSceneDimmer.groovy (or other Z-Wave drivers in this repository).
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 Zigbee drivers, see the analogous Building a Zigbee Driver document.