-
-
Save jtp10181/9f5d72019bcd15f9453960eab1de7d59 to your computer and use it in GitHub Desktop.
Hubitat Generic Z-Wave Tilt/Contact Sensor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Generic Z-Wave Tilt/Contact Sensor | |
* | |
* For Support, Information and Updates: | |
* https://github.com/jtp10181/Hubitat/tree/main/Drivers/ | |
* | |
Changelog: | |
## [0.1.0] - 2023-02-04 (@jtp10181) | |
### Added | |
- Initial Release | |
* Copyright 2023 Jeff Page | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
* | |
*/ | |
import groovy.transform.Field | |
@Field static final String VERSION = "0.1.0" | |
metadata { | |
definition ( | |
name: "Generic Z-Wave Tilt/Contact Sensor", | |
namespace: "jtp10181", | |
author: "Jeff Page (@jtp10181)", | |
importUrl: "https://raw.githubusercontent.com/jtp10181/Hubitat/main/Drivers/generic/zwave-tilt-contact-sensor.groovy" | |
) { | |
capability "Sensor" | |
capability "ContactSensor" | |
capability "Battery" | |
capability "TamperAlert" | |
command "fullConfigure" | |
command "forceRefresh" | |
attribute "external", "string" | |
attribute "syncStatus", "string" | |
} | |
preferences { | |
command "setParameter",[[name:"parameterNumber*",type:"NUMBER", description:"Parameter Number", constraints:["NUMBER"]], | |
[name:"value*",type:"NUMBER", description:"Parameter Value", constraints:["NUMBER"]], | |
[name:"size",type:"NUMBER", description:"Parameter Size", constraints:["NUMBER"]]] | |
//Logging options similar to other Hubitat drivers | |
input "txtEnable", "bool", title: fmtTitle("Enable Description Text Logging?"), defaultValue: true | |
input "debugEnable", "bool", title: fmtTitle("Enable Debug Logging?"), defaultValue: true | |
} | |
} | |
//Preference Helpers | |
String fmtDesc(String str) { | |
return "<div style='font-size: 85%; font-style: italic; padding: 1px 0px 4px 2px;'>${str}</div>" | |
} | |
String fmtTitle(String str) { | |
return "<strong>${str}</strong>" | |
} | |
//Set Command Class Versions | |
@Field static final Map commandClassVersions = [ | |
0x70: 2, // Configuration (configurationv2) | |
0x71: 8, // Notification (notificationv8) | |
0x80: 1, // Battery (batteryv1) | |
0x84: 2, // Wakeup (wakeupv2) | |
0x85: 2, // Association (associationv2) | |
0x86: 2, // Version (versionv2) | |
] | |
/******************************************************************* | |
***** Core Functions | |
********************************************************************/ | |
void installed() { | |
logWarn "installed..." | |
} | |
void fullConfigure() { | |
logWarn "configure..." | |
if (debugEnable) runIn(1800, debugLogsOff) | |
if (!pendingChanges || state.resyncAll == null) { | |
logForceWakeupMessage "Full Re-Configure" | |
state.resyncAll = true | |
} else { | |
logForceWakeupMessage "Pending Configuration Changes" | |
} | |
updateSyncingStatus(1) | |
} | |
void updated() { | |
logDebug "updated..." | |
logDebug "Debug logging is: ${debugEnable == true}" | |
logDebug "Description logging is: ${txtEnable == true}" | |
if (debugEnable) runIn(1800, debugLogsOff) | |
if (!firmwareVersion) { | |
state.resyncAll = true | |
state.pendingRefresh = true | |
logForceWakeupMessage "Full Re-Configure and Refresh" | |
} | |
if (pendingChanges) { | |
logForceWakeupMessage "Pending Configuration Changes" | |
} | |
else if (!state.resyncAll && !state.pendingRefresh) { | |
state.remove("INFO") | |
} | |
updateSyncingStatus(1) | |
} | |
void forceRefresh() { | |
logDebug "refresh..." | |
state.pendingRefresh = true | |
logForceWakeupMessage "Sensor Info Refresh" | |
} | |
String setParameter(paramNum, value, size = null) { | |
if (paramNum == null || value == null || size == null) { | |
logWarn "Incomplete parameter list supplied..." | |
logWarn "Syntax: setParameter(paramNum, value, size)" | |
return | |
} | |
logDebug "setParameter ( number: $paramNum, value: $value, size: $size )" | |
return secureCmd(configSetCmd([num: paramNum, size: size], value as Integer)) | |
} | |
/******************************************************************* | |
***** Z-Wave Reports | |
********************************************************************/ | |
void parse(String description) { | |
hubitat.zwave.Command cmd = zwave.parse(description, commandClassVersions) | |
if (cmd) { | |
logTrace "parse: ${description} --PARSED-- ${cmd}" | |
zwaveEvent(cmd) | |
} else { | |
logWarn "Unable to parse: ${description}" | |
} | |
//Update Last Activity | |
updateLastCheckIn() | |
} | |
void zwaveEvent(hubitat.zwave.commands.securityv1.SecurityMessageEncapsulation cmd) { | |
def encapsulatedCmd = cmd.encapsulatedCommand(commandClassVersions) | |
logTrace "${cmd} --ENCAP-- ${encapsulatedCmd}" | |
if (encapsulatedCmd) { | |
zwaveEvent(encapsulatedCmd) | |
} else { | |
logWarn "Unable to extract encapsulated cmd from $cmd" | |
} | |
} | |
//Decodes Multichannel Encapsulated Commands | |
void zwaveEvent(hubitat.zwave.commands.multichannelv3.MultiChannelCmdEncap cmd) { | |
def encapsulatedCmd = cmd.encapsulatedCommand(commandClassVersions) | |
logTrace "${cmd} --ENCAP-- ${encapsulatedCmd}" | |
if (encapsulatedCmd) { | |
zwaveEvent(encapsulatedCmd, cmd.sourceEndPoint as Integer) | |
} else { | |
logWarn "Unable to extract encapsulated cmd from $cmd" | |
} | |
} | |
//Decodes Supervision Encapsulated Commands (and replies to device) | |
void zwaveEvent(hubitat.zwave.commands.supervisionv1.SupervisionGet cmd, ep=0) { | |
def encapsulatedCmd = cmd.encapsulatedCommand(commandClassVersions) | |
logTrace "${cmd} --ENCAP-- ${encapsulatedCmd}" | |
if (encapsulatedCmd) { | |
zwaveEvent(encapsulatedCmd, ep) | |
} else { | |
logWarn "Unable to extract encapsulated cmd from $cmd" | |
} | |
sendCommands(secureCmd(zwave.supervisionV1.supervisionReport(sessionID: cmd.sessionID, reserved: 0, moreStatusUpdates: false, status: 0xFF, duration: 0), ep)) | |
} | |
void zwaveEvent(hubitat.zwave.commands.versionv2.VersionReport cmd) { | |
logTrace "${cmd}" | |
String fullVersion = String.format("%d.%02d",cmd.firmware0Version,cmd.firmware0SubVersion) | |
device.updateDataValue("firmwareVersion", fullVersion) | |
logDebug "Received Version Report - Firmware: ${fullVersion}" | |
} | |
void zwaveEvent(hubitat.zwave.commands.configurationv2.ConfigurationReport cmd) { | |
logTrace "${cmd}" | |
Number val = cmd.scaledConfigurationValue | |
logDebug "Parameter #${cmd.parameterNumber} = ${val.toString()}" | |
} | |
void zwaveEvent(hubitat.zwave.commands.associationv2.AssociationReport cmd) { | |
logTrace "${cmd}" | |
Integer grp = cmd.groupingIdentifier | |
if (grp == 1) { | |
logDebug "Lifeline Association: ${cmd.nodeId}" | |
state.group1Assoc = (cmd.nodeId == [zwaveHubNodeId]) ? true : false | |
} | |
else { | |
logWarn "Unhandled Group: $cmd" | |
} | |
} | |
void zwaveEvent(hubitat.zwave.commands.batteryv1.BatteryReport cmd, ep=0) { | |
logTrace "${cmd} (ep ${ep})" | |
Integer batLvl = cmd.batteryLevel | |
if (batLvl == 0xFF) { | |
batLvl = 1 | |
logWarn "LOW BATTERY WARNING" | |
} | |
batLvl = validateRange(batLvl, 100, 1, 100) | |
String descText = "battery level is ${batLvl}%" | |
sendEventLog(name:"battery", value:batLvl, unit:"%", desc:descText, isStateChange:true) | |
} | |
void zwaveEvent(hubitat.zwave.commands.wakeupv2.WakeUpIntervalReport cmd) { | |
logTrace "${cmd}" | |
BigDecimal wakeHrs = safeToDec(cmd.seconds/3600,0,2) | |
logDebug "WakeUp Interval is $cmd.seconds seconds ($wakeHrs hours)" | |
device.updateDataValue("zwWakeupInterval", "${cmd.seconds}") | |
} | |
void zwaveEvent(hubitat.zwave.commands.wakeupv2.WakeUpNotification cmd, ep=0) { | |
logTrace "${cmd} (ep ${ep})" | |
logDebug "WakeUp Notification Received" | |
List<String> cmds = [] | |
cmds << batteryGetCmd() | |
//Refresh all if requested | |
if (state.pendingRefresh) { cmds += getRefreshCmds() } | |
//Any configuration needed | |
cmds += getConfigureCmds() | |
//This needs a longer delay | |
cmds << "delay 1000" << wakeUpNoMoreInfoCmd() | |
//Clear pending status | |
state.resyncAll = false | |
state.pendingRefresh = false | |
state.remove("INFO") | |
sendCommands(cmds, 400) | |
} | |
void zwaveEvent(hubitat.zwave.commands.notificationv8.NotificationReport cmd, ep=0) { | |
logTrace "${cmd} (ep ${ep})" | |
switch (cmd.notificationType) { | |
case 0x07: //Home Security | |
switch (cmd.event) { | |
case 0x02: //Intrusion (Tilt Sensor) | |
sendEventLog(name:"contact", value:(cmd.v1AlarmLevel ? "open" : "closed")) | |
break | |
case 0xFE: //Unknown event/state (External Contact) | |
sendEventLog(name:"external", value:(cmd.v1AlarmLevel ? "open" : "closed")) | |
break | |
case 0x03: //Tampering - cover removed | |
sendEventLog(name:"tamper", value:(cmd.v1AlarmLevel ? "detected" : "clear")) | |
runIn(120, tamperClear) //Device may never send a clear | |
break | |
default: | |
logWarn "Unhandled NotificationReport event: ${cmd}" | |
} | |
break | |
default: | |
logWarn "Unhandled NotificationReport notificationType: ${cmd}" | |
} | |
} | |
void tamperClear() { | |
logDebug "tamperClear - tamper timed out, clearing" | |
sendEventLog(name:"tamper", value:"clear") | |
} | |
void zwaveEvent(hubitat.zwave.Command cmd, ep=0) { | |
logDebug "Unhandled zwaveEvent: $cmd (ep ${ep})" | |
} | |
/******************************************************************* | |
***** Z-Wave Command Shortcuts | |
********************************************************************/ | |
//These send commands to the device either a list or a single command | |
void sendCommands(List<String> cmds, Long delay=200) { | |
sendHubCommand(new hubitat.device.HubMultiAction(delayBetween(cmds, delay), hubitat.device.Protocol.ZWAVE)) | |
} | |
//Single Command | |
void sendCommands(String cmd) { | |
sendHubCommand(new hubitat.device.HubAction(cmd, hubitat.device.Protocol.ZWAVE)) | |
} | |
//Consolidated zwave command functions so other code is easier to read | |
String associationSetCmd(Integer group, List<Integer> nodes) { | |
return secureCmd(zwave.associationV2.associationSet(groupingIdentifier: group, nodeId: nodes)) | |
} | |
String associationRemoveCmd(Integer group, List<Integer> nodes) { | |
return secureCmd(zwave.associationV2.associationRemove(groupingIdentifier: group, nodeId: nodes)) | |
} | |
String associationGetCmd(Integer group) { | |
return secureCmd(zwave.associationV2.associationGet(groupingIdentifier: group)) | |
} | |
String versionGetCmd() { | |
return secureCmd(zwave.versionV2.versionGet()) | |
} | |
String wakeUpIntervalGetCmd() { | |
return secureCmd(zwave.wakeUpV2.wakeUpIntervalGet()) | |
} | |
String wakeUpIntervalSetCmd(val) { | |
return secureCmd(zwave.wakeUpV2.wakeUpIntervalSet(seconds:val, nodeid:zwaveHubNodeId)) | |
} | |
String wakeUpNoMoreInfoCmd() { | |
return secureCmd(zwave.wakeUpV2.wakeUpNoMoreInformation()) | |
} | |
String batteryGetCmd() { | |
return secureCmd(zwave.batteryV1.batteryGet()) | |
} | |
String notificationGetCmd(notificationType, eventType) { | |
return secureCmd(zwave.notificationV8.notificationGet(notificationType: notificationType, v1AlarmType:0, event: eventType)) | |
} | |
String configSetCmd(Map param, Number value) { | |
//Convert to signed integer for scaledConfigurationValue | |
Long sizeFactor = Math.pow(256,param.size).round() | |
if (value >= sizeFactor/2) { value -= sizeFactor } | |
return secureCmd(zwave.configurationV2.configurationSet(parameterNumber: param.num, size: param.size, scaledConfigurationValue: value)) | |
} | |
String configGetCmd(Map param) { | |
return secureCmd(zwave.configurationV2.configurationGet(parameterNumber: param.num)) | |
} | |
List configSetGetCmd(Map param, Integer value) { | |
List<String> cmds = [] | |
cmds << configSetCmd(param, value) | |
cmds << configGetCmd(param) | |
return cmds | |
} | |
/******************************************************************* | |
***** Z-Wave Encapsulation | |
********************************************************************/ | |
//Secure and MultiChannel Encapsulate | |
String secureCmd(String cmd) { | |
return zwaveSecureEncap(cmd) | |
} | |
String secureCmd(hubitat.zwave.Command cmd, ep=0) { | |
return zwaveSecureEncap(multiChannelEncap(cmd, ep)) | |
} | |
//MultiChannel Encapsulate if needed | |
//This is called from secureCmd or supervisionEncap, do not call directly | |
String multiChannelEncap(hubitat.zwave.Command cmd, ep) { | |
//logTrace "multiChannelEncap: ${cmd} (ep ${ep})" | |
if (ep > 0) { | |
cmd = zwave.multiChannelV3.multiChannelCmdEncap(destinationEndPoint:ep).encapsulate(cmd) | |
} | |
return cmd.format() | |
} | |
/******************************************************************* | |
***** Execute / Build Commands | |
********************************************************************/ | |
List<String> getConfigureCmds() { | |
logDebug "getConfigureCmds..." | |
List<String> cmds = [] | |
if (state.resyncAll || !firmwareVersion) { | |
cmds << versionGetCmd() | |
cmds << wakeUpIntervalSetCmd(43200) | |
cmds << wakeUpIntervalGetCmd() | |
} | |
cmds += getConfigureAssocsCmds() | |
if (state.resyncAll) clearVariables() | |
state.resyncAll = false | |
if (cmds) updateSyncingStatus(6) | |
return cmds ?: [] | |
} | |
List<String> getRefreshCmds() { | |
List<String> cmds = [] | |
cmds << versionGetCmd() | |
cmds << wakeUpIntervalGetCmd() | |
return cmds ?: [] | |
} | |
void clearVariables() { | |
logWarn "Clearing state variables and data..." | |
//Clears State Variables | |
state.clear() | |
//Clear Data from other Drivers | |
device.removeDataValue("zwaveAssociationG1") | |
device.removeDataValue("zwaveAssociationG2") | |
device.removeDataValue("zwaveAssociationG3") | |
} | |
List getConfigureAssocsCmds() { | |
List<String> cmds = [] | |
if (!state.group1Assoc || state.resyncAll) { | |
if (state.group1Assoc == false) { | |
logDebug "Adding missing lifeline association..." | |
} | |
cmds << associationSetCmd(1, [zwaveHubNodeId]) | |
cmds << associationGetCmd(1) | |
} | |
return cmds | |
} | |
Integer getPendingChanges() { | |
Integer configChanges = 0 | |
Integer pendingAssocs = Math.ceil(getConfigureAssocsCmds()?.size()/2) ?: 0 | |
return (!state.resyncAll ? (configChanges + pendingAssocs) : configChanges) | |
} | |
/******************************************************************* | |
***** Event Senders | |
********************************************************************/ | |
//evt = [name, value, type, unit, desc, isStateChange] | |
void sendEventLog(Map evt, Integer ep=0) { | |
//Set description if not passed in | |
evt.descriptionText = evt.desc ?: "${evt.name} set to ${evt.value}${evt.unit ?: ''}" | |
//Main Device Events | |
if (evt.name != "syncStatus") { | |
if (device.currentValue(evt.name).toString() != evt.value.toString()) { | |
logInfo "${evt.descriptionText}" | |
} else { | |
logDebug "${evt.descriptionText} [NOT CHANGED]" | |
} | |
} | |
//Always send event to update last activity | |
sendEvent(evt) | |
} | |
/******************************************************************* | |
***** Common Functions | |
********************************************************************/ | |
/*** Parameter Store Map Functions ***/ | |
@Field static Map<String, Map> configsList = new java.util.concurrent.ConcurrentHashMap() | |
Integer getParamStoredValue(Integer paramNum) { | |
//Using Data (Map) instead of State Variables | |
TreeMap configsMap = getParamStoredMap() | |
return safeToInt(configsMap[paramNum], null) | |
} | |
void setParamStoredValue(Integer paramNum, Integer value) { | |
//Using Data (Map) instead of State Variables | |
TreeMap configsMap = getParamStoredMap() | |
configsMap[paramNum] = value | |
configsList[device.id][paramNum] = value | |
device.updateDataValue("configVals", configsMap.inspect()) | |
} | |
Map getParamStoredMap() { | |
Map configsMap = configsList[device.id] | |
if (configsMap == null) { | |
configsMap = [:] | |
if (device.getDataValue("configVals")) { | |
try { | |
configsMap = evaluate(device.getDataValue("configVals")) | |
} | |
catch(Exception e) { | |
logWarn("Clearing Invalid configVals: ${e}") | |
device.removeDataValue("configVals") | |
} | |
} | |
configsList[device.id] = configsMap | |
} | |
return configsMap | |
} | |
/*** Other Helper Functions ***/ | |
void updateSyncingStatus(Integer delay=2) { | |
runIn(delay, refreshSyncStatus) | |
sendEventLog(name:"syncStatus", value:"Syncing...") | |
} | |
void refreshSyncStatus() { | |
Integer changes = pendingChanges | |
sendEventLog(name:"syncStatus", value:(changes ? "${changes} Pending Changes" : "Synced")) | |
} | |
void updateLastCheckIn() { | |
if (!isDuplicateCommand(state.lastCheckInTime, 60000)) { | |
state.lastCheckInTime = new Date().time | |
state.lastCheckInDate = convertToLocalTimeString(new Date()) | |
} | |
} | |
BigDecimal getFirmwareVersion() { | |
String version = device?.getDataValue("firmwareVersion") | |
return ((version != null) && version.isNumber()) ? version.toBigDecimal() : 0.0 | |
} | |
String convertToLocalTimeString(dt) { | |
def timeZoneId = location?.timeZone?.ID | |
if (timeZoneId) { | |
return dt.format("MM/dd/yyyy hh:mm:ss a", TimeZone.getTimeZone(timeZoneId)) | |
} else { | |
return "$dt" | |
} | |
} | |
List convertIntListToHexList(intList, pad=2) { | |
def hexList = [] | |
intList?.each { | |
hexList.add(Integer.toHexString(it).padLeft(pad, "0").toUpperCase()) | |
} | |
return hexList | |
} | |
Integer validateRange(val, Integer defaultVal, Integer lowVal, Integer highVal) { | |
Integer intVal = safeToInt(val, defaultVal) | |
if (intVal > highVal) { | |
return highVal | |
} else if (intVal < lowVal) { | |
return lowVal | |
} else { | |
return intVal | |
} | |
} | |
Integer safeToInt(val, defaultVal=0) { | |
if ("${val}"?.isInteger()) { return "${val}".toInteger() } | |
else if ("${val}"?.isNumber()) { return "${val}".toDouble()?.round() } | |
else { return defaultVal } | |
} | |
BigDecimal safeToDec(val, defaultVal=0, roundTo=-1) { | |
BigDecimal decVal = "${val}"?.isNumber() ? "${val}".toBigDecimal() : defaultVal | |
if (roundTo == 0) { decVal = Math.round(decVal) } | |
else if (roundTo > 0) { decVal = decVal.setScale(roundTo, BigDecimal.ROUND_HALF_UP).stripTrailingZeros() } | |
if (decVal.scale()<0) { decVal = decVal.setScale(0) } | |
return decVal | |
} | |
boolean isDuplicateCommand(lastExecuted, allowedMil) { | |
!lastExecuted ? false : (lastExecuted + allowedMil > new Date().time) | |
} | |
/******************************************************************* | |
***** Logging Functions | |
********************************************************************/ | |
private logForceWakeupMessage(msg) { | |
String helpText = "Check the manual for how to wake up the device." | |
logWarn "${msg} will execute the next time the device wakes up. ${helpText}" | |
state.INFO = "*** ${msg} *** Waiting for device to wake up. ${helpText}" | |
} | |
void logsOff() {} | |
void debugLogsOff() { | |
logWarn "Debug logging disabled..." | |
device.updateSetting("debugEnable",[value:"false",type:"bool"]) | |
} | |
void logWarn(String msg) { | |
log.warn "${device.displayName}: ${msg}" | |
} | |
void logInfo(String msg) { | |
if (txtEnable) log.info "${device.displayName}: ${msg}" | |
} | |
void logDebug(String msg) { | |
if (debugEnable) log.debug "${device.displayName}: ${msg}" | |
} | |
//For Extreme Code Debugging - tracing commands | |
void logTrace(String msg) { | |
//Uncomment to Enable | |
//log.trace "${device.displayName}: ${msg}" | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment