/* * Securifi Almond Click * * Description: * This Hubitat driver is designed for use with the Securifi ZB2-BU01 Click button. * * Licensing: * Copyright 2024 David Snell * 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. * * Version Control: * 0.6.5 - Correction to ProcessEvent function and removal of old driver-specific attributes when Preferences are saved * 0.6.4 - Correction to MaxPresses rollover * 0.6.3 - Overhaul of ZigBee parsing, event handling, and start for tripleTap, forcing Events for button presses * 0.6.2 - Corrected MaxPresses to work properly and general cleanup * 0.6.1 - Correction to button commands from parsed data and use newer ProcessState/ProcessEvent commands * 0.6.0 - Update to attributes to remove int and replace with number, update to semver versioning, update to version checking, * updates for changes to capabilities since this driver was originally written * 0.5 - Update to driver version checking method * 0.4 - Updated the ZigBee reporting section * 0.3 - Added sendEvent for ButtonPresses * 0.2 - Correction to ClusterId detection and fingerprint * 0.1 - Initial version * * Thank you(s): * I would like to thank @Cobra his contributions to the community. Parts based on Cobra's driver update code * have been included at the bottom of the driver and are noted as such. */ import hubitat.zigbee.zcl.DataType // Returns the driver name def DriverName(){ return "Almond Click" } // Returns the driver version def DriverVersion(){ return "0.6.5" } metadata{ definition( name: "Almond Click", namespace: "Snell", author: "David Snell", importUrl: "https://www.drdsnell.com/projects/hubitat/drivers/AlmondClick.groovy" ){ capability "Configuration" capability "Sensor" capability "Battery" capability "PushableButton" capability "HoldableButton" capability "DoubleTapableButton" // Commands that are able to be activated command "push" command "doubleTap" //command "tripleTap" command "hold" // Attributes being built into the device attribute "DriverName", "string" // Identifies the driver's name attribute "DriverStatus", "string" // Identifies the driver's update status attribute "DriverVersion", "string" // Identifies the driver's version number attribute "ButtonPressed", "number" // ButtonPressed is used to give the number of the button pressed attribute "ButtonPositions", "string" // ButtonPosition is used to show which button was pressed attribute "PressType", "string" // PressType is used to show if a press was long or short attribute "ButtonPresses", "number" // ButtonPresses is used to show the number of button presses attribute "tripleTapped", "number" // tripleTapped is used to show when a triple tap has occurred fingerprint profileId: "0104", inClusters: "0000, 0003, 0500", outClusters: "0003, 0501", manufacturer: "Securifi Ltd.", model: "ZB2-BU01", deviceJoinName: "Almond Click" fingerprint profileId: "0104", inClusters: "0000, 0003, 0500", outClusters: "0003, 0501", manufacturer: "Securifi Ltd. ZB2-BU01 ���", model: "ZB2-BU01 �������������������", deviceJoinName: "Almond Click" } preferences{ section{ input( type: "int", name: "MaxPresses", title: "Max button presses before rolling over?", required: true, defaultValue: 4 ) input( type: "enum", name: "LogType", title: "Enable Logging?", required: true, multiple: false, options: [ "None", "Info", "Debug", "Trace" ], defaultValue: "Info" ) } } } // Called when preferences are saved // This clears state variables then sets some basic information as well as does a reconfig of the device def updated(){ // Clear current State Variables state.clear() // Set the driver name and version before update checking is scheduled if( state."Driver Name" != null ){ state.remove( "Driver Name" ) state.remove( "Driver Version" ) device.deleteCurrentState( "Driver Name" ) device.deleteCurrentState( "Driver Version" ) } ProcessState( "DriverName", "${ DriverName() }" ) ProcessState( "DriverVersion", "${ DriverVersion() }" ) // Set basic device information ProcessEvent( "numberOfButtons", 1 ) ProcessEvent( "PressType", null ) ProcessEvent( "pushed", 0 ) ProcessEvent( "held", 0 ) ProcessEvent( "doubleTapped", 0 ) //ProcessEvent( "tripleTapped", 0 ) ProcessEvent( "ButtonPresses", 0 ) // Schedule the daily driver version check schedule( new Date(), CheckForUpdate ) Logging( "Preferences saved... starting to configure device.", 2 ) configure() } // Parse incoming device messages to generate events def parse( String description ){ def event = zigbee.getEvent( description ) if( event ){ Logging( "event name is ${ event.name }", 4 ) if( event.name == "power" ){ PowerEvent( description ) } else { sendEvent( event ) } } else if( description?.startsWith( "read attr -" ) ){ parseReportAttributeMessage( description ) } else if( description?.startsWith( "zone status" ) ){ Logging( "Zone event: ${ description }", 4 ) } else if( description?.startsWith( "catchall" ) ){ parseReportAttributeMessage( description ) } else { Logging( "DID NOT PARSE MESSAGE", 3 ) Logging( "${ zigbee.parseDescriptionAsMap( description ) }", 3 ) } } // Parse ZigBee reports private parseReportAttributeMessage( String description ){ def descMap = zigbee.parseDescriptionAsMap( description ) def cluster = descMap.cluster ?: descMap.clusterId def hexValue = descMap.value def attrId = descMap.attrId Logging( "clusterId = ${ cluster }, attrId = ${ attrId } - cmd = ${ descMap.command }, data = ${ descMap.data }. value = ${ hexValue }", 4 ) switch( cluster ){ case "0000": // Basic Logging( "Basic Cluster response Command=${ descMap.command } & Data=${ descMap.data }", 4 ) break case "0001": // Power configuration BatteryReport( descMap.command, descMap.data ) break case "0501": // IAS ACE "Ancillary Control" HandleIASACE( descMap.data, hexValue ) break // "Known" clusters with no data relevant for this device so they are ignored case "0002": // Device temperature configuration case "0003": // Identify case "0004": // Groups case "0005": // Scenes case "0006": // On/Off case "0007": // On/Off switch configuration case "0008": // Level control case "0009": // Alarms case "000A": // Time case "000B": // RSSI Location case "000C": // Analog Input case "000D": // Analog Output case "000E": // Analog Value case "000F": // Binary Input case "0010": // Binary Output case "0011": // Binary Value case "0012": // Multistate Input case "0013": // ZDO Device Announce & Multistate Output case "0014": // Multistate Value case "0015": // Commissioning case "0016": // Discovery_Store_req case "0017": // Node_Desc_Store_req case "0018": // Power_Desc_Store_req case "0019": // Active_EP_Store_req case "001A": // Simple_Desc_Store_req case "001B": // Remove_Node_Cache_req case "001C": // Find_Node_Cache_req // End Device Bind, Unbind and Bind Management case "0020": // End_Dev_Bind_req case "0021": // Bind_req case "0022": // Unbind_req case "0023": // Bind_Register_req case "0024": // Replace_Device_req case "0025": // Store_Bkup_Bind_Entry_req case "0026": // Rm_Bkup_Bind_Entry_req case "0027": // Backup_Bind_Table_req case "0028": // Recover_Bind_Table_req case "0029": // Backup_Source_Bind_req case "002A": // Recover_Source_Bind_req // Network Management case "0030": // Mgmt_NWK_Disc_req case "0031": // Mgmt_LQI_req case "0032": // Mgmt_Rtg_req case "0033": // Mgmt_Bind_req case "0034": // Mgmt_Leave_req case "0035": // Mgmt_Direct_Join_req case "0036": // Mgmt_Permit_Join_req case "0037": // Mgmt_Cache_req // Closures Clusters case "0100": // Shade configuration case "0101": // Door Lock // HVAC Clusters case "0200": // Pump configuration and control case "0201": // Thermostat case "0202": // Fan control case "0203": // Dehumidifier control case "0204": // Thermostat user interface configuration // Lighting Clusters case "0300": // Color control case "0301": // Ballast configuration // Measurement and Sensing Clusters case "0400": // Luminance measurement case "0401": // Luminance level sensing case "0402": // Temperature measurement case "0403": // Pressure measurement case "0404": // Flow measurement case "0405": // Relative humidity measurement case "0406": // Occupancy sensing // Security & Safety Clusters case "0500": // IAS Zone case "0502": // IAS WD "Warning Devices" // Protocol Interface Clusters case "0600": // Generic Tunnel case "0601": // BACnet Protocol Tunnel case "0602": // Analog Input (BACnet Regular) case "0603": // Analog Input (BACnet Extended) case "0604": // Analog Output (BACnet Regular) case "0605": // Analog Output (BACnet Extended) case "0606": // Analog Value (BACnet Regular) case "0607": // Analog Value (BACnet Extended) case "0608": // Binary Input (BACnet Regular) case "0609": // Binary Input (BACnet Extended) case "060A": // Binary Output (BACnet Regular) case "060B": // Binary Output (BACnet Extended) case "060C": // Binary Value (BACnet Regular) case "060D": // Binary Value (BACnet Extended) case "060E": // Multistate Input (BACnet Regular) case "060F": // Multistate Input (BACnet Extended) case "0610": // Multistate Output (BACnet Regular) case "0611": // Multistate Output (BACnet Extended) case "0612": // Multistate Value (BACnet Regular) case "0613": // Multistate Value (BACnet Extended) case "0702": // seMetering case "2820": // Electrical Measurement case "8000": // NWK_ADDR_RSP case "8001": // IEEE_ADDR_RSP case "8002": // NODE_DESC_RSP case "8003": // POWER_DESC_RSP case "8004": // SIMPLE_DESC_RSP case "8005": // ACTIVE_EP_RSP case "8006": // MATCH_DESC_RSP case "8010": // Complex_Desc_rsp case "8011": // User_Desc_rsp case "8012": // Discovery_Cache_rsp case "8014": // User_Desc_Conf case "8015": // System_Server_Discover_rsp case "8016": // Discovery_Store_rsp case "8017": // Node_Desc_Store_rso case "8018": // Power_Desc_Store_rsp case "8019": // Active_EP_Store_rsp case "801A": // Simple_Desc_Store_rsp case "801B": // Remove_Node_Cache_rsp case "801C": // Find_Node_Cache_rsp // End Device Bind, Unbind and Bind Management case "8020": // End_Dev_Bind_rsp case "8021": // Bind_rsp case "8022": // Unbind_rsp case "8023": // Bind_Register_rsp case "8024": // Replace_Device_rsp case "8025": // Store_Bkup_Bind_Entry_rsp case "8026": // Rm_Bkup_Bind_Entry_rsp case "8027": // Backup_Bind_Table_rsp case "8028": // Recover_Bind_Table_rsp case "8029": // Backup_Source_Bind_rsp case "802A": // Recover_Source_Bind_rsp // Network Management case "8030": // Mgmt_NWK_Disc_rsp case "8031": // Mgmt_LQI_rsp case "8032": // Mgmt_Rtg_rsp case "8033": // Mgmt_Bind_rsp case "8034": // Mgmt_Leave_rsp case "8035": // Mgmt_Direct_Join_rsp case "8036": // Mgmt_Permit_Join_rsp case "8037": // Mgmt_Cache_rsp // Manufacturer-specific clusters? case "0B02": // ??? case "0B04": // haElectricalMeasurement ? case "0B05": // haDiagnostic ? case "FD00": // Halo Smoke Detector Only ??? case "FD01": // Halo Smoke Detector Only ??? case "FD02": // Halo Smoke Detector Only ??? break // "Unknown" clusters I have not seen before so logging just in case default: Logging( "Unhandled ClusterID: descMap:${ descMap }, description:${ description }", 3 ) break } } // Processes temperature related reports private BatteryReport( cmd, data ){ def BatteryPercent if( cmd == "07" ){ BatteryPercent = data[ 0 ] as int Logging( "Battery at ${ BatteryPercent }%", 2 ) sendEvent( name: "battery", value: BatteryPercent, unit: "%" ) } } // Processes IAS reports, at this time button presses from a Click or Keyfob private HandleIASACE( data, hex ){ if( data == null && hex == null) return def CleanedData = data[ 0 ] as String Logging( "ZigBee Button Value = ${ CleanedData }", 4 ) if( CleanedData == "00" ){ doubleTap() } else if( CleanedData == "03" ){ push() } else if( CleanedData == "20" ){ hold() } else if( CleanedData == "02" ){ hold() } } // Handles when a single press has been reported def push(){ def TempType = state.PressType def TempPresses = state.ButtonPresses if( state.PressType == "Hold" || state.PressType == "Double" || state.PressType == "Triple" || state.PressType == null ){ TempType = "Push" TempPresses = 0 } if( ( ( state.ButtonPresses as int ) < ( MaxPresses as int ) ) || ( state.ButtonPresses == null ) ){ TempPresses += 1 } else { TempPresses = 1 } Logging( "Short button presses = ${ state.ButtonPresses }", 4 ) ProcessEvent( "PressType", TempType, null, true ) ProcessEvent( "ButtonPresses", TempPresses ) ProcessEvent( "pushed", 1, null, true ) ProcessEvent( "doubleTapped", 0, null, true ) ProcessEvent( "held", 0, null, true ) } // Handles when a double press has been reported private doubleTap(){ def TempType = state.PressType def TempPresses = state.ButtonPresses if( state.PressType == "Hold" || state.PressType == "Push" || state.PressType == "Triple" || state.PressType == null ){ TempType = "Double" TempPresses = 0 } if( ( ( state.ButtonPresses as int ) < ( MaxPresses as int ) ) || ( state.ButtonPresses == null ) ){ TempPresses += 1 } else { TempPresses = 1 } Logging( "Double button presses = ${ state.ButtonPresses }", 4 ) ProcessEvent( "PressType", TempType, null, true ) ProcessEvent( "ButtonPresses", TempPresses ) ProcessEvent( "pushed", 1, null, true ) ProcessEvent( "doubleTapped", 1, null, true ) ProcessEvent( "held", 0, null, true ) } // Handles when a triple press has been reported private tripleTap(){ def TempType = state.PressType def TempPresses = state.ButtonPresses if( state.PressType == "Hold" || state.PressType == "Push" || state.PressType == "Double" || state.PressType == null ){ TempType = "Triple" TempPresses = 0 } if( ( ( state.ButtonPresses as int ) < ( MaxPresses as int ) ) || ( state.ButtonPresses == null ) ){ TempPresses += 1 } else { TempPresses = 1 } Logging( "Double button presses = ${ state.ButtonPresses }", 4 ) ProcessEvent( "PressType", TempType, null, true ) ProcessEvent( "ButtonPresses", TempPresses ) ProcessEvent( "pushed", 1, null, true ) ProcessEvent( "doubleTapped", 0, null, true ) ProcessEvent( "held", 0, null, true ) } // Handles when a long press has been reported (>5 seconds in length) private hold(){ def TempType = state.PressType def TempPresses = state.ButtonPresses if( state.PressType == "Push" || state.PressType == "Double" || state.PressType == null ){ TempType = "Hold" TempPresses = 0 } if( ( ( state.ButtonPresses as int ) < ( MaxPresses as int ) ) || ( state.ButtonPresses == null ) ){ TempPresses += 1 } else { TempPresses = 1 } Logging( "Long button presses = ${ state.ButtonPresses }", 4 ) ProcessEvent( "PressType", TempType, null, true ) ProcessEvent( "ButtonPresses", TempPresses ) ProcessEvent( "pushed", 1, null, true ) ProcessEvent( "doubleTapped", 0, null, true ) ProcessEvent( "held", 1, null, true ) } // Process data to check against current state value and then send an event if it has changed def ProcessEvent( Variable, Value, Unit = null, ForceEvent = false ){ if( ( state."${ Variable }" != Value ) || ( ForceEvent == true ) ){ state."${ Variable }" = Value if( Unit != null ){ Logging( "Event: ${ Variable } = ${ Value }${ Unit }", 4 ) sendEvent( name: "${ Variable }", value: Value, unit: Unit, isStateChange: true ) } else { Logging( "Event: ${ Variable } = ${ Value }", 4 ) sendEvent( name: "${ Variable }", value: Value, isStateChange: true ) } } } // Process data to check against current state value and then send an event if it has changed def ProcessState( Variable, Value ){ if( state."${ Variable }" != Value ){ Logging( "State: ${ Variable } = ${ Value }", 4 ) state."${ Variable }" = Value } } // refresh command def refresh(){ def cmds = zigbee.readAttribute( 0x0001, 0x0021 ) return cmds } // Configures the device, typically at install or when preferences are saved def configure(){ List cmds = zigbee.configureReporting( 0x0001, 0x0021, DataType.UINT8, 1, 86400, 1 ) cmds = cmds + ReadDevice() return cmds Logging( "Device configuration sent.", 2 ) } // ReadDevice is meant to check all the device basic information def ReadDevice(){ def cmds = zigbee.readAttribute( 0x0000, 0x0000, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0001, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0002, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0003, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0004, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0005, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0006, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0007, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0010, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0011, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0012, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0013, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x0014, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0000, 0x4000, [:], 200 ) cmds = cmds + zigbee.readAttribute( 0x0001, 0x0021 ) // Read the type of sensor (contact or motion, etc...) cmds = cmds + zigbee.readAttribute( 0x0500, 0x0001, [:], 200 ) return cmds } // installed is called when the device is installed, all it really does is run updated def installed(){ Logging( "Installed", 2 ) updated() } // initialize is called when the device is initialized, all it really does is run updated def initialize(){ Logging( "Initialized", 2 ) updated() } // Handles whether logging is enabled and thus what to put there. def Logging( LogMessage, LogLevel ){ // Add all messages as info logging if( ( LogLevel == 2 ) && ( LogType != "None" ) ){ log.info( "${ device.displayName } - ${ LogMessage }" ) } else if( ( LogLevel == 3 ) && ( ( LogType == "Debug" ) || ( LogType == "Trace" ) ) ){ log.debug( "${ device.displayName } - ${ LogMessage }" ) } else if( ( LogLevel == 4 ) && ( LogType == "Trace" ) ){ log.trace( "${ device.displayName } - ${ LogMessage }" ) } else if( LogLevel == 5 ){ log.error( "${ device.displayName } - ${ LogMessage }" ) } } // Checks drdsnell.com for the latest version of the driver // Original inspiration from @cobra's version checking def CheckForUpdate(){ ProcessEvent( "DriverName", DriverName() ) ProcessEvent( "DriverVersion", DriverVersion() ) httpGet( uri: "https://www.drdsnell.com/projects/hubitat/drivers/versions.json", contentType: "application/json" ){ resp -> switch( resp.status ){ case 200: if( resp.data."${ DriverName() }" ){ CurrentVersion = DriverVersion().split( /\./ ) if( resp.data."${ DriverName() }".version == "REPLACED" ){ ProcessEvent( "DriverStatus", "Driver replaced, please use ${ resp.data."${ state.DriverName }".file }" ) } else if( resp.data."${ DriverName() }".version == "REMOVED" ){ ProcessEvent( "DriverStatus", "Driver removed and no longer supported." ) } else { SiteVersion = resp.data."${ DriverName() }".version.split( /\./ ) if( CurrentVersion == SiteVersion ){ Logging( "Driver version up to date", 3 ) ProcessEvent( "DriverStatus", "Up to date" ) } else if( ( CurrentVersion[ 0 ] as int ) > ( SiteVersion [ 0 ] as int ) ){ Logging( "Major development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version", 3 ) ProcessEvent( "DriverStatus", "Major development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version" ) } else if( ( CurrentVersion[ 1 ] as int ) > ( SiteVersion [ 1 ] as int ) ){ Logging( "Minor development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version", 3 ) ProcessEvent( "DriverStatus", "Minor development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version" ) } else if( ( CurrentVersion[ 2 ] as int ) > ( SiteVersion [ 2 ] as int ) ){ Logging( "Patch development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version", 3 ) ProcessEvent( "DriverStatus", "Patch development ${ CurrentVersion[ 0 ] }.${ CurrentVersion[ 1 ] }.${ CurrentVersion[ 2 ] } version" ) } else if( ( SiteVersion[ 0 ] as int ) > ( CurrentVersion[ 0 ] as int ) ){ Logging( "New major release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available", 2 ) ProcessEvent( "DriverStatus", "New major release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available" ) } else if( ( SiteVersion[ 1 ] as int ) > ( CurrentVersion[ 1 ] as int ) ){ Logging( "New minor release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available", 2 ) ProcessEvent( "DriverStatus", "New minor release ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available" ) } else if( ( SiteVersion[ 2 ] as int ) > ( CurrentVersion[ 2 ] as int ) ){ Logging( "New patch ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available", 2 ) ProcessEvent( "DriverStatus", "New patch ${ SiteVersion[ 0 ] }.${ SiteVersion[ 1 ] }.${ SiteVersion[ 2 ] } available" ) } } } else { Logging( "${ DriverName() } is not published on drdsnell.com", 2 ) ProcessEvent( "DriverStatus", "${ DriverName() } is not published on drdsnell.com" ) } break default: Logging( "Unable to check drdsnell.com for ${ DriverName() } driver updates.", 2 ) break } } }