diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..9bea4330
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+
+.DS_Store
diff --git a/README.md b/README.md
index d31a2969..03e48f6f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,35 @@
-# Xiaomi
-Repository of my smartthings device handlers for Xiaomi Devices. Created them primarily for my own use but you are free to use if you so wish.
+# Xiaomi Original & Aqara Device Handlers for SmartThings
-The devices are difficult to get paired initially. Plenty of information https://community.smartthings.com/t/release-xiaomi-sensors-and-button-beta/77576
+Maintained by bspranger
+Forked from a4refillpad's Xiaomi repository. Contributions from veeceeoh, ronvandegraaf, tmleafs & gn0st1c.
-Great devices but I personally do not recommend the outlets as they appear to make my system less stable. However, other people do not seem to report this.
+Install manually or using GitHub integration with these settings:
+```
+Owner: bspranger
+Name: Xiaomi
+Branch: master
+```
+
+---
+
+## Pairing
+
+These devices are not easy to pair initially. More information and help is available at this SmartThings Community Post.
+
+
+## Supported Xiaomi Devices
+
+| | |
+| --- | --- |
+| [![Xiaomi Button](images/button.jpg)](./devicetypes/bspranger/xiaomi-button.src) | [![Xiaomi Aqara Button](images/aqarabutton.jpg)](./devicetypes/bspranger/xiaomi-aqara-button.src) |
+| **Xiaomi Button** | **Xiaomi Aqara Button** |
+| [![Xiaomi Door/Window Sensor](images/door.jpg)](./devicetypes/bspranger/xiaomi-door-window-sensor.src) | [![Xiaomi Aqara Door/Window Sensor](images/aqaradoor.jpg)](./devicetypes/bspranger/xiaomi-aqara-door-window-sensor.src) |
+| **Xiaomi Door/Window Sensor** | **Xiaomi Aqara Door/Window Sensor** |
+| [![Xiaomi Motion Sensor](images/motion.jpg)](./devicetypes/bspranger/xiaomi-motion-sensor.src) | [![Xiaomi Aqara Motion Sensor](images/aqaramotion.jpg)](./devicetypes/bspranger/xiaomi-aqara-motion-sensor.src) |
+| **Xiaomi Motion Sensor** | **Xiaomi Aqara Motion Sensor** |
+| [![Xiaomi Temperature Humidity Sensor](images/temp.jpg)](./devicetypes/bspranger/xiaomi-temperature-humidity-sensor.src) | [![Xiaomi Aqara Temperature Humidity Sensor](images/aqaratemp.jpg)](./devicetypes/bspranger/xiaomi-aqara-temperature-humidity-sensor.src) |
+| **Xiaomi Temperature Humidity Sensor** | **Xiaomi Aqara Temperature Humidity Sensor** |
+| [![Xiaomi mijia Honeywell Fire Alarm Detector](images/smoke.jpg)](./devicetypes/bspranger/xiaomi-mijia-honeywell-fire-detector.src) | [![Xiaomi Aqara Leak Sensor](images/aqarawater.jpg)](./devicetypes/bspranger/xiaomi-aqara-leak-sensor.src) |
+| **Xiaomi mijia Honeywell Fire Alarm Detector** | **Xiaomi Aqara Leak Sensor** |
+| [![Xiaomi Zigbee Outlet](images/outlet.jpg)](./devicetypes/bspranger/xiaomi-zigbee-outlet.src) | |
+| **Xiaomi Zigbee Outlet**
**Note:** We do not recommend this outlet as they may make SmartThings less stable. | |
diff --git a/devicetypes/a4refillpad/xiaomi-door-window-sensor.src/xiaomi-door-window-sensor.groovy b/devicetypes/a4refillpad/xiaomi-door-window-sensor.src/xiaomi-door-window-sensor.groovy
deleted file mode 100644
index da925f88..00000000
--- a/devicetypes/a4refillpad/xiaomi-door-window-sensor.src/xiaomi-door-window-sensor.groovy
+++ /dev/null
@@ -1,257 +0,0 @@
-/**
- * Xiaomi Door/Window Sensor
- *
- *
- * 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.
- *
- * Based on original DH by Eric Maycock 2015 and Rave from Lazcad
- * change log:
- * added DH Colours
- * added 100% battery max
- * fixed battery parsing problem
- * added lastcheckin attribute and tile
- * added extra tile to show when last opened
- *
- */
-metadata {
- definition (name: "Xiaomi Door/Window Sensor", namespace: "a4refillpad", author: "a4refillpad") {
- capability "Configuration"
- capability "Sensor"
- capability "Contact Sensor"
- capability "Refresh"
- capability "Battery"
-
- attribute "lastCheckin", "String"
- attribute "lastOpened", "String"
-
- fingerprint profileId: "0104", deviceId: "0104", inClusters: "0000, 0003", outClusters: "0000, 0004, 0003, 0006, 0008, 0005", manufacturer: "LUMI", model: "lumi.sensor_magnet", deviceJoinName: "Xiaomi Door Sensor"
-
- command "enrollResponse"
-
- }
-
- simulator {
- status "closed": "on/off: 0"
- status "open": "on/off: 1"
- }
-
- tiles(scale: 2) {
- multiAttributeTile(name:"contact", type: "generic", width: 6, height: 4){
- tileAttribute ("device.contact", key: "PRIMARY_CONTROL") {
- attributeState "open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#ffa81e"
- attributeState "closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#79b821"
- }
- tileAttribute("device.lastCheckin", key: "SECONDARY_CONTROL") {
- attributeState("default", label:'Last Update: ${currentValue}',icon: "st.Health & Wellness.health9")
- }
- }
- standardTile("icon", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 1) {
- state "default", label:'Last Opened:'
- }
- valueTile("lastopened", "device.lastOpened", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
- state "default", label:'${currentValue}'
- }
- valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
- state "battery", label:'${currentValue}% battery', unit:""
- }
- standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
- state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
- }
- standardTile("configure", "device.configure", inactiveLabel: false, width: 2, height: 2, decoration: "flat") {
- state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure"
- }
-
- main (["contact"])
- details(["contact","battery","configure","refresh","icon","lastopened"])
- }
-}
-
-def parse(String description) {
- log.debug "Parsing '${description}'"
-
-// send event for heartbeat
- def now = new Date().format("yyyy MMM dd EEE h:mm:ss a", location.timeZone)
- sendEvent(name: "lastCheckin", value: now)
-
- Map map = [:]
-
- log.debug "${resultMap}"
- if (description?.startsWith('on/off: ')) {
- map = parseCustomMessage(description)
- sendEvent(name: "lastOpened", value: now)
- }
- if (description?.startsWith('catchall:'))
- map = parseCatchAllMessage(description)
- log.debug "Parse returned $map"
- def results = map ? createEvent(map) : null
-
- return results;
-}
-
-private Map getBatteryResult(rawValue) {
- log.debug 'Battery'
- def linkText = getLinkText(device)
-
- log.debug rawValue
-
- def result = [
- name: 'battery',
- value: '--'
- ]
-
- def volts = rawValue / 1
- def maxVolts = 100
-
- if (volts > maxVolts) {
- volts = maxVolts
- }
-
- result.value = volts
- result.descriptionText = "${linkText} battery was ${result.value}%"
-
- return result
-}
-
-private Map parseCatchAllMessage(String description) {
- Map resultMap = [:]
- def cluster = zigbee.parse(description)
- log.debug cluster
- if (cluster) {
- switch(cluster.clusterId) {
- case 0x0000:
- resultMap = getBatteryResult(cluster.data.get(23))
- break
-
- case 0xFC02:
- log.debug 'ACCELERATION'
- break
-
- case 0x0402:
- log.debug 'TEMP'
- // temp is last 2 data values. reverse to swap endian
- String temp = cluster.data[-2..-1].reverse().collect { cluster.hex1(it) }.join()
- def value = getTemperature(temp)
- resultMap = getTemperatureResult(value)
- break
- }
- }
-
- return resultMap
-}
-
-private boolean shouldProcessMessage(cluster) {
- // 0x0B is default response indicating message got through
- // 0x07 is bind message
- boolean ignoredMessage = cluster.profileId != 0x0104 ||
- cluster.command == 0x0B ||
- cluster.command == 0x07 ||
- (cluster.data.size() > 0 && cluster.data.first() == 0x3e)
- return !ignoredMessage
-}
-
-
-def configure() {
- String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
- log.debug "${device.deviceNetworkId}"
- def endpointId = 1
- log.debug "${device.zigbeeId}"
- log.debug "${zigbeeEui}"
- def configCmds = [
- //battery reporting and heartbeat
- "zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 1 {${device.zigbeeId}} {}", "delay 200",
- "zcl global send-me-a-report 1 0x20 0x20 600 3600 {01}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 1500",
-
-
- // Writes CIE attribute on end device to direct reports to the hub's EUID
- "zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 1", "delay 500",
- ]
-
- log.debug "configure: Write IAS CIE"
- return configCmds
-}
-
-def enrollResponse() {
- log.debug "Enrolling device into the IAS Zone"
- [
- // Enrolling device into the IAS Zone
- "raw 0x500 {01 23 00 00 00}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 1"
- ]
-}
-
-/*
-def refresh() {
- log.debug "Refreshing Battery"
- def endpointId = 0x01
- [
- "st rattr 0x${device.deviceNetworkId} ${endpointId} 0x0000 0x0000", "delay 200"
-
- ] //+ enrollResponse()
-}
-*/
-
-def refresh() {
- log.debug "refreshing"
- [
- "st rattr 0x${device.deviceNetworkId} 1 0 0", "delay 500",
- "st rattr 0x${device.deviceNetworkId} 1 0", "delay 250",
- ]
-}
-
-
-private Map parseCustomMessage(String description) {
- def result
- if (description?.startsWith('on/off: ')) {
- if (description == 'on/off: 0') //contact closed
- result = getContactResult("closed")
- else if (description == 'on/off: 1') //contact opened
- result = getContactResult("open")
- return result
- }
-}
-
-private Map getContactResult(value) {
- def linkText = getLinkText(device)
- def descriptionText = "${linkText} was ${value == 'open' ? 'opened' : 'closed'}"
- return [
- name: 'contact',
- value: value,
- descriptionText: descriptionText
- ]
-}
-
-private String swapEndianHex(String hex) {
- reverseArray(hex.decodeHex()).encodeHex()
-}
-private getEndpointId() {
- new BigInteger(device.endpointId, 16).toString()
-}
-
-Integer convertHexToInt(hex) {
- Integer.parseInt(hex,16)
-}
-
-private byte[] reverseArray(byte[] array) {
- int i = 0;
- int j = array.length - 1;
- byte tmp;
-
- while (j > i) {
- tmp = array[j];
- array[j] = array[i];
- array[i] = tmp;
- j--;
- i++;
- }
-
- return array
-}
\ No newline at end of file
diff --git a/devicetypes/a4refillpad/xiaomi-motion-sensor.src/xiaomi-motion-sensor.groovy b/devicetypes/a4refillpad/xiaomi-motion-sensor.src/xiaomi-motion-sensor.groovy
deleted file mode 100644
index 3aa325d4..00000000
--- a/devicetypes/a4refillpad/xiaomi-motion-sensor.src/xiaomi-motion-sensor.groovy
+++ /dev/null
@@ -1,317 +0,0 @@
-/**
- * Xiaomi Motion Sensor
- *
- *
- * 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.
- *
- * Based on original DH by Eric Maycock 2015
- * modified 29/12/2016 a4refillpad
- * Added fingerprinting
- * Added heartbeat/lastcheckin for monitoring
- * Added battery and refresh
- * Motion background colours consistent with latest DH
- * Fixed max battery percentage to be 100%
- * Added Last update to main tile
- * Added last motion tile
- * Heartdeat icon plus improved localisation of date
- *
- */
-
-metadata {
- definition (name: "Xiaomi Motion Sensor", namespace: "a4refillpad", author: "a4refillpad") {
- capability "Motion Sensor"
- capability "Configuration"
- capability "Battery"
- capability "Sensor"
- capability "Refresh"
-
- attribute "lastCheckin", "String"
- attribute "lastMotion", "String"
-
- fingerprint profileId: "0104", deviceId: "0104", inClusters: "0000, 0003, FFFF, 0019", outClusters: "0000, 0004, 0003, 0006, 0008, 0005, 0019", manufacturer: "LUMI", model: "lumi.sensor_motion", deviceJoinName: "Xiaomi Motion"
-
- command "reset"
-
- }
-
- simulator {
- }
-
- preferences {
- input "motionReset", "number", title: "Number of seconds after the last reported activity to report that motion is inactive (in seconds).", description: "", value:120, displayDuringSetup: false
- }
-
- tiles(scale: 2) {
- multiAttributeTile(name:"motion", type: "generic", width: 6, height: 4){
- tileAttribute ("device.motion", key: "PRIMARY_CONTROL") {
- attributeState "active", label:'motion', icon:"st.motion.motion.active", backgroundColor:"#ffa81e"
- attributeState "inactive", label:'no motion', icon:"st.motion.motion.inactive", backgroundColor:"#79b821"
- }
- tileAttribute("device.lastCheckin", key: "SECONDARY_CONTROL") {
- attributeState("default", label:'Last Update: ${currentValue}',icon: "st.Health & Wellness.health9")
- }
- }
- valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
- state "battery", label:'${currentValue}% battery', unit:""
- }
-
- standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
- state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
- }
- standardTile("configure", "device.configure", inactiveLabel: false, width: 2, height: 2, decoration: "flat") {
- state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure"
- }
-
- standardTile("reset", "device.reset", inactiveLabel: false, decoration: "flat", width: 2, height: 1) {
- state "default", action:"reset", label: "Reset Motion"
- }
- standardTile("icon", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 1) {
- state "default", label:'Last Motion:', icon:"st.Entertainment.entertainment15"
- }
- valueTile("lastmotion", "device.lastMotion", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
- state "default", label:'${currentValue}'
- }
-
- main(["motion"])
- details(["motion", "battery", "configure", "refresh","icon", "lastmotion", "reset" ])
- }
-}
-
-def parse(String description) {
- log.debug "description: $description"
- def value = zigbee.parse(description)?.text
- log.debug "Parse: $value"
- Map map = [:]
- if (description?.startsWith('catchall:')) {
- map = parseCatchAllMessage(description)
- }
- else if (description?.startsWith('read attr -')) {
- map = parseReportAttributeMessage(description)
- }
-
- log.debug "Parse returned $map"
- def result = map ? createEvent(map) : null
-// send event for heartbeat
- def now = new Date().format("yyyy MMM dd EEE h:mm:ss a", location.timeZone)
- sendEvent(name: "lastCheckin", value: now)
-
- if (description?.startsWith('enroll request')) {
- List cmds = enrollResponse()
- log.debug "enroll response: ${cmds}"
- result = cmds?.collect { new physicalgraph.device.HubAction(it) }
- }
-
- return result
-}
-
-private Map getBatteryResult(rawValue) {
- log.debug 'Battery'
- def linkText = getLinkText(device)
-
- log.debug rawValue
-
- def result = [
- name: 'battery',
- value: '--'
- ]
-
- def volts = rawValue / 1
-
- def maxVolts = 100
-
- if (volts > maxVolts) {
- volts = maxVolts
- }
-
- result.value = volts
- result.descriptionText = "${linkText} battery was ${result.value}%"
-
- return result
-}
-
-private Map parseCatchAllMessage(String description) {
- Map resultMap = [:]
- def cluster = zigbee.parse(description)
- log.debug cluster
- if (shouldProcessMessage(cluster)) {
- switch(cluster.clusterId) {
- case 0x0000:
- resultMap = getBatteryResult(cluster.data.last())
- break
-
- case 0xFC02:
- log.debug 'ACCELERATION'
- break
-
- case 0x0402:
- log.debug 'TEMP'
- // temp is last 2 data values. reverse to swap endian
- String temp = cluster.data[-2..-1].reverse().collect { cluster.hex1(it) }.join()
- def value = getTemperature(temp)
- resultMap = getTemperatureResult(value)
- break
- }
- }
-
- return resultMap
-}
-
-private boolean shouldProcessMessage(cluster) {
- // 0x0B is default response indicating message got through
- // 0x07 is bind message
- boolean ignoredMessage = cluster.profileId != 0x0104 ||
- cluster.command == 0x0B ||
- cluster.command == 0x07 ||
- (cluster.data.size() > 0 && cluster.data.first() == 0x3e)
- return !ignoredMessage
-}
-
-
-def configure() {
- String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
- log.debug "${device.deviceNetworkId}"
- def endpointId = 1
- log.debug "${device.zigbeeId}"
- log.debug "${zigbeeEui}"
- def configCmds = [
- //battery reporting and heartbeat
- "zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 1 {${device.zigbeeId}} {}", "delay 200",
- "zcl global send-me-a-report 1 0x20 0x20 600 3600 {01}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 1500",
-
-
- // Writes CIE attribute on end device to direct reports to the hub's EUID
- "zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 1", "delay 500",
- ]
-
- log.debug "configure: Write IAS CIE"
- return configCmds
-}
-
-def enrollResponse() {
- log.debug "Enrolling device into the IAS Zone"
- [
- // Enrolling device into the IAS Zone
- "raw 0x500 {01 23 00 00 00}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 1"
- ]
-}
-
-def refresh() {
- log.debug "Refreshing Battery"
- def endpointId = 0x01
- [
- "st rattr 0x${device.deviceNetworkId} ${endpointId} 0x0000 0x0000", "delay 200"
-// "st rattr 0x${device.deviceNetworkId} ${endpointId} 0x0000", "delay 200"
- ] //+ enrollResponse()
-}
-
-private Map parseReportAttributeMessage(String description) {
- Map descMap = (description - "read attr - ").split(",").inject([:]) { map, param ->
- def nameAndValue = param.split(":")
- map += [(nameAndValue[0].trim()):nameAndValue[1].trim()]
- }
- //log.debug "Desc Map: $descMap"
-
- Map resultMap = [:]
- def now = new Date().format("yyyy MMM dd EEE h:mm:ss a", location.timeZone)
-
- if (descMap.cluster == "0001" && descMap.attrId == "0020") {
- resultMap = getBatteryResult(Integer.parseInt(descMap.value, 16))
- }
- else if (descMap.cluster == "0406" && descMap.attrId == "0000") {
- def value = descMap.value.endsWith("01") ? "active" : "inactive"
- sendEvent(name: "lastMotion", value: now)
- if (settings.motionReset == null || settings.motionReset == "" ) settings.motionReset = 120
- if (value == "active") runIn(settings.motionReset, stopMotion)
- resultMap = getMotionResult(value)
- }
- return resultMap
-}
-
-private Map parseCustomMessage(String description) {
- Map resultMap = [:]
- return resultMap
-}
-
-private Map parseIasMessage(String description) {
- List parsedMsg = description.split(' ')
- String msgCode = parsedMsg[2]
-
- Map resultMap = [:]
- switch(msgCode) {
- case '0x0020': // Closed/No Motion/Dry
- resultMap = getMotionResult('inactive')
- break
-
- case '0x0021': // Open/Motion/Wet
- resultMap = getMotionResult('active')
- break
-
- case '0x0022': // Tamper Alarm
- log.debug 'motion with tamper alarm'
- resultMap = getMotionResult('active')
- break
-
- case '0x0023': // Battery Alarm
- break
-
- case '0x0024': // Supervision Report
- log.debug 'no motion with tamper alarm'
- resultMap = getMotionResult('inactive')
- break
-
- case '0x0025': // Restore Report
- break
-
- case '0x0026': // Trouble/Failure
- log.debug 'motion with failure alarm'
- resultMap = getMotionResult('active')
- break
-
- case '0x0028': // Test Mode
- break
- }
- return resultMap
-}
-
-
-private Map getMotionResult(value) {
- log.debug 'motion'
- String linkText = getLinkText(device)
- String descriptionText = value == 'active' ? "${linkText} detected motion" : "${linkText} motion has stopped"
- def commands = [
- name: 'motion',
- value: value,
- descriptionText: descriptionText
- ]
- return commands
-}
-
-private byte[] reverseArray(byte[] array) {
- byte tmp;
- tmp = array[1];
- array[1] = array[0];
- array[0] = tmp;
- return array
-}
-
-private String swapEndianHex(String hex) {
- reverseArray(hex.decodeHex()).encodeHex()
-}
-
-def stopMotion() {
- sendEvent(name:"motion", value:"inactive")
-}
-
-def reset() {
- sendEvent(name:"motion", value:"inactive")
-}
diff --git a/devicetypes/a4refillpad/xiaomi-temperature-humidity-sensor.src/xiaomi-temperature-humidity-sensor.groovy b/devicetypes/a4refillpad/xiaomi-temperature-humidity-sensor.src/xiaomi-temperature-humidity-sensor.groovy
deleted file mode 100644
index ab021eb4..00000000
--- a/devicetypes/a4refillpad/xiaomi-temperature-humidity-sensor.src/xiaomi-temperature-humidity-sensor.groovy
+++ /dev/null
@@ -1,265 +0,0 @@
-/**
- * Copyright 2017 A4refillpad
- *
- * 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.
- *
- * 2017-03 First release of the Xiaomi Temp/Humidity Device Handler
- * 2017-03 Includes battery level (hope it works, I've only had access to a device for a limited period, time will tell!)
- * 2017-03 Last checkin activity to help monitor health of device and multiattribute tile
- * 2017-03 Changed temperature to update on .1° changes - much more useful
- * 2017-03-08 Changed the way the battery level is being measured. Very different to other Xiaomi sensors.
- * 2017-03-23 Added Fahrenheit support
- * 2017-03-25 Minor update to display unknown battery as "--", added fahrenheit colours to main and device tiles
- * 2017-03-29 Temperature offset preference added to handler
- *
- * known issue: these devices do not seem to respond to refresh requests left in place in case things change
- * known issue: tile formatting on ios and android devices vary a little due to smartthings app - again, nothing I can do about this
- * known issue: there's nothing I can do about the pairing process with smartthings. it is indeed non standard, please refer to community forum for details
- *
- */
-metadata {
- definition (name: "Xiaomi Temperature Humidity Sensor", namespace: "a4refillpad", author: "a4refillpad") {
- capability "Temperature Measurement"
- capability "Relative Humidity Measurement"
- capability "Sensor"
- capability "Battery"
- capability "Refresh"
-
- attribute "lastCheckin", "String"
-
- fingerprint profileId: "0104", deviceId: "0302", inClusters: "0000,0001,0003,0009,0402,0405"
- }
-
- // simulator metadata
- simulator {
- for (int i = 0; i <= 100; i += 10) {
- status "${i}F": "temperature: $i F"
- }
-
- for (int i = 0; i <= 100; i += 10) {
- status "${i}%": "humidity: ${i}%"
- }
- }
-
- preferences {
- section {
- input title: "Temperature Offset", description: "This feature allows you to correct any temperature variations by selecting an offset. Ex: If your sensor consistently reports a temp that's 5 degrees too warm, you'd enter '-5'. If 3 degrees too cold, enter '+3'. Please note, any changes will take effect only on the NEXT temperature change.", displayDuringSetup: false, type: "paragraph", element: "paragraph"
- input "tempOffset", "number", title: "Degrees", description: "Adjust temperature by this many degrees", range: "*..*", displayDuringSetup: false
- }
- }
-
- // UI tile definitions
- tiles(scale: 2) {
- multiAttributeTile(name:"temperature", type:"generic", width:6, height:4) {
- tileAttribute("device.temperature", key:"PRIMARY_CONTROL"){
- attributeState("default", label:'${currentValue}°',
- backgroundColors:[
- [value: 0, color: "#153591"],
- [value: 5, color: "#1e9cbb"],
- [value: 10, color: "#90d2a7"],
- [value: 15, color: "#44b621"],
- [value: 20, color: "#f1d801"],
- [value: 25, color: "#d04e00"],
- [value: 30, color: "#bc2323"],
- [value: 44, color: "#1e9cbb"],
- [value: 59, color: "#90d2a7"],
- [value: 74, color: "#44b621"],
- [value: 84, color: "#f1d801"],
- [value: 95, color: "#d04e00"],
- [value: 96, color: "#bc2323"]
- ]
- )
- }
- tileAttribute("device.lastCheckin", key: "SECONDARY_CONTROL") {
- attributeState("default", label:'Last Update: ${currentValue}', icon: "st.Health & Wellness.health9")
- }
- }
- standardTile("humidity", "device.humidity", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
- state "default", label:'${currentValue}%', icon:"st.Weather.weather12"
- }
-
- valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
- state "default", label:'${currentValue}% battery', unit:""
- }
-
- valueTile("temperature2", "device.temperature", decoration: "flat", inactiveLabel: false) {
- state "default", label:'${currentValue}°', icon: "st.Weather.weather2",
- backgroundColors:[
- [value: 0, color: "#153591"],
- [value: 5, color: "#1e9cbb"],
- [value: 10, color: "#90d2a7"],
- [value: 15, color: "#44b621"],
- [value: 20, color: "#f1d801"],
- [value: 25, color: "#d04e00"],
- [value: 30, color: "#bc2323"],
- [value: 44, color: "#1e9cbb"],
- [value: 59, color: "#90d2a7"],
- [value: 74, color: "#44b621"],
- [value: 84, color: "#f1d801"],
- [value: 95, color: "#d04e00"],
- [value: 96, color: "#bc2323"]
- ]
- }
-
- standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
- state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
- }
-
- main(["temperature2"])
- details(["temperature", "battery", "humidity","refresh"])
- }
-}
-
-// Parse incoming device messages to generate events
-def parse(String description) {
- log.debug "RAW: $description"
- def name = parseName(description)
- log.debug "Parsename: $name"
- def value = parseValue(description)
- log.debug "Parsevalue: $value"
- def unit = name == "temperature" ? getTemperatureScale() : (name == "humidity" ? "%" : null)
- def result = createEvent(name: name, value: value, unit: unit)
- log.debug "Evencreated: $name, $value, $unit"
- log.debug "Parse returned ${result?.descriptionText}"
- def now = new Date().format("yyyy MMM dd EEE h:mm:ss a", location.timeZone)
- sendEvent(name: "lastCheckin", value: now)
- return result
-}
-
-private String parseName(String description) {
-
- if (description?.startsWith("temperature: ")) {
- return "temperature"
-
- } else if (description?.startsWith("humidity: ")) {
- return "humidity"
-
- } else if (description?.startsWith("catchall: ")) {
- return "battery"
- }
- null
-}
-
-private String parseValue(String description) {
-
- if (description?.startsWith("temperature: ")) {
- def value = ((description - "temperature: ").trim()) as Float
-
- if (getTemperatureScale() == "C") {
- if (tempOffset) {
- return (Math.round(value * 10))/ 10 + tempOffset as Float
- } else {
- return (Math.round(value * 10))/ 10 as Float
- }
- } else {
- if (tempOffset) {
- return (Math.round(value * 90/5))/10 + 32 + offset as Float
- } else {
- return (Math.round(value * 90/5))/10 + 32 as Float
- }
- }
-
- } else if (description?.startsWith("humidity: ")) {
- def pct = (description - "humidity: " - "%").trim()
-
- if (pct.isNumber()) {
- return Math.round(new BigDecimal(pct)).toString()
- }
- } else if (description?.startsWith("catchall: ")) {
- return parseCatchAllMessage(description)
- } else {
- log.debug "unknown: $description"
- sendEvent(name: "unknown", value: description)
- }
- null
-}
-
-private String parseCatchAllMessage(String description) {
- def result = '--'
- def cluster = zigbee.parse(description)
- log.debug cluster
- if (cluster) {
- switch(cluster.clusterId) {
- case 0x0000:
- result = getBatteryResult(cluster.data.get(6))
- break
- }
- }
-
- return result
-}
-
-
-private String getBatteryResult(rawValue) {
- log.debug 'Battery'
- def linkText = getLinkText(device)
- log.debug rawValue
-
- def result = '--'
- def maxBatt = 100
- def battLevel = Math.round(rawValue * 100 / 255)
-
- if (battLevel > maxBatt) {
- battLevel = maxBatt
- }
-
- return battLevel
-}
-
-def refresh() {
- log.debug "refresh called"
- def refreshCmds = [
- "st rattr 0x${device.deviceNetworkId} 1 1 0x00", "delay 2000",
- "st rattr 0x${device.deviceNetworkId} 1 1 0x20", "delay 2000"
- ]
-
- return refreshCmds + enrollResponse()
-}
-
-def configure() {
- // Device-Watch allows 2 check-in misses from device + ping (plus 1 min lag time)
- // enrolls with default periodic reporting until newer 5 min interval is confirmed
- sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 1 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
-
- // temperature minReportTime 30 seconds, maxReportTime 5 min. Reporting interval if no activity
- // battery minReport 30 seconds, maxReportTime 6 hrs by default
- return refresh() + zigbee.batteryConfig() + zigbee.temperatureConfig(30, 900) // send refresh cmds as part of config
-}
-
-def enrollResponse() {
- log.debug "Sending enroll response"
- String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
- [
- //Resending the CIE in case the enroll request is sent before CIE is written
- "zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 2000",
- //Enroll Response
- "raw 0x500 {01 23 00 00 00}", "delay 200",
- "send 0x${device.deviceNetworkId} 1 1", "delay 2000"
- ]
-}
-
-private String swapEndianHex(String hex) {
- reverseArray(hex.decodeHex()).encodeHex()
-}
-
-private byte[] reverseArray(byte[] array) {
- int i = 0;
- int j = array.length - 1;
- byte tmp;
- while (j > i) {
- tmp = array[j];
- array[j] = array[i];
- array[i] = tmp;
- j--;
- i++;
- }
- return array
-}
\ No newline at end of file
diff --git a/devicetypes/a4refillpad/xiaomi-zigbee-button.src/xiaomi-zigbee-button.groovy b/devicetypes/a4refillpad/xiaomi-zigbee-button.src/xiaomi-zigbee-button.groovy
deleted file mode 100644
index 370aca5d..00000000
--- a/devicetypes/a4refillpad/xiaomi-zigbee-button.src/xiaomi-zigbee-button.groovy
+++ /dev/null
@@ -1,219 +0,0 @@
-/**
- * Xiaomi Zigbee Button
- *
- * 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.
- *
- * Based on original DH by Eric Maycock 2015 and Rave from Lazcad
- * change log:
- * added 100% battery max
- * fixed battery parsing problem
- * added lastcheckin attribute and tile
- * added a means to also push button in as tile on smartthings app
- * fixed ios tile label problem and battery bug
- *
- */
-metadata {
- definition (name: "Xiaomi Zigbee Button", namespace: "a4refillpad", author: "a4refillpad") {
- capability "Battery"
- capability "Button"
- capability "Holdable Button"
- capability "Actuator"
- capability "Switch"
- capability "Momentary"
- capability "Configuration"
- capability "Sensor"
- capability "Refresh"
-
- attribute "lastPress", "string"
- attribute "batterylevel", "string"
- attribute "lastCheckin", "string"
-
- fingerprint profileId: "0104", deviceId: "0104", inClusters: "0000, 0003", outClusters: "0000, 0004, 0003, 0006, 0008, 0005", manufacturer: "LUMI", model: "lumi.sensor_switch", deviceJoinName: "Xiaomi Button"
-
- }
-
- simulator {
- status "button 1 pressed": "on/off: 0"
- status "button 1 released": "on/off: 1"
- }
-
- preferences{
- input ("holdTime", "number", title: "Minimum time in seconds for a press to count as \"held\"",
- defaultValue: 4, displayDuringSetup: false)
- }
-
- tiles(scale: 2) {
-
- multiAttributeTile(name:"switch", type: "lighting", width: 6, height: 4, canChangeIcon: true) {
- tileAttribute ("device.switch", key: "PRIMARY_CONTROL") {
- attributeState("on", label:' push', action: "momentary.push", backgroundColor:"#53a7c0")
- attributeState("off", label:' push', action: "momentary.push", backgroundColor:"#ffffff", nextState: "on")
- }
- tileAttribute("device.lastCheckin", key: "SECONDARY_CONTROL") {
- attributeState("default", label:'Last Update: ${currentValue}',icon: "st.Health & Wellness.health9")
- }
- }
-
- valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
- state "battery", label:'${currentValue}% battery', unit:""
- }
- standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
- state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
- }
- standardTile("configure", "device.configure", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
- state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure"
- }
-
- main (["switch"])
- details(["switch", "battery", "refresh", "configure"])
- }
-}
-
-def parse(String description) {
- log.debug "Parsing '${description}'"
-// send event for heartbeat
- def now = new Date().format("yyyy MMM dd EEE h:mm:ss a", location.timeZone)
- sendEvent(name: "lastCheckin", value: now)
-
- def results = []
- if (description?.startsWith('on/off: '))
- results = parseCustomMessage(description)
- if (description?.startsWith('catchall:'))
- results = parseCatchAllMessage(description)
-
- return results;
-}
-
-def configure(){
- [
- "zdo bind 0x${device.deviceNetworkId} 1 2 0 {${device.zigbeeId}} {}", "delay 5000",
- "zcl global send-me-a-report 2 0 0x10 1 0 {01}", "delay 500",
- "send 0x${device.deviceNetworkId} 1 2"
- ]
-}
-
-def refresh(){
- "st rattr 0x${device.deviceNetworkId} 1 2 0"
- "st rattr 0x${device.deviceNetworkId} 1 0 0"
- log.debug "refreshing"
-
- createEvent([name: 'batterylevel', value: '100', data:[buttonNumber: 1], displayed: false])
-}
-
-private Map parseCatchAllMessage(String description) {
- Map resultMap = [:]
- def cluster = zigbee.parse(description)
- log.debug cluster
- if (cluster) {
- switch(cluster.clusterId) {
- case 0x0000:
- resultMap = getBatteryResult(cluster.data.last())
- break
-
- case 0xFC02:
- log.debug 'ACCELERATION'
- break
-
- case 0x0402:
- log.debug 'TEMP'
- // temp is last 2 data values. reverse to swap endian
- String temp = cluster.data[-2..-1].reverse().collect { cluster.hex1(it) }.join()
- def value = getTemperature(temp)
- resultMap = getTemperatureResult(value)
- break
- }
- }
-
- return resultMap
-}
-
-private Map getBatteryResult(rawValue) {
- log.debug 'Battery'
- def linkText = getLinkText(device)
-
- log.debug rawValue
-
- int battValue = rawValue
-
- def maxbatt = 100
-
- if (battValue > maxbatt) {
- battValue = maxbatt
- }
-
-
- def result = [
- name: 'battery',
- value: battValue,
- unit: "%",
- isStateChange:true,
- descriptionText : "${linkText} battery was ${battValue}%"
- ]
-
- log.debug result.descriptionText
- state.lastbatt = new Date().time
- return createEvent(result)
-}
-
-private Map parseCustomMessage(String description) {
- if (description?.startsWith('on/off: ')) {
- if (description == 'on/off: 0') //button pressed
- return createPressEvent(1)
- else if (description == 'on/off: 1') //button released
- return createButtonEvent(1)
- }
-}
-
-//this method determines if a press should count as a push or a hold and returns the relevant event type
-private createButtonEvent(button) {
- def currentTime = now()
- def startOfPress = device.latestState('lastPress').date.getTime()
- def timeDif = currentTime - startOfPress
- def holdTimeMillisec = (settings.holdTime?:3).toInteger() * 1000
-
- if (timeDif < 0)
- return [] //likely a message sequence issue. Drop this press and wait for another. Probably won't happen...
- else if (timeDif < holdTimeMillisec)
- return createEvent(name: "button", value: "pushed", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was pushed", isStateChange: true)
- else
- return createEvent(name: "button", value: "held", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was held", isStateChange: true)
-}
-
-private createPressEvent(button) {
- return createEvent([name: 'lastPress', value: now(), data:[buttonNumber: button], displayed: false])
-}
-
-//Need to reverse array of size 2
-private byte[] reverseArray(byte[] array) {
- byte tmp;
- tmp = array[1];
- array[1] = array[0];
- array[0] = tmp;
- return array
-}
-
-private String swapEndianHex(String hex) {
- reverseArray(hex.decodeHex()).encodeHex()
-}
-
-def push() {
- sendEvent(name: "switch", value: "on", isStateChange: true, displayed: false)
- sendEvent(name: "switch", value: "off", isStateChange: true, displayed: false)
- sendEvent(name: "momentary", value: "pushed", isStateChange: true)
- sendEvent(name: "button", value: "pushed", data: [buttonNumber: 1], descriptionText: "$device.displayName button 1 was pushed", isStateChange: true)
-}
-
-def on() {
- push()
-}
-
-def off() {
- push()
-}
diff --git a/devicetypes/a4refillpad/xiaomi-zigbee-outlet.src/xiaomi-zigbee-outlet.groovy b/devicetypes/a4refillpad/xiaomi-zigbee-outlet.src/xiaomi-zigbee-outlet.groovy
deleted file mode 100644
index a5f7f454..00000000
--- a/devicetypes/a4refillpad/xiaomi-zigbee-outlet.src/xiaomi-zigbee-outlet.groovy
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- *
- * 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.
- *
- * Based on on original by Lazcad / RaveTam
- * 01/2017 corrected the temperature reading
- * 02/2017 added heartbeat to monitor connectivity health of outlet
- * 02/2017 added multiattribute tile
- */
-
-metadata {
- definition (name: "Xiaomi Zigbee Outlet", namespace: "a4refillpad", author: "a4refillpad") {
- capability "Actuator"
- capability "Configuration"
- capability "Refresh"
- capability "Switch"
- capability "Temperature Measurement"
-
- attribute "lastCheckin", "string"
- }
-
- // simulator metadata
- simulator {
- // status messages
- status "on": "on/off: 1"
- status "off": "on/off: 0"
-
- // reply messages
- reply "zcl on-off on": "on/off: 1"
- reply "zcl on-off off": "on/off: 0"
- }
-
- tiles(scale: 2) {
- multiAttributeTile(name:"switch", type: "lighting", width: 6, height: 4, canChangeIcon: true){
- tileAttribute ("device.switch", key: "PRIMARY_CONTROL") {
- attributeState "on", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#00a0dc", nextState:"turningOff"
- attributeState "off", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
- attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#00a0dc", nextState:"turningOff"
- attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
- }
- tileAttribute("device.lastCheckin", key: "SECONDARY_CONTROL") {
- attributeState("default", label:'Last Update: ${currentValue}',icon: "st.Health & Wellness.health9")
- }
- }
- valueTile("temperature", "device.temperature", width: 2, height: 2) {
- state("temperature", label:'${currentValue}°',
- backgroundColors:[
- [value: 31, color: "#153591"],
- [value: 44, color: "#1e9cbb"],
- [value: 59, color: "#90d2a7"],
- [value: 74, color: "#44b621"],
- [value: 84, color: "#f1d801"],
- [value: 95, color: "#d04e00"],
- [value: 96, color: "#bc2323"]
- ]
- )
- }
- standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
- state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh"
- }
- main (["switch", "temperature"])
- details(["switch", "temperature", "refresh"])
- }
-}
-
-// Parse incoming device messages to generate events
-def parse(String description) {
- log.debug "Parsing '${description}'"
- def value = zigbee.parse(description)?.text
- log.debug "Parse: $value"
- Map map = [:]
-
- if (description?.startsWith('catchall:')) {
- map = parseCatchAllMessage(description)
- }
- else if (description?.startsWith('read attr -')) {
- map = parseReportAttributeMessage(description)
- }
- else if (description?.startsWith('on/off: ')){
- def resultMap = zigbee.getKnownDescription(description)
- log.debug "${resultMap}"
-
- map = parseCustomMessage(description)
- }
-
- log.debug "Parse returned $map"
- // send event for heartbeat
- def now = new Date()
- sendEvent(name: "lastCheckin", value: now)
-
- def results = map ? createEvent(map) : null
- return results;
-}
-
-private Map parseCatchAllMessage(String description) {
- Map resultMap = [:]
- def cluster = zigbee.parse(description)
- log.debug cluster
-
- if (cluster.clusterId == 0x0006 && cluster.command == 0x01){
- def onoff = cluster.data[-1]
- if (onoff == 1)
- resultMap = createEvent(name: "switch", value: "on")
- else if (onoff == 0)
- resultMap = createEvent(name: "switch", value: "off")
- }
-
- return resultMap
-}
-
-private Map parseReportAttributeMessage(String description) {
- Map descMap = (description - "read attr - ").split(",").inject([:]) { map, param ->
- def nameAndValue = param.split(":")
- map += [(nameAndValue[0].trim()):nameAndValue[1].trim()]
- }
- //log.debug "Desc Map: $descMap"
-
- Map resultMap = [:]
-
- if (descMap.cluster == "0001" && descMap.attrId == "0020") {
- resultMap = getBatteryResult(convertHexToInt(descMap.value / 2))
- }
- if (descMap.cluster == "0002" && descMap.attrId == "0000") {
- resultMap = createEvent(name: "temperature", value: zigbee.parseHATemperatureValue("temperature: " + (convertHexToInt(descMap.value) / 2), "temperature: ", getTemperatureScale()), unit: getTemperatureScale())
- log.debug "Temperature Hex convert to ${resultMap.value}%"
- }
- else if (descMap.cluster == "0008" && descMap.attrId == "0000") {
- resultMap = createEvent(name: "switch", value: "off")
- }
- return resultMap
-}
-
-def off() {
- log.debug "off()"
- sendEvent(name: "switch", value: "off")
- "st cmd 0x${device.deviceNetworkId} 1 6 0 {}"
-}
-
-def on() {
- log.debug "on()"
- sendEvent(name: "switch", value: "on")
- "st cmd 0x${device.deviceNetworkId} 1 6 1 {}"
-}
-
-def refresh() {
- log.debug "refreshing"
- [
- "st rattr 0x${device.deviceNetworkId} 1 6 0", "delay 500",
- "st rattr 0x${device.deviceNetworkId} 1 6 0", "delay 250",
- "st rattr 0x${device.deviceNetworkId} 1 2 0", "delay 250",
- "st rattr 0x${device.deviceNetworkId} 1 1 0", "delay 250",
- "st rattr 0x${device.deviceNetworkId} 1 0 0"
- ]
-}
-
-private Map parseCustomMessage(String description) {
- def result
- if (description?.startsWith('on/off: ')) {
- if (description == 'on/off: 0')
- result = createEvent(name: "switch", value: "off")
- else if (description == 'on/off: 1')
- result = createEvent(name: "switch", value: "on")
- }
-
- return result
-}
-
-private Integer convertHexToInt(hex) {
- Integer.parseInt(hex,16)
-}
-
diff --git a/devicetypes/bspranger/xiaomi-aqara-button-old-firmware.src/xiaomi-aqara-button-old-firmware.groovy b/devicetypes/bspranger/xiaomi-aqara-button-old-firmware.src/xiaomi-aqara-button-old-firmware.groovy
new file mode 100644
index 00000000..ca45e5f2
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-button-old-firmware.src/xiaomi-aqara-button-old-firmware.groovy
@@ -0,0 +1,462 @@
+/**
+ * Xiaomi Aqara Zigbee Button
+ * OLD Device Handler for SmartThings - Firmware versions 24.x and older ONLY
+ * for Aqara Button models WXKG11LM (original & new revision) / WXKG12LM
+ * and Aqara Wireless Smart Light Switch models WXKG02LM / WXKG03LM (original & new revision)
+ * Version 1.3.6
+ *
+ * NOTE: Do NOT use this device handler on a SmartThings v2/v3 hub with firmware 25.20 or newer
+ * Instead use either xiaomi-aqara-button.groovy for Aqara Button models WXKG11LM / WXKG12LM
+ * or xiaomi-aqara-wireless-switch.groovy for Aqara Wireless Smart Light Switch models WXKG02LM / WXKG03LM
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, veeceeoh, & xtianpaiva
+ *
+ * Notes on capabilities of the different models:
+ * Model WXKG03LM (1 button - Original revision):
+ * - Only single press is supported, sent as button 1 "pushed" event
+ * Model WXKG03LM (1 button - New revision):
+ * - Single press results in button 1 "pushed" event
+ * - Press and hold for more than 400ms results in button 1 "held" event
+ * - Double click results in button 2 "pushed" event
+ * Model WXKG02LM (2 button - Original revision):
+ * - Left, right, or both buttons pressed all result in button 1 "pushed" event
+ * This is because the SmartThings API ignores the data that distinguishes between left, right, or both-button
+ * Model WXKG02LM (2 button - New revision):
+ * - Single press of either/both buttons results in button 1 "pushed" event
+ * - Press and hold of either/both buttons for more than 400ms results in button 1 "held" event
+ * - Double click of either/both buttons results in button 2 "pushed" event
+ * - Left, right, and both button pushes are not recognized because the SmartThings API ignores the data that distinguishes between left, right, or both-button
+ * Details of button press ZigBee messages:
+ * Cluster 0012 (Multistate Input)
+ * Attribute 0055
+ * Endpoint 1 = left, 2 = right, 3 = both (ignored by SmartThings)
+ * Value 0 = hold, 1 = single, 2 = double
+ * Model WXKG11LM (original revision))
+ * - Only single press is supported, sent as button 1 "pushed" event
+ * Model WXKG11LM (new revision):
+ * - Single click results in button 1 "pushed" event
+ * - Hold for longer than 400ms results in button 1 "held" event
+ * - Double click results in button 2 "pushed" event
+ * - Single or double click results in custom "lastPressedCoRE" event for webCoRE use
+ * - Release of button results in "lastReleasedCoRE" event for webCoRE use
+ * Model WXKG12LM:
+ * - Single click results in button 1 "pushed" event
+ * - Hold for longer than 400ms results in button 1 "held" event
+ * - Double click results in button 2 "pushed" event
+ * - Shaking the button results in button 3 "pushed" event
+ * - Single or double click results in custom "lastPressedCoRE" event for webCoRE use
+ * - Release of button results in "lastReleasedCoRE" event for webCoRE use
+ *
+ * Known issues:
+ * - Xiaomi sensors do not seem to respond to refresh requests
+ * - Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * - Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub.
+ * - The battery level is not reported at pairing. Wait for the first status report, 50-60 minutes after pairing.
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Aqara Button Old Firmware", namespace: "bspranger", author: "bspranger") {
+ capability "Battery"
+ capability "Sensor"
+ capability "Button"
+ capability "Holdable Button"
+ capability "Actuator"
+ capability "Momentary"
+ capability "Configuration"
+ capability "Health Check"
+
+ attribute "lastCheckin", "string"
+ attribute "lastCheckinCoRE", "string"
+ attribute "lastHeld", "string"
+ attribute "lastHeldCoRE", "string"
+ attribute "lastPressed", "string"
+ attribute "lastPressedCoRE", "string"
+ attribute "lastReleased", "string"
+ attribute "lastReleasedCoRE", "string"
+ attribute "batteryRuntime", "string"
+ attribute "buttonStatus", "enum", ["pushed", "held", "single-clicked", "double-clicked", "shaken", "released"]
+
+ // Aqara Button - model WXKG11LM (original revision)
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,FFFF,0006", outClusters: "0000,0004,FFFF", manufacturer: "LUMI", model: "lumi.sensor_switch.aq2", deviceJoinName: "Aqara Button WXKG11LM"
+ // Aqara Button - model WXKG11LM (new revision)
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0012,0003", outClusters: "0000", manufacturer: "LUMI", model: "lumi.remote.b1acn01", deviceJoinName: "Aqara Button WXKG11LM r2"
+ // Aqara Button - model WXKG12LM
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0001,0006,0012", outClusters: "0000", manufacturer: "LUMI", model: "lumi.sensor_switch.aq3", deviceJoinName: "Aqara Button WXKG12LM"
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0001,0006,0012", outClusters: "0000", manufacturer: "LUMI", model: "lumi.sensor_swit", deviceJoinName: "Aqara Button WXKG12LM"
+ // Aqara Smart Light Switch - single button - model WXKG03LM (Original revision)
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw1lu", deviceJoinName: "Aqara Switch WXKG03LM"
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw1", deviceJoinName: "Aqara Switch WXKG03LM"
+ // Aqara Smart Light Switch - single button - model WXKG03LM (New revision)
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.remote.b186acn01", deviceJoinName: "Aqara Switch WXKG03LM r2"
+ // Aqara Smart Light Switch - dual button - model WXKG02LM (Original revision)
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw2Un", deviceJoinName: "Aqara Switch WXKG02LM"
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw2", deviceJoinName: "Aqara Switch WXKG02LM"
+ // Aqara Smart Light Switch - dual button - model WXKG02LM (New revision)
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.remote.b286acn01", deviceJoinName: "Aqara Switch WXKG02LM r2"
+
+ command "resetBatteryRuntime"
+ }
+
+ simulator {
+ status "Press button": "on/off: 0"
+ status "Release button": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"buttonStatus", type: "lighting", width: 6, height: 4, canChangeIcon: false) {
+ tileAttribute ("device.buttonStatus", key: "PRIMARY_CONTROL") {
+ attributeState("default", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("pushed", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("held", label:'Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("single-clicked", label:'Single-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("double-clicked", label:'Double-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("shaken", label:'Shaken', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("released", label:'Released', action: "momentary.push", backgroundColor:"#ffffff", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonReleased.png")
+ }
+ tileAttribute("device.lastPressed", key: "SECONDARY_CONTROL") {
+ attributeState "lastPressed", label:'Last Pressed: ${currentValue}'
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("lastCheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "lastCheckin", label:'Last Event:\n${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+ main (["buttonStatus"])
+ details(["buttonStatus","battery","lastCheckin","batteryRuntime"])
+ }
+
+ preferences {
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Advanced Settings
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ //Battery Voltage Range
+ input description: "", type: "paragraph", element: "paragraph", title: "BATTERY VOLTAGE RANGE"
+ input name: "voltsmax", type: "decimal", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", type: "decimal", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", range: "2..2.7", defaultValue: 2.5
+ //Live Logging Message Display Config
+ input description: "These settings affect the display of messages in the Live Logging tab of the SmartThings IDE.", type: "paragraph", element: "paragraph", title: "LIVE LOGGING"
+ input name: "infoLogging", type: "bool", title: "Display info log messages?", defaultValue: true
+ input name: "debugLogging", type: "bool", title: "Display debug log messages?"
+ }
+}
+
+//adds functionality to press the center tile as a virtualApp Button
+def push() {
+ displayInfoLog(": Virtual App Button Pressed")
+ sendEvent(mapButtonEvent(3))
+}
+
+// Parse incoming device messages to generate events
+def parse(description) {
+ displayDebugLog(": Parsing '${description}'")
+ def result = [:]
+
+ // Any report - button press & Battery - results in a lastCheckin event and update to Last Checkin tile
+ sendEvent(name: "lastCheckin", value: formatDate(), displayed: false)
+ sendEvent(name: "lastCheckinCoRE", value: now(), displayed: false)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith('on/off: ')) {
+ // Models WXKG11LM (original revision), WXKG02LM/WXKG03LM (original revision) - button press generates pushed event
+ updateLastPressed("Pressed")
+ result = mapButtonEvent(3)
+ } else if (description?.startsWith("read attr - raw: ")) {
+ // Parse button messages of other models, or messages on short-press of reset button
+ result = parseReadAttrMessage(description)
+ } else if (description?.startsWith('catchall:')) {
+ // Parse battery level from regular hourly announcement messages
+ result = parseCatchAllMessage(description)
+ }
+ if (result != [:]) {
+ displayDebugLog(": Creating event $result")
+ return createEvent(result)
+ } else
+ return [:]
+}
+
+private Map parseReadAttrMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ def data = ""
+ def modelName = ""
+ def model = value
+ Map resultMap = [:]
+
+ // Process models WXKG02LM/WXKG03LM (new revision), WXKG12LM, or WXKG11LM (new revision) button message
+ if (cluster == "0012")
+ resultMap = mapButtonEvent(Integer.parseInt(value[2..3],16))
+
+ // Process message containing model name and/or battery voltage report
+ if (cluster == "0000" && attrId == "0005") {
+ if (value.length() > 45) {
+ model = value.split("01FF")[0]
+ data = value.split("01FF")[1]
+ if (data[4..7] == "0121") {
+ def BatteryVoltage = (Integer.parseInt((data[10..11] + data[8..9]),16))
+ resultMap = getBatteryResult(BatteryVoltage)
+ }
+ data = ", data: ${value.split("01FF")[1]}"
+ }
+
+ // Parsing the model name
+ for (int i = 0; i < model.length(); i+=2) {
+ def str = model.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ displayDebugLog(" reported model: $modelName$data")
+ }
+ return resultMap
+}
+
+// Create map of values to be used for button events
+private mapButtonEvent(value) {
+ // Models WXKG02LM/WXKG03LM (new revision) message values: 0: hold, 1 = push, 2 = double-click,
+ // WXKG11LM (new revision) message values: 0: hold, 1 = push, 2 = double-click, 255 = release
+ // WXKG12LM message values: 1 = push, 2 = double-click, 16 = hold, 17 = release, 18 = shaken
+ def messageType = [0: "held", 1: "single-clicked", 2: "double-clicked", 3: "pushed", 16: "held", 18: "shaken"]
+ def eventType = [0: "held", 1: "pushed", 2: "pushed", 3: "pushed", 16: "held", 18: "pushed"]
+ def buttonNum = [0: 1, 1: 1, 2: 2, 3: 1, 16: 1, 18: 3]
+ if (value == 17 || value == 255) {
+ displayInfoLog(" was released")
+ updateLastPressed("Released")
+ sendEvent(name: "buttonStatus", value: "released", isStateChange: true, displayed: false)
+ return [:]
+ } else {
+ displayInfoLog(" was ${messageType[value]} (Button ${buttonNum[value]} ${eventType[value]})")
+ if (value == 0 || value == 16)
+ updateLastPressed("Held")
+ else
+ updateLastPressed("Pressed")
+ sendEvent(name: "buttonStatus", value: messageType[value], isStateChange: true, displayed: false)
+ if (eventType[value] == "pushed")
+ runIn(1, clearButtonStatus)
+ return [
+ name: 'button',
+ value: eventType[value],
+ data: [buttonNumber: buttonNum[value]],
+ descriptionText: "$device.displayName was ${messageType[value]}",
+ isStateChange: true
+ ]
+ }
+}
+
+// on any type of button pressed update lastHeld(CoRE), lastPressed(CoRE), or lastReleased(CoRE) to current date/time
+def updateLastPressed(pressType) {
+ displayDebugLog(": Setting Last $pressType to current date/time")
+ sendEvent(name: "last${pressType}", value: formatDate(), displayed: false)
+ sendEvent(name: "last${pressType}CoRE", value: now(), displayed: false)
+}
+
+def clearButtonStatus() {
+ sendEvent(name: "buttonStatus", value: "released", isStateChange: true, displayed: false)
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts = voltsmin ? voltsmin : 2.5
+ def maxVolts = voltsmax ? voltsmax : 3.0
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+ def descText = "Battery at ${roundedPct}% (${rawVolts} Volts)"
+ displayInfoLog(": $descText")
+ return [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "$device.displayName $descText"
+ ]
+}
+
+private def displayDebugLog(message) {
+ if (debugLogging)
+ log.debug "${device.displayName}${message}"
+}
+
+private def displayInfoLog(message) {
+ if (infoLogging || state.prefsSetCount < 3)
+ log.info "${device.displayName}${message}"
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: formatDate(true))
+ displayInfoLog(": Setting Battery Changed to current date${newlyPaired}")
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.prefsSetCount = 0
+ displayInfoLog(": Installing")
+ init()
+ checkIntervalEvent("")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ displayInfoLog(": Configuring")
+ init()
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ displayInfoLog(": Updating preference settings")
+ if (!state.prefsSetCount)
+ state.prefsSetCount = 1
+ else if (state.prefsSetCount < 3)
+ state.prefsSetCount = state.prefsSetCount + 1
+ init()
+ if (battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+ checkIntervalEvent("preferences updated")
+ displayInfoLog(": Info message logging enabled")
+ displayDebugLog(": Debug message logging enabled")
+}
+
+def init() {
+ clearButtonStatus()
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime(true)
+ setNumButtons()
+}
+
+def setNumButtons() {
+ if (device.getDataValue("model")) {
+ def modelName = device.getDataValue("model")
+ def modelText = "Button WXKG12LM"
+ if (!state.numButtons) {
+ if (modelName.startsWith("lumi.sensor_switch.aq2")) {
+ modelText = "Button WXKG11LM (original revision)"
+ state.numButtons = 1
+ }
+ else if (modelName.startsWith("lumi.sensor_86sw2")) {
+ modelText = "Wireless Smart Light Switch WXKG02LM - dual button (2016 model)"
+ state.numButtons = 1
+ }
+ else if (modelName.startsWith("lumi.remote.b1acn01")) {
+ modelText = "Button WXKG11LM (new revision)"
+ state.numButtons = 2
+ }
+ else if (modelName.startsWith("lumi.sensor_86sw1")) {
+ modelText = "Wireless Smart Light Switch WXKG03LM - single button (2016 model)"
+ state.numButtons = 2
+ }
+ else if (modelName.startsWith("lumi.remote.b186acn01")) {
+ modelText = "Wireless Smart Light Switch WXKG03LM - single button (2018 model)"
+ state.numButtons = 2
+ }
+ else if (modelName.startsWith("lumi.remote.b286acn01")) {
+ modelText = "Wireless Smart Light Switch WXKG02LM - dual button (2018 model)"
+ state.numButtons = 2
+ }
+ else {
+ state.numButtons = 3
+ }
+ displayInfoLog("Model is Aqara $modelText.")
+ displayInfoLog("Number of buttons set to ${state.numButtons}.")
+ sendEvent(name: "numberOfButtons", value: state.numButtons)
+ }
+ }
+ else {
+ displayInfoLog("Model is unknown, so number of buttons is set to default of 3.")
+ sendEvent(name: "numberOfButtons", value: 3)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ if (text)
+ displayInfoLog(": Set health checkInterval when ${text}")
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-aqara-button.src/xiaomi-aqara-button.groovy b/devicetypes/bspranger/xiaomi-aqara-button.src/xiaomi-aqara-button.groovy
new file mode 100644
index 00000000..3dba1438
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-button.src/xiaomi-aqara-button.groovy
@@ -0,0 +1,454 @@
+/**
+ * Aqara Button - models WXKG11LM (original & new revision) / WXKG12LM
+ * Device Handler for SmartThings - Firmware version 25.20 and newer ONLY
+ * Version 1.4.3
+ *
+ * NOTE: Do NOT use this device handler on any SmartThings hub running Firmware 24.x and older
+ * Instead use the xiaomi-aqara-button-old-firmware device handler
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger, updated for changes in firmware 25.20 by veeceeoh
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, veeceeoh, & xtianpaiva
+ *
+ * Notes on capabilities of the different models:
+ * Model WXKG11LM (original revision)
+ * - Single-click results in "button 1 pushed" event
+ * - Double-click results in "button 2 pushed" event
+ * - Triple-click results in "button 3 pushed" event
+ * - Quadruple-click results in button 4 "pushed" event
+ * - Any type of click results in custom "lastPressedCoRE" event for webCoRE use
+ * Model WXKG11LM (new revision):
+ * - Single-click results in "button 1 pushed" event
+ * - Hold for longer than 400ms results in "button 1 held" event
+ * - Double-click results in "button 2 pushed" event
+ * - Release after a hold results in "button 3 pushed" event
+ * - Single or double-click results in custom "lastPressedCoRE" event for webCoRE use
+ * - Hold results in custom "lastHeldCoRE" event for webCoRE use
+ * - Release results in custom "lastReleasedCoRE" event for webCoRE use
+ * Model WXKG12LM:
+ * - Single-click results in "button 1 pushed" event
+ * - Hold for longer than 400ms results in "button 1 held" event
+ * - Double-click results in "button 2 pushed" event
+ * - Shaking the button results in "button 3 pushed" event
+ * - Release after a hold results in "button 4 pushed" event
+ * - Single/double-click or shake results in custom "lastPressedCoRE" event for webCoRE use
+ * - Hold results in custom "lastHeldCoRE" event for webCoRE use
+ * - Release of button results in custom "lastReleasedCoRE" event for webCoRE use
+ *
+ * Known issues:
+ * - As of March 2019, the SmartThings Samsung Connect mobile app does NOT support custom device handlers such as this one
+ * - The SmartThings Classic mobile app UI text/graphics is rendered differently on iOS vs Android devices - This is due to SmartThings, not this device handler
+ * - Pairing Xiaomi/Aqara devices can be difficult as they were not designed to use with a SmartThings hub.
+ * - The battery level is not reported at pairing. Wait for the first status report, 50-60 minutes after pairing.
+ * - Xiaomi devices do not respond to refresh requests
+ * - Most ZigBee repeater devices (generally mains-powered ZigBee devices) are NOT compatible with Xiaomi/Aqara devices, causing them to drop off the network.
+ * Only XBee ZigBee modules, the IKEA Tradfri Outlet / Tradfri Bulb, and ST user @iharyadi's custom multi-sensor ZigBee repeater device are confirmed to be compatible.
+ *
+ */
+
+ import groovy.json.JsonOutput
+ import physicalgraph.zigbee.zcl.DataType
+
+metadata {
+ definition (name: "Xiaomi Aqara Button", namespace: "bspranger", author: "bspranger", minHubCoreVersion: "000.022.0002", ocfDeviceType: "x.com.st.d.remotecontroller") {
+ capability "Actuator"
+ capability "Battery"
+ capability "Button"
+ capability "Configuration"
+ capability "Health Check"
+ capability "Holdable Button"
+ capability "Momentary"
+ capability "Sensor"
+
+ attribute "lastCheckin", "string"
+ attribute "lastCheckinCoRE", "string"
+ attribute "lastHeld", "string"
+ attribute "lastHeldCoRE", "string"
+ attribute "lastPressed", "string"
+ attribute "lastPressedCoRE", "string"
+ attribute "lastReleased", "string"
+ attribute "lastReleasedCoRE", "string"
+ attribute "batteryRuntime", "string"
+ attribute "buttonStatus", "enum", ["pushed", "held", "single-clicked", "double-clicked", "triple-clicked", "quadruple-clicked", "shaken", "released"]
+
+ // Aqara Button - model WXKG11LM (original revision)
+ fingerprint deviceId: "5F01", inClusters: "0000,FFFF,0006", outClusters: "0000,0004,FFFF", manufacturer: "LUMI", model: "lumi.sensor_switch.aq2", deviceJoinName: "Aqara Button WXKG11LM"
+ // Aqara Button - model WXKG11LM (new revision)
+ fingerprint deviceId: "5F01", inClusters: "0000,0012,0003", outClusters: "0000", manufacturer: "LUMI", model: "lumi.remote.b1acn01", deviceJoinName: "Aqara Button WXKG11LM r2"
+ // Aqara Button - model WXKG12LM
+ fingerprint deviceId: "5F01", inClusters: "0000,0001,0006,0012", outClusters: "0000", manufacturer: "LUMI", model: "lumi.sensor_switch.aq3", deviceJoinName: "Aqara Button WXKG12LM"
+ fingerprint deviceId: "5F01", inClusters: "0000,0001,0006,0012", outClusters: "0000", manufacturer: "LUMI", model: "lumi.sensor_swit", deviceJoinName: "Aqara Button WXKG12LM"
+
+ command "resetBatteryRuntime"
+ }
+
+ simulator {
+ status "Press button": "on/off: 0"
+ status "Release button": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"buttonStatus", type: "lighting", width: 6, height: 4, canChangeIcon: false) {
+ tileAttribute ("device.buttonStatus", key: "PRIMARY_CONTROL") {
+ attributeState("default", label:'Single-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("held", label:'Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("single-clicked", label:'Single-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("double-clicked", label:'Double-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("triple-clicked", label:'Triple-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("quadruple-clicked", label:'Quadruple-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("shaken", label:'Shaken', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("released", label:'Released', action: "momentary.push", backgroundColor:"#ffffff", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonReleased.png")
+ }
+ tileAttribute("device.lastPressed", key: "SECONDARY_CONTROL") {
+ attributeState "lastPressed", label:'Last Pressed: ${currentValue}'
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("lastCheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "lastCheckin", label:'Last Event:\n${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+ main (["buttonStatus"])
+ details(["buttonStatus","battery","lastCheckin","batteryRuntime"])
+ }
+
+ preferences {
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Advanced Settings
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ //Battery Voltage Range
+ input description: "", type: "paragraph", element: "paragraph", title: "BATTERY VOLTAGE RANGE"
+ input name: "voltsmax", type: "decimal", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", type: "decimal", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", range: "2..2.7", defaultValue: 2.5
+ //Live Logging Message Display Config
+ input description: "These settings affect the display of messages in the Live Logging tab of the SmartThings IDE.", type: "paragraph", element: "paragraph", title: "LIVE LOGGING"
+ input name: "infoLogging", type: "bool", title: "Display info log messages?", defaultValue: true
+ input name: "debugLogging", type: "bool", title: "Display debug log messages?"
+ }
+}
+
+//adds functionality to press the center tile as a virtualApp Button
+def push() {
+ displayInfoLog(": Virtual App Button Pressed")
+ sendEvent(mapButtonEvent(1))
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ displayDebugLog(": Parsing '${description}'")
+ def result = [:]
+
+ // Any report - button press & Battery - results in a lastCheckin event and update to Last Checkin tile
+ sendEvent(name: "lastCheckin", value: formatDate(), displayed: false)
+ sendEvent(name: "lastCheckinCoRE", value: now(), displayed: false)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith('on/off: ')) {
+ // Model WXKG11LM (original revision) will produce this message on OLDER firmware prior to version 25.20
+ // This device handler is NOT designed for use on firmware 24.x or earlier
+ updateLastPressed("Pressed")
+ result = mapButtonEvent(1)
+ log.warn "It appears you may be using a SmartThings hub running firmware OLDER than 25.20"
+ log.warn "This device handler is NOT compatible with firmware 24.x or earlier"
+ log.warn "Please switch to the xiaomi-aqara-button-old-firmware device handler"
+ } else if (description?.startsWith("read attr - raw: ")) {
+ // Parse messages received on button press actions or on short-press of reset button
+ result = parseReadAttrMessage(description)
+ } else if (description?.startsWith('catchall:')) {
+ // Parse catchall message to check for battery voltage report
+ result = parseCatchAllMessage(description)
+ }
+ if (result != [:]) {
+ displayDebugLog(": Creating event $result")
+ return createEvent(result)
+ } else
+ return [:]
+}
+
+private Map parseReadAttrMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def valueHex = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ Map resultMap = [:]
+
+ if (cluster == "0006")
+ // Process model WXKG11LM (original revision)
+ resultMap = parse11LMMessage(attrId, Integer.parseInt(valueHex[0..1],16))
+ else if (cluster == "0012")
+ // Process model WXKG11LM (new revision) or WXKG12LM button messages
+ resultMap = mapButtonEvent(Integer.parseInt(valueHex[2..3],16))
+ // Process message containing model name and/or battery voltage report
+ else if (cluster == "0000" && attrId == "0005") {
+ def data = ""
+ def modelName = ""
+ def model = valueHex
+ if (valueHex.length() > 45) {
+ model = valueHex.split("01FF")[0]
+ data = valueHex.split("01FF")[1]
+ if (data[4..7] == "0121") {
+ def BatteryVoltage = (Integer.parseInt((data[10..11] + data[8..9]),16))
+ resultMap = getBatteryResult(BatteryVoltage)
+ }
+ data = ", data: ${valueHex.split("01FF")[1]}"
+ }
+
+ // Parsing the model name
+ for (int i = 0; i < model.length(); i+=2) {
+ def str = model.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ displayDebugLog(" reported model: $modelName$data")
+ }
+ return resultMap
+}
+
+// Parse WXKG11LM (original revision) button message: press, double-click, triple-click, & quad-click
+private parse11LMMessage(attrId, value) {
+ def messageType = [1: "single-clicked", 2: "double-clicked", 3: "triple-clicked", 4: "quadruple-clicked"]
+ def result = [:]
+ value = (attrId == "0000") ? 1 : value
+ displayDebugLog(": attrID = $attrId, value = $value")
+ if (value <= 4) {
+ def descText = " was ${messageType[value]} (Button $value pushed)"
+ sendEvent(name: "buttonStatus", value: messageType[value], isStateChange: true, displayed: false)
+ runIn(1, clearButtonStatus)
+ updateLastPressed("Pressed")
+ displayInfoLog(descText)
+ result = [
+ name: 'button',
+ value: "pushed",
+ data: [buttonNumber: value],
+ isStateChange: true,
+ descriptionText: "$device.displayName$descText"
+ ]
+ } else
+ displayDebugLog(": Button press message is unrecognized")
+ return result
+}
+
+// Create map of values to be used for button events
+private mapButtonEvent(value) {
+ // WXKG11LM (new revision) message values: 0: hold, 1 = push, 2 = double-click, 255 = release
+ // WXKG12LM message values: 1 = push, 2 = double-click, 16 = hold, 17 = release, 18 = shaken
+ def messageType = [0: "held", 1: "single-clicked", 2: "double-clicked", 16: "held", 17: "released", 18: "shaken", 255: "released"]
+ def eventType = [0: "held", 1: "pushed", 2: "pushed", 16: "held", 17: "pushed", 18: "pushed", 255: "pushed"]
+ def buttonNum = [0: 1, 1: 1, 2: 2, 16: 1, 17: 4, 18: 3, 255: 3]
+ if (value == 17 || value == 255) {
+ updateLastPressed("Released")
+ } else if (value == 0 || value == 16) {
+ updateLastPressed("Held")
+ } else if (value <= 18) {
+ updateLastPressed("Pressed")
+ if (eventType[value] == "pushed")
+ runIn(1, clearButtonStatus)
+ } else {
+ displayDebugLog(": Button press message is unrecognized")
+ return [:]
+ }
+ def descText = " was ${messageType[value]} (Button ${buttonNum[value]} ${eventType[value]})"
+ displayInfoLog(descText)
+ sendEvent(name: "buttonStatus", value: messageType[value], isStateChange: true, displayed: false)
+ return [
+ name: 'button',
+ value: eventType[value],
+ data: [buttonNumber: buttonNum[value]],
+ descriptionText: "$device.displayName$descText",
+ isStateChange: true
+ ]
+}
+
+// on any type of button pressed update lastHeld(CoRE), lastPressed(CoRE), or lastReleased(CoRE) to current date/time
+def updateLastPressed(pressType) {
+ displayDebugLog(": Setting Last $pressType to current date/time")
+ sendEvent(name: "last${pressType}", value: formatDate(), displayed: false)
+ sendEvent(name: "last${pressType}CoRE", value: now(), displayed: false)
+}
+
+def clearButtonStatus() {
+ sendEvent(name: "buttonStatus", value: "released", isStateChange: true, displayed: false)
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parseDescriptionAsMap(description)
+ //displayDebugLog(": Zigbee parse of catchall = $catchall")
+ //displayDebugLog(": Length of data payload = ${catchall.value.size()}")
+ // Parse battery voltage data from catchall messages with payload value data larger than 10 bytes
+ if ((catchall.attrId == "0005" || catchall.attrId == "FF01") && catchall.value.size() > 20) {
+ // Battery voltage value is sent as INT16 in two bytes, #6 & #7, in large-endian (reverse) order
+ def batteryString = catchall.data[7] + catchall.data[6]
+ if (catchall.additionalAttrs && catchall.additionalAttrs.attrId[0] == "ff01")
+ batteryString = catchall.unparsedData[7] + catchall.unparsedData[6]
+ displayDebugLog(": Parsing battery voltage string $batteryString")
+ resultMap = getBatteryResult(Integer.parseInt(batteryString,16))
+ }
+ else
+ displayDebugLog(": Catchall message does not contain usable data, no action taken")
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts = voltsmin ? voltsmin : 2.5
+ def maxVolts = voltsmax ? voltsmax : 3.0
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+ def descText = "Battery at ${roundedPct}% (${rawVolts} Volts)"
+ displayInfoLog(": $descText")
+ return [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "$device.displayName $descText"
+ ]
+}
+
+private def displayDebugLog(message) {
+ if (debugLogging)
+ log.debug "${device.displayName}${message}"
+}
+
+private def displayInfoLog(message) {
+ if (infoLogging || state.prefsSetCount < 3)
+ log.info "${device.displayName}${message}"
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: formatDate(true))
+ displayInfoLog(": Setting Battery Changed to current date${newlyPaired}")
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.prefsSetCount = 0
+ displayInfoLog(": Installing")
+ checkIntervalEvent("")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ displayInfoLog(": Configuring")
+ initialize()
+ device.currentValue("numberOfButtons")?.times {
+ sendEvent(name: "button", value: "pushed", data: [buttonNumber: it+1], displayed: false)
+ }
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ displayInfoLog(": Updating preference settings")
+ if (!state.prefsSetCount)
+ state.prefsSetCount = 1
+ else if (state.prefsSetCount < 3)
+ state.prefsSetCount = state.prefsSetCount + 1
+ initialize()
+ if (battReset) {
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+ checkIntervalEvent("preferences updated")
+ displayInfoLog(": Info message logging enabled")
+ displayDebugLog(": Debug message logging enabled")
+}
+
+def initialize() {
+ sendEvent(name: "DeviceWatch-Enroll", value: JsonOutput.toJson([protocol: "zigbee", scheme:"untracked"]), displayed: false)
+ clearButtonStatus()
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime(true)
+ if (!state.numButtons)
+ setNumButtons()
+}
+
+def setNumButtons() {
+ if (device.getDataValue("model")) {
+ def modelName = device.getDataValue("model")
+ def modelText = "Button WXKG12LM"
+ state.numButtons = 4
+ if (modelName.startsWith("lumi.sensor_switch.aq2")) {
+ modelText = "Button WXKG11LM (original revision)"
+ } else if (modelName.startsWith("lumi.remote.b1acn01")) {
+ modelText = "Button WXKG11LM (new revision)"
+ state.numButtons = 3
+ }
+ displayInfoLog(": Model is Aqara $modelText.")
+ displayInfoLog(": Number of buttons set to ${state.numButtons}.")
+ sendEvent(name: "numberOfButtons", value: state.numButtons)
+ } else {
+ displayInfoLog(": Model is unknown, so number of buttons is set to default of 4.")
+ sendEvent(name: "numberOfButtons", value: 4)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ if (text)
+ displayInfoLog(": Set health checkInterval when ${text}")
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-aqara-curtain-motor.src/Placeholder.txt b/devicetypes/bspranger/xiaomi-aqara-curtain-motor.src/Placeholder.txt
new file mode 100644
index 00000000..0f5e6698
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-curtain-motor.src/Placeholder.txt
@@ -0,0 +1 @@
+Future home of Aqara Curtain Motor device driver.
\ No newline at end of file
diff --git a/devicetypes/bspranger/xiaomi-aqara-door-window-sensor.src/xiaomi-aqara-door-window-sensor.groovy b/devicetypes/bspranger/xiaomi-aqara-door-window-sensor.src/xiaomi-aqara-door-window-sensor.groovy
new file mode 100644
index 00000000..6605b1c4
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-door-window-sensor.src/xiaomi-aqara-door-window-sensor.groovy
@@ -0,0 +1,313 @@
+/**
+ * Xiaomi Aqara Door/Window Sensor
+ * Version 1.1
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub. See
+ *
+ */
+metadata {
+ definition (name: "Xiaomi Aqara Door/Window Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Configuration"
+ capability "Sensor"
+ capability "Contact Sensor"
+ capability "Battery"
+ capability "Health Check"
+
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinDate", "Date"
+ attribute "lastOpened", "String"
+ attribute "lastOpenedDate", "Date"
+ attribute "batteryRuntime", "String"
+
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,FFFF,0006", outClusters: "0000,0004,FFFF", manufacturer: "LUMI", model: "lumi.sensor_magnet.aq2", deviceJoinName: "Xiaomi Aqara Door Sensor"
+
+ command "resetBatteryRuntime"
+ command "resetClosed"
+ command "resetOpen"
+ }
+
+ simulator {
+ status "closed": "on/off: 0"
+ status "open": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"contact", type: "generic", width: 6, height: 4) {
+ tileAttribute("device.contact", key: "PRIMARY_CONTROL") {
+ attributeState "open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#e86d13"
+ attributeState "closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#00a0dc"
+ }
+ tileAttribute("device.lastOpened", key: "SECONDARY_CONTROL") {
+ attributeState("default", label:'Last Opened: ${currentValue}')
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("spacer", "spacer", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("lastcheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "default", label:'Last Event:\n${currentValue}'
+ }
+ standardTile("resetClosed", "device.resetClosed", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetClosed", label:'Override Close', icon:"st.contact.contact.closed"
+ }
+ standardTile("resetOpen", "device.resetOpen", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetOpen", label:'Override Open', icon:"st.contact.contact.open"
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed:\n ${currentValue}'
+ }
+
+ main (["contact"])
+ details(["contact","battery","resetClosed","resetOpen","spacer","lastcheckin", "spacer", "spacer", "batteryRuntime", "spacer"])
+ }
+ preferences {
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\n US (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3, required: false
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5, required: false
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ def result = zigbee.getEvent(description)
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+ // Any report - contact sensor & Battery - results in a lastCheckin event and update to Last Checkin tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ Map map = [:]
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (result) {
+ log.debug "${device.displayName} Event: ${result}"
+ map = getContactResult(result)
+ if (map.value == "open") {
+ sendEvent(name: "lastOpened", value: now, displayed: false)
+ sendEvent(name: "lastOpenedDate", value: nowDate, displayed: false)
+ }
+ } else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ } else if (description?.startsWith('read attr - raw:')) {
+ map = parseReadAttr(description)
+ }
+
+ log.debug "${device.displayName}: Parse returned ${map}"
+ def results = map ? createEvent(map) : null
+ return results
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+
+ def minVolts
+ def maxVolts
+
+ if(voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if(voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ log.debug "${device.displayName}: ${result}"
+ return result
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Parse raw data on reset button press to retrieve reported battery voltage
+private Map parseReadAttr(String description) {
+ log.debug "${device.displayName}: reset button press detected"
+ def buttonRaw = (description - "read attr - raw:")
+ Map resultMap = [:]
+
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ def model = value.split("01FF")[0]
+ def data = value.split("01FF")[1]
+
+ if (cluster == "0000" && attrId == "0005") {
+ def modelName = ""
+ // Parsing the model
+ for (int i = 0; i < model.length(); i+=2)
+ {
+ def str = model.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ log.debug "${device.displayName} reported: cluster: ${cluster}, attrId: ${attrId}, value: ${value}, model:${modelName}, data:${data}"
+ }
+ if (data[4..7] == "0121") {
+ resultMap = getBatteryResult(Integer.parseInt((data[10..11] + data[8..9]),16))
+ }
+ return resultMap
+}
+
+private Map getContactResult(result) {
+ def value = result.value == "on" ? "open" : "closed"
+ def descriptionText = "${device.displayName} was ${value == "open" ? value + "ed" : value}"
+ return [
+ name: 'contact',
+ value: value,
+ isStateChange: true,
+ descriptionText: descriptionText
+ ]
+}
+
+def resetClosed() {
+ sendEvent(name: "contact", value: "closed", descriptionText: "${device.displayName} was manually reset to closed")
+}
+
+def resetOpen() {
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+ sendEvent(name: "lastOpened", value: now, displayed: false)
+ sendEvent(name: "lastOpenedDate", value: nowDate, displayed: false)
+ sendEvent(name: "contact", value: "open", descriptionText: "${device.displayName} was manually reset to open")
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("installed")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated")
+ if(battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-aqara-leak-sensor.src/xiaomi-aqara-leak-sensor.groovy b/devicetypes/bspranger/xiaomi-aqara-leak-sensor.src/xiaomi-aqara-leak-sensor.groovy
new file mode 100644
index 00000000..ba5e7e2e
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-leak-sensor.src/xiaomi-aqara-leak-sensor.groovy
@@ -0,0 +1,324 @@
+/**
+ * Xiaomi Aqara Leak Sensor
+ * Version 1.1
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub. See
+ *
+ */
+metadata {
+ definition (name: "Xiaomi Aqara Leak Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Configuration"
+ capability "Sensor"
+ capability "Water Sensor"
+ capability "Battery"
+ capability "Health Check"
+
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinDate", "Date"
+ attribute "lastWet", "String"
+ attribute "lastWetDate", "Date"
+ attribute "batteryRuntime", "String"
+
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "0402", inClusters: "0000,0003,0001", outClusters: "0019", manufacturer: "LUMI", model: "lumi.sensor_wleak.aq1", deviceJoinName: "Xiaomi Leak Sensor"
+
+ command "resetDry"
+ command "resetWet"
+ command "resetBatteryRuntime"
+ }
+
+ simulator {
+ status "dry": "on/off: 0"
+ status "wet": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"water", type: "generic", width: 6, height: 4) {
+ tileAttribute("device.water", key: "PRIMARY_CONTROL") {
+ attributeState "dry", label:'Dry', icon:"st.alarm.water.dry", backgroundColor:"#ffffff"
+ attributeState "wet", label:'Wet', icon:"st.alarm.water.wet", backgroundColor:"#00a0dc"
+ }
+ tileAttribute("device.lastWet", key: "SECONDARY_CONTROL") {
+ attributeState "default", label:'Last Wet: ${currentValue}'
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("spacer", "spacer", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("lastcheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "default", label:'Last Event:\n${currentValue}'
+ }
+ standardTile("resetWet", "device.resetWet", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetWet", label:'Override Wet', icon:"st.alarm.water.wet"
+ }
+ standardTile("resetDry", "device.resetDry", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetDry", label:'Override Dry', icon:"st.alarm.water.dry"
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed:\n ${currentValue}'
+ }
+
+ main (["water"])
+ details(["water","battery","resetDry","resetWet", "spacer", "lastcheckin", "spacer", "spacer","batteryRuntime", "spacer"])
+ }
+ preferences {
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\n US (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3, required: false
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5, required: false
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName} Description:${description}"
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+ // Any report - wet sensor & Battery - results in a lastCheckin event and update to Last Checkin tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ Map map = [:]
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith('zone status')) {
+ map = parseZoneStatusMessage(description)
+ if (map.value == "wet") {
+ sendEvent(name: "lastWet", value: now, displayed: false)
+ sendEvent(name: "lastWetDate", value: nowDate, displayed: false)
+ }
+ } else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ } else if (description?.startsWith('read attr - raw:')) {
+ map = parseReadAttr(description)
+ }
+
+ log.debug "${device.displayName}: Parse returned ${map}"
+ def results = map ? createEvent(map) : null
+ return results
+}
+
+private Map parseZoneStatusMessage(String description) {
+ def result = [
+ name: 'water',
+ value: value,
+ descriptionText: 'water contact'
+ ]
+ if (description?.startsWith('zone status')) {
+ if (description?.startsWith('zone status 0x0001')) { // detected water
+ result.value = "wet"
+ result.descriptionText = "${device.displayName} has detected water"
+ } else if (description?.startsWith('zone status 0x0000')) { // did not detect water
+ result.value = "dry"
+ result.descriptionText = "${device.displayName} is dry"
+ }
+ return result
+ }
+
+ return [:]
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts
+ def maxVolts
+
+ if(voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if(voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ log.debug "${device.displayName}: ${result}"
+ return result
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Parse raw data on reset button press to retrieve reported battery voltage
+private Map parseReadAttr(String description) {
+ def buttonRaw = (description - "read attr - raw:")
+ Map resultMap = [:]
+
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ def model = value.split("01FF")[0]
+ def data = value.split("01FF")[1]
+ //log.debug "cluster: ${cluster}, attrId: ${attrId}, value: ${value}, model:${model}, data:${data}"
+
+ if (data[4..7] == "0121") {
+ def MaxBatteryVoltage = (Integer.parseInt((data[10..11] + data[8..9]),16))/1000
+ state.maxBatteryVoltage = MaxBatteryVoltage
+ }
+
+ if (cluster == "0000" && attrId == "0005") {
+ resultMap.name = 'Model'
+ resultMap.value = ""
+ resultMap.descriptionText = "device model"
+ // Parsing the model
+ for (int i = 0; i < model.length(); i+=2) {
+ def str = model.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ resultMap.value = resultMap.value + NextChar
+ }
+ return resultMap
+ }
+
+ return [:]
+}
+
+def resetDry() {
+ sendEvent(name:"water", value:"dry")
+}
+
+def resetWet() {
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+ sendEvent(name:"water", value:"wet")
+ sendEvent(name: "lastWet", value: now, displayed: false)
+ sendEvent(name: "lastWetDate", value: nowDate, displayed: false)
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("installed")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated")
+ if(battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-aqara-motion-sensor.src/xiaomi-aqara-motion-sensor.groovy b/devicetypes/bspranger/xiaomi-aqara-motion-sensor.src/xiaomi-aqara-motion-sensor.groovy
new file mode 100644
index 00000000..1af26db6
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-motion-sensor.src/xiaomi-aqara-motion-sensor.groovy
@@ -0,0 +1,330 @@
+/**
+ * Xiaomi Aqara Motion Sensor
+ * Version 1.1
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub. See
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Aqara Motion Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Motion Sensor"
+ capability "Illuminance Measurement"
+ capability "Configuration"
+ capability "Battery"
+ capability "Sensor"
+ capability "Health Check"
+
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinDate", "String"
+ attribute "lastMotion", "String"
+ attribute "light", "number"
+ attribute "batteryRuntime", "String"
+
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "0107", inClusters: "0000,FFFF,0406,0400,0500,0001,0003", outClusters: "0000,0019", manufacturer: "LUMI", model: "lumi.sensor_motion.aq2", deviceJoinName: "Xiaomi Aqara Motion Sensor"
+ fingerprint profileId: "0104", deviceId: "0104", inClusters: "0000, 0400, 0406, FFFF", outClusters: "0000, 0019", manufacturer: "LUMI", model: "lumi.sensor_motion", deviceJoinName: "Xiaomi Aqara Motion Sensor"
+
+ command "resetBatteryRuntime"
+ command "stopMotion"
+ }
+
+ simulator {
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"motion", type:"generic", width: 6, height: 4) {
+ tileAttribute ("device.motion", key: "PRIMARY_CONTROL") {
+ attributeState "active", label:'Motion', icon:"st.motion.motion.active", backgroundColor:"#00a0dc"
+ attributeState "inactive", label:'No Motion', icon:"st.motion.motion.inactive", backgroundColor:"#ffffff"
+ }
+ tileAttribute("device.lastMotion", key: "SECONDARY_CONTROL") {
+ attributeState("default", label:'Last Motion: ${currentValue}')
+ }
+ }
+ valueTile("battery", "device.battery", decoration:"flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("spacer", "spacer", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("illuminance", "device.illuminance", decoration:"flat", inactiveLabel: false, width: 2, height: 2) {
+ state "default", label:'${currentValue}\nlux', unit:"lux",
+ backgroundColors: [
+ [value:0, color:"#000000"],
+ [value:1, color:"#07212c"],
+ [value:25, color:"#0f4357"],
+ [value:50, color:"#12536d"],
+ [value:100, color:"#1a7599"],
+ [value:150, color:"#2196c4"],
+ [value:250, color:"#3bb0de"],
+ [value:500, color:"#51b8e1"],
+ [value:750, color:"#66c1e5"],
+ [value:1000, color:"#7ccae9"],
+ [value:1500, color: "#92d3ed"]
+ ]
+ }
+ standardTile("reset", "device.reset", inactiveLabel: false, decoration:"flat", width: 2, height: 2) {
+ state "default", action:"stopMotion", label:'Reset Motion', icon:"st.motion.motion.active"
+ }
+ valueTile("lastcheckin", "device.lastCheckin", decoration:"flat", inactiveLabel: false, width: 4, height: 1) {
+ state "default", label:'Last Event:\n ${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration:"flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed:\n ${currentValue}'
+ }
+ main(["motion"])
+ details(["motion", "battery", "illuminance", "reset", "spacer", "lastcheckin", "spacer", "spacer", "batteryRuntime", "spacer"])
+ }
+
+ preferences {
+ //Reset to No Motion Config
+ input description: "This setting only changes how long MOTION DETECTED is reported in SmartThings. The sensor hardware always remains blind to motion for 60 seconds after any activity.", type: "paragraph", element: "paragraph", title: "MOTION RESET"
+ input "motionreset", "number", title: "", description: "Enter number of seconds (default = 60)", range: "1..7200"
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\n US (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName} parsing: $description"
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+
+ // Any report - motion, lux & Battery - results in a lastCheckin event and update to Last Event tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ Map map = [:]
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith('illuminance:')) {
+ map = parseIlluminance(description)
+ }
+ else if (description?.startsWith('read attr -')) {
+ map = parseReportAttributeMessage(description)
+ }
+ else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ }
+
+ log.debug "${device.displayName} parse returned: $map"
+ def result = map ? createEvent(map) : null
+
+ return result
+}
+
+// Parse illuminance report
+private Map parseIlluminance(String description) {
+ def lux = ((description - "illuminance: ").trim()) as int
+
+ def result = [
+ name: 'illuminance',
+ value: lux,
+ unit: "lux",
+ isStateChange: true,
+ descriptionText : "${device.displayName} illuminance was ${lux} lux"
+ ]
+ return result
+}
+// Parse motion active report or model name message on reset button press
+private Map parseReportAttributeMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+
+ Map resultMap = [:]
+ def now = formatDate()
+
+ // The sensor only sends a motion detected message so the reset to no motion is performed in code
+ if (cluster == "0406" & value == "01") {
+ log.debug "${device.displayName} detected motion"
+ def seconds = motionreset ? motionreset : 120
+ resultMap = [
+ name: 'motion',
+ value: 'active',
+ descriptionText: "${device.displayName} detected motion"
+ ]
+ sendEvent(name: "lastMotion", value: now, displayed: false)
+ runIn(seconds, stopMotion)
+ }
+ else if (cluster == "0000" && attrId == "0005") {
+ def modelName = ""
+ // Parsing the model
+ for (int i = 0; i < value.length(); i+=2) {
+ def str = value.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ log.debug "${device.displayName} reported: cluster: ${cluster}, attrId: ${attrId}, model:${modelName}"
+ }
+ return resultMap
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts
+ def maxVolts
+
+ if (voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if (voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange: true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ return result
+}
+
+// If currently in 'active' motion detected state, stopMotion() resets to 'inactive' state and displays 'no motion'
+def stopMotion() {
+ if (device.currentState('motion')?.value == "active") {
+ def seconds = motionreset ? motionreset : 120
+ sendEvent(name:"motion", value:"inactive", isStateChange: true)
+ log.debug "${device.displayName} reset to no motion after ${seconds} seconds"
+ }
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("installed")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated")
+ if(battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-aqara-temperature-humidity-sensor.src/xiaomi-aqara-temperature-humidity-sensor.groovy b/devicetypes/bspranger/xiaomi-aqara-temperature-humidity-sensor.src/xiaomi-aqara-temperature-humidity-sensor.groovy
new file mode 100644
index 00000000..6cdbbe50
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-temperature-humidity-sensor.src/xiaomi-aqara-temperature-humidity-sensor.groovy
@@ -0,0 +1,451 @@
+/**
+ * Xiaomi Aqara Temperature Humidity Sensor
+ * Version 1.3
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, cscheiene, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub. See
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Aqara Temperature Humidity Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Temperature Measurement"
+ capability "Relative Humidity Measurement"
+ capability "Sensor"
+ capability "Battery"
+ capability "Health Check"
+
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinDate", "String"
+ attribute "maxTemp", "number"
+ attribute "minTemp", "number"
+ attribute "maxHumidity", "number"
+ attribute "minHumidity", "number"
+ attribute "multiAttributesReport", "String"
+ attribute "currentDay", "String"
+ attribute "batteryRuntime", "String"
+
+ fingerprint profileId: "0104", deviceId: "5F01", inClusters: "0000, 0003, FFFF, 0402, 0403, 0405", outClusters: "0000, 0004, FFFF", manufacturer: "LUMI", model: "lumi.weather", deviceJoinName: "Xiaomi Aqara Temp Sensor"
+
+ command "resetBatteryRuntime"
+ }
+
+ // simulator metadata
+ simulator {
+ for (int i = 0; i <= 100; i += 10) {
+ status "${i}F": "temperature: $i F"
+ }
+
+ for (int i = 0; i <= 100; i += 10) {
+ status "${i}%": "humidity: ${i}%"
+ }
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"temperature", type:"generic", width:6, height:4) {
+ tileAttribute("device.temperature", key: "PRIMARY_CONTROL") {
+ attributeState("temperature", label:'${currentValue}°',
+ backgroundColors:[
+ [value: 31, color: "#153591"],
+ [value: 44, color: "#1e9cbb"],
+ [value: 59, color: "#90d2a7"],
+ [value: 74, color: "#44b621"],
+ [value: 84, color: "#f1d801"],
+ [value: 95, color: "#d04e00"],
+ [value: 96, color: "#bc2323"]
+ ]
+ )
+ }
+ tileAttribute("device.multiAttributesReport", key: "SECONDARY_CONTROL") {
+ attributeState("multiAttributesReport", label:'${currentValue}' //icon:"st.Weather.weather12",
+ )
+ }
+ }
+ valueTile("temperature2", "device.temperature", inactiveLabel: false) {
+ state "temperature", label:'${currentValue}°', icon:"st.Weather.weather2",
+ backgroundColors:[
+ [value: 31, color: "#153591"],
+ [value: 44, color: "#1e9cbb"],
+ [value: 59, color: "#90d2a7"],
+ [value: 74, color: "#44b621"],
+ [value: 84, color: "#f1d801"],
+ [value: 95, color: "#d04e00"],
+ [value: 96, color: "#bc2323"]
+ ]
+ }
+ valueTile("humidity", "device.humidity", inactiveLabel: false, width: 2, height: 2) {
+ state "humidity", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiHumidity.png",
+ backgroundColors:[
+ [value: 0, color: "#FFFCDF"],
+ [value: 4, color: "#FDF789"],
+ [value: 20, color: "#A5CF63"],
+ [value: 23, color: "#6FBD7F"],
+ [value: 56, color: "#4CA98C"],
+ [value: 59, color: "#0072BB"],
+ [value: 76, color: "#085396"]
+ ]
+ }
+ standardTile("pressure", "device.pressure", inactiveLabel: false, decoration:"flat", width: 2, height: 2) {
+ state "pressure", label:'${currentValue}', icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiPressure.png"
+ }
+ valueTile("battery", "device.battery", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("spacer", "spacer", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("lastcheckin", "device.lastCheckin", inactiveLabel: false, decoration:"flat", width: 4, height: 1) {
+ state "lastcheckin", label:'Last Event:\n ${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration:"flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+
+ main("temperature2")
+ details(["temperature", "battery", "pressure", "humidity", "spacer", "lastcheckin", "spacer", "spacer", "batteryRuntime", "spacer"])
+ }
+ preferences {
+ //Button Config
+ input description: "The settings below customize additional infomation displayed in the main tile.", type: "paragraph", element: "paragraph", title: "MAIN TILE DISPLAY"
+ input name: "displayTempInteger", type: "bool", title: "Display temperature as integer?", description:"NOTE: Takes effect on the next temperature report. High/Low temperatures are always displayed as integers."
+ input name: "displayTempHighLow", type: "bool", title: "Display high/low temperature?"
+ input name: "displayHumidHighLow", type: "bool", title: "Display high/low humidity?"
+ //Temp, Humidity, and Pressure Offsets and Pressure Units
+ input description: "The settings below allow correction of variations in temperature, humidity, and pressure by setting an offset. Examples: If the sensor consistently reports temperature 5 degrees too warm, enter '-5' for the Temperature Offset. If it reports humidity 3% too low, enter ‘3' for the Humidity Offset. NOTE: Changes will take effect on the NEXT temperature / humidity / pressure report.", type: "paragraph", element: "paragraph", title: "OFFSETS & UNITS"
+ input "tempOffset", "decimal", title:"Temperature Offset", description:"Adjust temperature by this many degrees", range:"*..*"
+ input "humidOffset", "number", title:"Humidity Offset", description:"Adjust humidity by this many percent", range: "*..*"
+ input "pressOffset", "number", title:"Pressure Offset", description:"Adjust pressure by this many units", range: "*..*"
+ input name:"PressureUnits", type:"enum", title:"Pressure Units", options:["mbar", "kPa", "inHg", "mmHg"], description:"Sets the unit in which pressure will be reported"
+ input description: "NOTE: The temperature unit (C / F) can be changed in the location settings for your hub.", type: "paragraph", element: "paragraph", title: ""
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?", description: ""
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts.\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing)\nat __ volts. Range 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName}: Parsing description: ${description}"
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+
+ // Any report - temp, humidity, pressure, & battery - results in a lastCheckin event and update to Last Checkin tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ // Check if the min/max temp and min/max humidity should be reset
+ checkNewDay(now)
+
+ // getEvent automatically retrieves temp and humidity in correct unit as integer
+ Map map = zigbee.getEvent(description)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (map.name == "temperature") {
+ def temp = parseTemperature(description)
+ map.value = displayTempInteger ? (int) temp : temp
+ map.descriptionText = "${device.displayName} temperature is ${map.value}°${temperatureScale}"
+ map.translatable = true
+ updateMinMaxTemps(map.value)
+ } else if (map.name == "humidity") {
+ map.value = humidOffset ? (int) map.value + (int) humidOffset : (int) map.value
+ updateMinMaxHumidity(map.value)
+ } else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ } else if (description?.startsWith('read attr - raw:')) {
+ map = parseReadAttr(description)
+ } else {
+ log.debug "${device.displayName}: was unable to parse ${description}"
+ sendEvent(name: "lastCheckin", value: now)
+ }
+
+ if (map) {
+ log.debug "${device.displayName}: Parse returned ${map}"
+ return createEvent(map)
+ } else
+ return [:]
+}
+
+// Calculate temperature with 0.1 precision in C or F unit as set by hub location settings
+private parseTemperature(String description) {
+ def temp = ((description - "temperature: ").trim()) as Float
+ def offset = tempOffset ? tempOffset : 0
+ temp = (temp > 100) ? (100 - temp) : temp
+ temp = (temperatureScale == "F") ? ((temp * 1.8) + 32) + offset : temp + offset
+ return temp.round(1)
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Original Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Parse pressure report or battery report on reset button press
+private Map parseReadAttr(String description) {
+ Map resultMap = [:]
+
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+
+ // log.debug "${device.displayName}: Parsing read attr: cluster: ${cluster}, attrId: ${attrId}, value: ${value}"
+
+ if ((cluster == "0403") && (attrId == "0000")) {
+ def result = value[0..3]
+ float pressureval = Integer.parseInt(result, 16)
+
+ if (!(settings.PressureUnits)){
+ settings.PressureUnits = "mbar"
+ }
+ // log.debug "${device.displayName}: Converting ${pressureval} to ${PressureUnits}"
+
+ switch (PressureUnits) {
+ case "mbar":
+ pressureval = (pressureval/10) as Float
+ pressureval = pressureval.round(1);
+ break;
+
+ case "kPa":
+ pressureval = (pressureval/100) as Float
+ pressureval = pressureval.round(2);
+ break;
+
+ case "inHg":
+ pressureval = (((pressureval/10) as Float) * 0.0295300)
+ pressureval = pressureval.round(2);
+ break;
+
+ case "mmHg":
+ pressureval = (((pressureval/10) as Float) * 0.750062)
+ pressureval = pressureval.round(2);
+ break;
+ }
+ // log.debug "${device.displayName}: Pressure is ${pressureval} ${PressureUnits} before applying the pressure offset."
+
+ if (settings.pressOffset) {
+ pressureval = (pressureval + settings.pressOffset)
+ }
+
+ pressureval = pressureval.round(2);
+
+ resultMap = [
+ name: 'pressure',
+ value: pressureval,
+ unit: "${PressureUnits}",
+ isStateChange: true,
+ descriptionText : "${device.displayName} Pressure is ${pressureval} ${PressureUnits}"
+ ]
+ } else if (cluster == "0000" && attrId == "0005") {
+ def modelName = ""
+
+ // Parsing the model name
+ for (int i = 0; i < value.length(); i+=2) {
+ def str = value.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ log.debug "${device.displayName}: Reported model: ${modelName}"
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts
+ def maxVolts
+
+ if(voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if(voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange: true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ return result
+}
+
+// If the day of month has changed from that of previous event, reset the daily min/max temp values
+def checkNewDay(now) {
+ def oldDay = ((device.currentValue("currentDay")) == null) ? "32" : (device.currentValue("currentDay"))
+ def newDay = new Date(now).format("dd")
+ if (newDay != oldDay) {
+ resetMinMax()
+ sendEvent(name: "currentDay", value: newDay, displayed: false)
+ }
+}
+
+// Reset daily min/max temp and humidity values to the current temp/humidity values
+def resetMinMax() {
+ def currentTemp = device.currentValue('temperature')
+ def currentHumidity = device.currentValue('humidity')
+ currentTemp = currentTemp ? (int) currentTemp : currentTemp
+ log.debug "${device.displayName}: Resetting daily min/max values to current temperature of ${currentTemp}° and humidity of ${currentHumidity}%"
+ sendEvent(name: "maxTemp", value: currentTemp, displayed: false)
+ sendEvent(name: "minTemp", value: currentTemp, displayed: false)
+ sendEvent(name: "maxHumidity", value: currentHumidity, displayed: false)
+ sendEvent(name: "minHumidity", value: currentHumidity, displayed: false)
+ refreshMultiAttributes()
+}
+
+// Check new min or max temp for the day
+def updateMinMaxTemps(temp) {
+ temp = temp ? (int) temp : temp
+ if ((temp > device.currentValue('maxTemp')) || (device.currentValue('maxTemp') == null))
+ sendEvent(name: "maxTemp", value: temp, displayed: false)
+ if ((temp < device.currentValue('minTemp')) || (device.currentValue('minTemp') == null))
+ sendEvent(name: "minTemp", value: temp, displayed: false)
+ refreshMultiAttributes()
+}
+
+// Check new min or max humidity for the day
+def updateMinMaxHumidity(humidity) {
+ if ((humidity > device.currentValue('maxHumidity')) || (device.currentValue('maxHumidity') == null))
+ sendEvent(name: "maxHumidity", value: humidity, displayed: false)
+ if ((humidity < device.currentValue('minHumidity')) || (device.currentValue('minHumidity') == null))
+ sendEvent(name: "minHumidity", value: humidity, displayed: false)
+ refreshMultiAttributes()
+}
+
+// Update display of multiattributes in main tile
+def refreshMultiAttributes() {
+ def temphiloAttributes = displayTempHighLow ? (displayHumidHighLow ? "Today's High/Low: ${device.currentState('maxTemp')?.value}° / ${device.currentState('minTemp')?.value}°" : "Today's High: ${device.currentState('maxTemp')?.value}° / Low: ${device.currentState('minTemp')?.value}°") : ""
+ def humidhiloAttributes = displayHumidHighLow ? (displayTempHighLow ? " ${device.currentState('maxHumidity')?.value}% / ${device.currentState('minHumidity')?.value}%" : "Today's High: ${device.currentState('maxHumidity')?.value}% / Low: ${device.currentState('minHumidity')?.value}%") : ""
+ sendEvent(name: "multiAttributesReport", value: "${temphiloAttributes}${humidhiloAttributes}", displayed: false)
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("installed")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated")
+ if(battReset) {
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-aqara-vibration-sensor.src/xiaomi-aqara-vibration-sensor.groovy b/devicetypes/bspranger/xiaomi-aqara-vibration-sensor.src/xiaomi-aqara-vibration-sensor.groovy
new file mode 100644
index 00000000..c29fa42d
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-vibration-sensor.src/xiaomi-aqara-vibration-sensor.groovy
@@ -0,0 +1,580 @@
+/**
+ * Xiaomi Aqara Vibration Sensor
+ * Model DJT11LM
+ * Version 0.91b
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub.
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Aqara Vibration Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Acceleration Sensor"
+ capability "Battery"
+ capability "Button"
+ capability "Configuration"
+ capability "Contact Sensor"
+ capability "Health Check"
+ capability "Motion Sensor"
+ capability "Refresh"
+ capability "Sensor"
+ capability "Three Axis"
+
+ attribute "accelSensitivity", "String"
+ attribute "angleX", "number"
+ attribute "angleY", "number"
+ attribute "angleZ", "number"
+ attribute "batteryRuntime", "String"
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinCoRE", "Date"
+ attribute "lastDrop", "String"
+ attribute "lastDropCoRE", "Date"
+ attribute "lastStationary", "String"
+ attribute "lastStationaryCoRE", "Date"
+ attribute "lastTilt", "String"
+ attribute "lastTiltCoRE", "Date"
+ attribute "lastVibration", "String"
+ attribute "lastVibrationCoRE", "Date"
+ attribute "tiltAngle", "String"
+ attribute "sensorStatus", "enum", ["vibrating", "tilted", "dropped", "Stationary"]
+ attribute "activityLevel", "String"
+
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "000A", inClusters: "0000,0003,0019,0101", outClusters: "0000,0004,0003,0005,0019,0101", manufacturer: "LUMI", model: "lumi.vibration.aq1", deviceJoinName: "Xiaomi Aqara Vibration Sensor"
+
+ command "resetBatteryRuntime"
+ command "changeSensitivity"
+ command "setOpenPosition"
+ command "setClosedPosition"
+ }
+
+ simulator {
+ }
+
+ tiles(scale: 2) {
+ // Motion used for sensor vibration/shake events
+ multiAttributeTile(name:"sensorStatus", type: "lighting", width: 6, height: 4) {
+ tileAttribute("device.sensorStatus", key: "PRIMARY_CONTROL") {
+ attributeState "default", label:'Stationary', backgroundColor:"#ffffff"
+ attributeState "Vibration", label:'Vibration', backgroundColor:"#00a0dc"
+ attributeState "Tilt", label:'Tilt', backgroundColor:"#00a0dc"
+ attributeState "Drop", label:'Drop', backgroundColor:"#00a0dc"
+ attributeState "Stationary", label:'Stationary', backgroundColor:"#ffffff"
+ }
+ tileAttribute("device.lastCheckin", key: "SECONDARY_CONTROL") {
+ attributeState("lastCheckin", label:'Last Event: ${currentValue}')
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ standardTile("contact", "device.contact", decoration: "flat", width: 2, height: 2) {
+ state("default", label: 'Unknown', icon: "st.contact.contact.closed", backgroundColor: "#cccccc")
+ state("open", label: 'Open', icon: "st.contact.contact.open", backgroundColor: "#e86d13")
+ state("closed", label: 'Closed', icon: "st.contact.contact.closed", backgroundColor: "#00a0dc")
+ state("unknown", label: 'Unknown', icon: "st.contact.contact.closed", backgroundColor: "#cccccc")
+ }
+ standardTile("motion", "device.motion", decoration: "flat", width: 2, height: 2) {
+ state("active", label: 'Motion', icon:"st.motion.motion.active", backgroundColor:"#00a0dc")
+ state("inactive", label: '${name}', icon: "st.motion.motion.inactive", backgroundColor: "#cccccc")
+ }
+ standardTile("acceleration", "device.acceleration", decoration: "flat", width: 2, height: 2) {
+ state("active", label: 'Accel', icon:"st.motion.acceleration.active", backgroundColor:"#00a0dc")
+ state("inactive", label: '${name}', icon: "st.motion.acceleration.inactive", backgroundColor: "#cccccc")
+ }
+ standardTile("setClosedPosition", "device.setClosedPosition", decoration: "flat", width: 2, height: 2) {
+ state "default", action:"setClosedPosition", icon: "st.contact.contact.closed", label:'Set Close'
+ }
+ standardTile("setOpenPosition", "device.setOpenPosition", decoration: "flat", width: 2, height: 2) {
+ state "default", action:"setOpenPosition", icon: "st.contact.contact.open", label:'Set Open'
+ }
+ valueTile("accelSensitivity", "device.accelSensitivity", decoration: "flat", width: 2, height: 2) {
+ state "default", action: "changeSensitivity", label:'Sensitivity\nLevel:\n${currentValue}'
+ }
+ valueTile("activityLevel", "device.activityLevel", decoration: "flat", width: 2, height: 2) {
+ state "default", label:'Recent Activity Level:\n${currentValue}'
+ }
+ valueTile("tiltAngle", "device.tiltAngle", decoration: "flat", width: 2, height: 2) {
+ state "default", label:'Last Angle Change:\n${currentValue}°'
+ }
+ valueTile("threeAxis", "device.threeAxis", decoration: "flat", width: 4, height: 1) {
+ state "default", label:'3-Axis Angles: ${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+ standardTile("refresh", "device.refresh", decoration: "flat", width: 2, height: 2) {
+ state "default", action:"refresh.refresh", icon: "st.secondary.refresh"
+ }
+ valueTile("spacer", "spacer", decoration: "flat", width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("spacerlg", "spacerlg", decoration: "flat", width: 2, height: 2) {
+ state "default", label:''
+ }
+
+ main (["sensorStatus"])
+ details(["sensorStatus","motion","contact","acceleration","setClosedPosition","accelSensitivity","setOpenPosition","tiltAngle","battery","activityLevel","threeAxis","batteryRuntime","refresh"])
+ }
+
+ preferences {
+ //Reset to No Motion Config
+ input description: "This setting changes how long MOTION ACTIVE is reported in SmartThings when the sensor detects vibration/shock. NOTE: The hardware waits about 60 seconds between vibration/shock detections.", type: "paragraph", element: "paragraph", title: "MOTION RESET"
+ input "motionreset", "number", title: "", description: "Number of seconds (default = 65)", range: "1..7200"
+ // Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ // Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ // Advanced Settings
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ // Battery Voltage Range
+ input description: "", type: "paragraph", element: "paragraph", title: "BATTERY VOLTAGE RANGE"
+ input name: "voltsmax", type: "decimal", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", type: "decimal", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", range: "2..2.7", defaultValue: 2.5
+ // Live Logging Message Display Config
+ input description: "These settings affect the display of messages in the Live Logging tab of the SmartThings IDE.", type: "paragraph", element: "paragraph", title: "LIVE LOGGING"
+ input name: "infoLogging", type: "bool", title: "Display info log messages?", defaultValue: true
+ input name: "debugLogging", type: "bool", title: "Display debug log messages?"
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ displayDebugLog(": Parsing '${description}'")
+ def result = [:]
+
+ // Any report - button press & Battery - results in a lastCheckin event and update to Last Checkin tile
+ sendEvent(name: "lastCheckin", value: formatDate(), displayed: false)
+ sendEvent(name: "lastCheckinCoRE", value: now(), displayed: false)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith("read attr - raw: ")) {
+ result = parseReadAttrMessage(description)
+ } else if (description?.startsWith('catchall:')) {
+ result = parseCatchAllMessage(description)
+ }
+ if (result != [:]) {
+ displayDebugLog(": Creating event $result")
+ return createEvent(result)
+ } else {
+ displayDebugLog(": Unable to parse unrecognized message")
+ return [:]
+ }
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ displayDebugLog(": $catchall")
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Parse read attr - raw messages (includes all sensor event messages and reset button press, and )
+private Map parseReadAttrMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ def eventType
+ Map resultMap = [:]
+
+ if (cluster == "0101") {
+ // Handles vibration (value 01), tilt (value 02), and drop (value 03) event messages
+ if (attrId == "0055") {
+ if (value?.endsWith('0002')) {
+ eventType = 2
+ parseTiltAngle(value[0..3])
+ } else {
+ eventType = Integer.parseInt(value,16)
+ }
+ resultMap = mapSensorEvent(eventType)
+ }
+ // Handles XYZ Accelerometer values
+ else if (attrId == "0508") {
+ short x = (short)Integer.parseInt(value[8..11],16)
+ short y = (short)Integer.parseInt(value[4..7],16)
+ short z = (short)Integer.parseInt(value[0..3],16)
+ float Psi = Math.round(Math.atan(x/Math.sqrt(z*z+y*y))*1800/Math.PI)/10
+ float Phi = Math.round(Math.atan(y/Math.sqrt(x*x+z*z))*1800/Math.PI)/10
+ float Theta = Math.round(Math.atan(z/Math.sqrt(x*x+y*y))*1800/Math.PI)/10
+ def descText = ": Calculated angles are Psi = ${Psi}°, Phi = ${Phi}°, Theta = ${Theta}° "
+ displayDebugLog(": Raw accelerometer XYZ axis values = $x, $y, $z")
+ displayDebugLog(descText)
+ sendEvent(name: "angleX", value: Psi, displayed: false)
+ sendEvent(name: "angleY", value: Phi, displayed: false)
+ sendEvent(name: "angleZ", value: Theta, displayed: false)
+ resultMap = [
+ name: 'threeAxis',
+ value: [Psi, Phi, Theta],
+ linkText: getLinkText(device),
+ isStateChange: true,
+ descriptionText: "$device.displayName$descText",
+ ]
+ if (!state.closedX || !state.openX)
+ displayInfoLog(": Open/Closed position is unknown because Open and/or Closed positions have not been set")
+ else {
+ def float cX = Float.parseFloat(state.closedX)
+ def float cY = Float.parseFloat(state.closedY)
+ def float cZ = Float.parseFloat(state.closedZ)
+ def float oX = Float.parseFloat(state.openX)
+ def float oY = Float.parseFloat(state.openY)
+ def float oZ = Float.parseFloat(state.openZ)
+ def float e = 10.0 // Sets range for margin of error
+ def ocPosition = "unknown"
+ if ((Psi < cX + e) && (Psi > cX - e) && (Phi < cY + e) && (Phi > cY - e) && (Theta < cZ + e) && (Theta > cZ - e))
+ ocPosition = "closed"
+ else if ((Psi < oX + e) && (Psi > oX - e) && (Phi < oY + e) && (Phi > oY - e) && (Theta < oZ + e) && (Theta > oZ - e))
+ ocPosition = "open"
+ else
+ displayDebugLog(": The current calculated angle position does not match either stored open/closed positions")
+ sendpositionEvent(ocPosition)
+ }
+ }
+ // Handles Recent Activity level value messages
+ else if (attrId == "0505") {
+ def level = Integer.parseInt(value[0..3],16)
+ def descText = ": Recent activity level reported at $level"
+ displayInfoLog(descText)
+ resultMap = [
+ name: 'activityLevel',
+ value: level,
+ descriptionText: "$device.displayName$descText",
+ ]
+ }
+ }
+ else if (cluster == "0000" && attrId == "0005") {
+ displayInfoLog(": reset button short press detected")
+ def modelName = ""
+ // Parsing the model
+ for (int i = 0; i < value.length(); i+=2) {
+ def str = value.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ displayDebugLog(" reported model name:${modelName}")
+ }
+ return resultMap
+}
+
+// Create map of values to be used for vibration, tilt, or drop event
+private Map mapSensorEvent(value) {
+ def seconds = (value == 1 || value == 4) ? (motionreset ? motionreset : 65) : 2
+ def time = new Date(now() + (seconds * 1000))
+ def statusType = ["Stationary", "Vibration", "Tilt", "Drop", "", ""]
+ def eventName = ["", "motion", "acceleration", "button", "motion", "acceleration"]
+ def eventType = ["", "active", "active", "pushed", "inactive", "inactive"]
+ def eventMessage = [" is stationary", " was vibrating or moving (Motion active)", " was tilted (Acceleration active)", " was dropped (Button pushed)", ": Motion reset to inactive after $seconds seconds", ": Acceleration reset to inactive"]
+ if (value < 4) {
+ sendEvent(name: "sensorStatus", value: statusType[value], descriptionText: "$device.displayName${eventMessage[value]}", isStateChange: true, displayed: (value == 0) ? true : false)
+ updateLastMovementEvent(statusType[value])
+ }
+ displayInfoLog("${eventMessage[value]}")
+ if (value == 0)
+ return
+ else if (value == 1) {
+ runOnce(time, clearmotionEvent)
+ state.motionactive = 1
+ }
+ else if (value == 2)
+ runOnce(time, clearaccelEvent)
+ else if (value == 3)
+ runOnce(time, cleardropEvent)
+ return [
+ name: eventName[value],
+ value: eventType[value],
+ descriptionText: "$device.displayName${eventMessage[value]}",
+ isStateChange: true,
+ displayed: true
+ ]
+}
+
+// Handles tilt angle change message and posts event to update UI tile display
+private parseTiltAngle(value) {
+ def angle = Integer.parseInt(value,16)
+ def descText = ": tilt angle changed by $angle°"
+ sendEvent(
+ name: 'tiltAngle',
+ value: angle,
+ // unit: "°", // Need to check whether this works or is needed at all
+ descriptionText : "$device.displayName$descText",
+ isStateChange:true,
+ displayed: true
+ )
+ displayInfoLog(descText)
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+
+ def minVolts
+ def maxVolts
+
+ if (voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if (voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def descText = ": Battery at ${roundedPct}% (${rawVolts} Volts)"
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "$device.displayName$descText"
+ ]
+
+ displayInfoLog(descText)
+ return result
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: formatDate(true))
+ displayInfoLog(": Setting Battery Changed to current date${newlyPaired}")
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.prefsSetCount = 0
+ displayInfoLog(": Installing")
+ init(0)
+ checkIntervalEvent("")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ displayInfoLog(": Configuring")
+ mapSensorEvent(0)
+ refresh()
+ init(1)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ displayInfoLog(": Updating preference settings")
+ if (!state.prefsSetCount)
+ state.prefsSetCount = 1
+ else if (state.prefsSetCount < 3)
+ state.prefsSetCount = state.prefsSetCount + 1
+ init(0)
+ if (battReset) {
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+ refresh()
+ checkIntervalEvent("preferences updated")
+ displayInfoLog(": Info message logging enabled")
+ displayDebugLog(": Debug message logging enabled")
+}
+
+def init(displayLog) {
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime(true)
+ sendEvent(name: "numberOfButtons", value: 1, displayed: false)
+}
+
+// update lastStationary, lastVibration, lastTilt, or lastDrop to current date/time
+def updateLastMovementEvent(pressType) {
+ displayDebugLog(": Setting Last $pressType to current date/time")
+ sendEvent(name: "last${pressType}", value: formatDate(), displayed: false)
+ sendEvent(name: "last${pressType}CoRE", value: now(), displayed: false)
+}
+
+def clearmotionEvent() {
+ def result = [:]
+ if (device.currentState('sensorStatus')?.value == "Vibration")
+ mapSensorEvent(0)
+ result = mapSensorEvent(4)
+ state.motionactive = 0
+ displayDebugLog(": Sending event $result")
+ sendEvent(result)
+}
+
+def clearaccelEvent() {
+ def result = [:]
+ if (device.currentState('sensorStatus')?.value == "Tilt") {
+ if (state.motionactive == 1)
+ sendEvent(name: "sensorStatus", value: "Vibration", displayed: false)
+ else
+ mapSensorEvent(0)
+ }
+ result = mapSensorEvent(5)
+ displayDebugLog(": Sending event $result")
+ sendEvent(result)
+}
+
+def cleardropEvent() {
+ if (device.currentState('sensorStatus')?.value == "Drop") {
+ if (state.motionactive == 1)
+ sendEvent(name: "sensorStatus", value: "Vibration", displayed: false)
+ else
+ mapSensorEvent(0)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ if (text)
+ displayInfoLog(": Set health checkInterval when ${text}")
+ sendEvent(name: "checkInterval", value: 3 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def setClosedPosition() {
+ if (device.currentValue('angleX')) {
+ state.closedX = device.currentState('angleX').value
+ state.closedY = device.currentState('angleY').value
+ state.closedZ = device.currentState('angleZ').value
+ sendpositionEvent("closed")
+ displayInfoLog(": Closed position successfully set")
+ displayDebugLog(": Closed position set to $state.closedX°, $state.closedY°, $state.closedZ°")
+ }
+ else
+ displayDebugLog(": Closed position NOT set because no 3-axis accelerometer reports have been received yet")
+}
+
+def setOpenPosition() {
+ if (device.currentValue('angleX')) {
+ state.openX = device.currentState('angleX').value
+ state.openY = device.currentState('angleY').value
+ state.openZ = device.currentState('angleZ').value
+ sendpositionEvent("open")
+ displayInfoLog(": Open position successfully set")
+ displayDebugLog(": Open position set to $state.openX°, $state.openY°, $state.openZ°")
+ }
+ else
+ displayDebugLog(": Open position NOT set because no 3-axis accelerometer reports have been received yet")
+}
+
+def sendpositionEvent(String ocPosition) {
+ def descText = ": Calculated position is $ocPosition"
+ displayInfoLog(descText)
+ sendEvent(
+ name: "contact",
+ value: ocPosition,
+ isStateChange: true,
+ descriptionText: "$device.displayName$descText")
+}
+
+def changeSensitivity() {
+ state.sensitivity = (state.sensitivity < 3) ? state.sensitivity + 1 : 1
+ def attrValue = [0, 0x15, 0x0B, 0x01]
+ def levelText = ["", "Low", "Medium", "High"]
+ def descText = ": Sensitivity level set to ${levelText[state.sensitivity]}"
+ zigbee.writeAttribute(0x0000, 0xFF0D, 0x20, attrValue[state.sensitivity], [mfgCode: 0x115F])
+ zigbee.readAttribute(0x0000, 0xFF0D, [mfgCode: 0x115F])
+/** ALTERNATE METHOD FOR WRITE & READ ATTRUBUTE COMMANDS
+ def cmds = zigbee.writeAttribute(0x0000, 0xFF0D, 0x20, attrValue[level], [mfgCode: 0x115F]) + zigbee.readAttribute(0x0000, 0xFF0D, [mfgCode: 0x115F])
+ for (String cmdString : commands) {
+ sendHubCommand([cmdString].collect {new physicalgraph.device.HubAction(it)}, 0)
+ }
+*/
+ sendEvent(name: "accelSensitivity", value: levelText[state.sensitivity], isStateChange: true, descriptionText: descText)
+ displayInfoLog(descText)
+}
+
+def displayDebugLog(String message) {
+ if (debugLogging)
+ log.debug "$device.displayName$message"
+}
+
+def displayInfoLog(String message) {
+ if (infoLogging || state.prefsSetCount < 3)
+ log.info "$device.displayName$message"
+}
+
+def refresh() {
+ displayInfoLog(": Refreshing UI display")
+ if (!state.sensitivity) {
+ state.sensitivity = 0
+ changeSensitivity()
+ }
+ if (device.currentValue('tiltAngle') == null)
+ sendEvent(name: 'tiltAngle', value: "--", isStateChange: true, displayed: false)
+ if (device.currentValue('activityLevel') == null)
+ sendEvent(name: 'activityLevel', value: "--", isStateChange: true, displayed: false)
+ zigbee.readAttribute(0x0000, 0xFF0D, [mfgCode: 0x115F])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-aqara-wireless-switch.src/xiaomi-aqara-wireless-switch.groovy b/devicetypes/bspranger/xiaomi-aqara-wireless-switch.src/xiaomi-aqara-wireless-switch.groovy
new file mode 100644
index 00000000..09fa322d
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-aqara-wireless-switch.src/xiaomi-aqara-wireless-switch.groovy
@@ -0,0 +1,441 @@
+/**
+ * Aqara Wireless Smart Light Switch models WXKG02LM / WXKG03LM (2016 & 2018 revisions)
+ * Device Handler for SmartThings
+ * Version 0.9.2
+ *
+ *
+ * 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.
+ *
+ * Based on original device handler code by a4refillpad, adapted by bspranger, then rewritten and updated for changes in firmware 25.20 by veeceeoh
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, veeceeoh, & xtianpaiva
+ *
+ * Notes on capabilities of the different models:
+ * Model WXKG03LM (1 button) - 2016 Revision (lumi.sensor_86sw1lu):
+ * - Single press results in "button 1 pushed" event
+ * Model WXKG03LM (1 button) - 2018 Revision (lumi.remote.b186acn01):
+ * - Single press results in "button 1 pushed" event
+ * - Double click results in "button 2 pushed" event
+ * - Hold for longer than 400ms results in "button 1 held" event
+ * Model WXKG02LM (2 button) - 2016 Revision (lumi.sensor_86sw2Un):
+ * + If using firmware version 24.x or older:
+ * - Press of left, right, or both all result in "button 1 pushed" event
+ * + If using firmware version 25.20 or newer:
+ * - Press of left button results in "button 1 pushed" event
+ * - Press of right button results in "button 2 pushed" event
+ * - Press of both buttons results in "button 3 pushed" event
+ * Model WXKG02LM (2 button) - 2018 Revision (lumi.remote.b286acn01):
+ * - Single press of left/right/both button(s) results in button 1/2/3 "pushed" event
+ * - Double click of left/right/both button(s) results in button 3/4/5 "pushed" event
+ * - Hold of left/right/both button(s) for longer than 400ms results in button 1/2/3 "held" event
+ * Details of 2018 revision button press ZigBee messages:
+ * Cluster 0012 (Multistate Input), Attribute 0055
+ * Endpoint 1 = left, 2 = right, 3 = both
+ * Value 0 = hold, 1 = single, 2 = double
+ *
+ * Known issues:
+ * - As of March 2019, the SmartThings Samsung Connect mobile app does NOT support custom device handlers such as this one
+ * - The SmartThings Classic mobile app UI text/graphics is rendered differently on iOS vs Android devices - This is due to SmartThings, not this device handler
+ * - Pairing Xiaomi/Aqara devices can be difficult as they were not designed to use with a SmartThings hub.
+ * - The battery level is not reported at pairing. Wait for the first status report, 50-60 minutes after pairing.
+ * - Xiaomi devices do not respond to refresh requests
+ * - Most ZigBee repeater devices (generally mains-powered ZigBee devices) are NOT compatible with Xiaomi/Aqara devices, causing them to drop off the network.
+ * Only XBee ZigBee modules, the IKEA Tradfri Outlet / Tradfri Bulb, and ST user @iharyadi's custom multi-sensor ZigBee repeater device are confirmed to be compatible.
+ *
+ */
+
+ import groovy.json.JsonOutput
+ import physicalgraph.zigbee.zcl.DataType
+
+ metadata {
+ definition (name: "Xiaomi Aqara Wireless Switch", namespace: "bspranger", author: "bspranger", minHubCoreVersion: "000.022.0002", ocfDeviceType: "x.com.st.d.remotecontroller") {
+ capability "Battery"
+ capability "Sensor"
+ capability "Button"
+ capability "Holdable Button"
+ capability "Actuator"
+ capability "Momentary"
+ capability "Configuration"
+ capability "Health Check"
+
+ attribute "lastCheckin", "string"
+ attribute "lastCheckinCoRE", "string"
+ attribute "lastHeld", "string"
+ attribute "lastHeldCoRE", "string"
+ attribute "lastPressed", "string"
+ attribute "lastPressedCoRE", "string"
+ attribute "lastReleased", "string"
+ attribute "lastReleasedCoRE", "string"
+ attribute "batteryRuntime", "string"
+ attribute "buttonStatus", "enum", ["pushed", "held", "single-clicked", "double-clicked", "shaken", "released"]
+
+ // Aqara Smart Light Switch - single button - model WXKG03LM (2016 revision)
+ fingerprint deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw1lu", deviceJoinName: "Aqara Switch WXKG03LM (2016)"
+ fingerprint deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw1", deviceJoinName: "Aqara Switch WXKG03LM (2016)"
+ // Aqara Smart Light Switch - single button - model WXKG03LM (2018 revision)
+ fingerprint deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.remote.b186acn01", deviceJoinName: "Aqara Switch WXKG03LM (2018)"
+ // Aqara Smart Light Switch - dual button - model WXKG02LM (2016 revision)
+ fingerprint deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw2Un", deviceJoinName: "Aqara Switch WXKG02LM (2016)"
+ fingerprint deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.sensor_86sw2", deviceJoinName: "Aqara Switch WXKG02LM (2016)"
+ // Aqara Smart Light Switch - dual button - model WXKG02LM (2018 revision)
+ fingerprint deviceId: "5F01", inClusters: "0000,0003,0019,0012,FFFF", outClusters: "0000,0003,0004,0005,0019,0012,FFFF", manufacturer: "LUMI", model: "lumi.remote.b286acn01", deviceJoinName: "Aqara Switch WXKG02LM (2018)"
+
+ command "resetBatteryRuntime"
+ }
+
+ simulator {
+ status "Press button": "on/off: 0"
+ status "Release button": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"buttonStatus", type: "lighting", width: 6, height: 4, canChangeIcon: false) {
+ tileAttribute ("device.buttonStatus", key: "PRIMARY_CONTROL") {
+ attributeState("default", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("pushed", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("held", label:'Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("double-clicked", label:'Double-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("leftpushed", label:'Left Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("rightpushed", label:'Right Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("bothpushed", label:'Both Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("leftdouble-clicked", label:'Left Dbl-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("rightdouble-clicked", label:'Right Dbl-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("bothdouble-clicked", label:'Both Dbl-clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("leftheld", label:'Left Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("rightheld", label:'Right Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("bothheld", label:'Both Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("released", label:'Released', action: "momentary.push", backgroundColor:"#ffffff", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonReleased.png")
+ }
+ tileAttribute("device.lastPressed", key: "SECONDARY_CONTROL") {
+ attributeState "lastPressed", label:'Last Pressed: ${currentValue}'
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("lastCheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "lastCheckin", label:'Last Event:\n${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+ main (["buttonStatus"])
+ details(["buttonStatus","battery","lastCheckin","batteryRuntime"])
+ }
+
+ preferences {
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Advanced Settings
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ //Battery Voltage Range
+ input description: "", type: "paragraph", element: "paragraph", title: "BATTERY VOLTAGE RANGE"
+ input name: "voltsmax", type: "decimal", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", type: "decimal", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", range: "2..2.7", defaultValue: 2.5
+ //Live Logging Message Display Config
+ input description: "These settings affect the display of messages in the Live Logging tab of the SmartThings IDE.", type: "paragraph", element: "paragraph", title: "LIVE LOGGING"
+ input name: "infoLogging", type: "bool", title: "Display info log messages?", defaultValue: true
+ input name: "debugLogging", type: "bool", title: "Display debug log messages?"
+ }
+}
+
+//adds functionality to press the center tile as a virtualApp Button
+def push() {
+ def result = mapButtonEvent(0, 1)
+ displayDebugLog(": Sending event $result")
+ sendEvent(result)
+}
+
+// Parse incoming device messages to generate events
+def parse(description) {
+ displayDebugLog(": Parsing '$description'")
+ def result = [:]
+
+ // Any report - button press & Battery - results in a lastCheckin event and update to Last Checkin tile
+ sendEvent(name: "lastCheckin", value: formatDate(), displayed: false)
+ sendEvent(name: "lastCheckinCoRE", value: now(), displayed: false)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith('on/off: ')) {
+ // Hub FW prior to 25.x - Models WXKG02LM/WXKG03LM (original revision) - any press generates button 1 pushed event
+ state.numButtons = 1
+ result = mapButtonEvent(1, 1)
+ } else if (description?.startsWith("read attr")) {
+ // Parse button messages of other models, or messages on short-press of reset button
+ result = parseReadAttrMessage(description - "read attr - ")
+ } else if (description?.startsWith("catchall")) {
+ // Parse battery level from regular hourly announcement messages
+ result = parseCatchAllMessage(description)
+ }
+ if (result != [:]) {
+ displayDebugLog(": Creating event $result")
+ return createEvent(result)
+ } else
+ return [:]
+}
+
+private Map parseReadAttrMessage(String description) {
+ Map descMap = (description).split(",").inject([:]) {
+ map, param ->
+ def nameAndValue = param.split(":")
+ map += [(nameAndValue[0].trim()):nameAndValue[1].trim()]
+ }
+ Map resultMap = [:]
+ if (descMap.cluster == "0006") {
+ // Process model WXKG02LM / WXKG03LM (2016 revision) button messages
+ resultMap = mapButtonEvent(Integer.parseInt(descMap.endpoint,16), 1)
+ } else if (descMap.cluster == "0012") {
+ // Process model WXKG02LM / WXKG03LM (2018 revision) button messages
+ resultMap = mapButtonEvent(Integer.parseInt(descMap.endpoint,16), Integer.parseInt(descMap.value[2..3],16))
+ } else if (descMap.cluster == "0000" && descMap.attrId == "0005") {
+ // Process message containing model name and/or battery voltage report
+ def data = ""
+ def modelName = ""
+ def model = descMap.value
+ if (descMap.value.length() > 45) {
+ model = descMap.value.split("01FF")[0]
+ data = descMap.value.split("01FF")[1]
+ if (data[4..7] == "0121") {
+ def BatteryVoltage = (Integer.parseInt((data[10..11] + data[8..9]),16))
+ resultMap = getBatteryResult(BatteryVoltage)
+ }
+ }
+ // Parsing the model name
+ for (int i = 0; i < model.length(); i+=2) {
+ def str = model.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ displayDebugLog(" reported ZigBee model: $modelName")
+ }
+ return resultMap
+}
+
+// Create map of values to be used for button events
+private mapButtonEvent(buttonValue, actionValue) {
+ // buttonValue (message endpoint) 1 = left, 2 = right, 3 = both (and 0 = virtual app button)
+ // actionValue (message value) 0 = hold, 1 = push, 2 = double-click (hold & double-click on 2018 revision only)
+ def whichButtonText = ["Virtual button was", ((state.numButtons < 3) ? "Button was" : "Left button was"), "Right button was", "Both buttons were"]
+ def statusButton = ["", ((state.numButtons < 3) ? "" : "left"), "right", "both"]
+ def pressType = ["held", "pushed", "double-clicked"]
+ def eventType = (actionValue == 0) ? "held" : "pushed"
+ def lastPressType = (actionValue == 0) ? "Held" : "Pressed"
+ def buttonNum = (buttonValue == 0 ? 1 : buttonValue) + (actionValue == 2 ? 3 : 0)
+ def descText = "${whichButtonText[buttonValue]} ${pressType[actionValue]} (Button $buttonNum $eventType)"
+ sendEvent(name: "buttonStatus", value: "${statusButton[buttonValue]}${pressType[actionValue]}", isStateChange: true, displayed: false)
+ updateLastPressed(lastPressType)
+ displayInfoLog(": $descText")
+ runIn(1, clearButtonStatus)
+ return [
+ name: 'button',
+ value: eventType,
+ data: [buttonNumber: buttonNum],
+ descriptionText: descText,
+ isStateChange: true
+ ]
+}
+
+// on any type of button pressed update lastHeld(CoRE), lastPressed(CoRE), or lastReleased(CoRE) to current date/time
+def updateLastPressed(pressType) {
+ displayDebugLog(": Setting Last $pressType to current date/time")
+ sendEvent(name: "last${pressType}", value: formatDate(), displayed: false)
+ sendEvent(name: "last${pressType}CoRE", value: now(), displayed: false)
+}
+
+def clearButtonStatus() {
+ sendEvent(name: "buttonStatus", value: "released", isStateChange: true, displayed: false)
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts = voltsmin ? voltsmin : 2.5
+ def maxVolts = voltsmax ? voltsmax : 3.0
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+ def descText = "Battery at ${roundedPct}% (${rawVolts} Volts)"
+ displayInfoLog(": $descText")
+ return [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "$device.displayName $descText"
+ ]
+}
+
+private def displayDebugLog(message) {
+ if (debugLogging)
+ log.debug "${device.displayName}${message}"
+}
+
+private def displayInfoLog(message) {
+ if (infoLogging || state.prefsSetCount < 3)
+ log.info "${device.displayName}${message}"
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: formatDate(true))
+ displayInfoLog(": Setting Battery Changed to current date${newlyPaired}")
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.prefsSetCount = 0
+ displayInfoLog(": Installing")
+ checkIntervalEvent("")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ displayInfoLog(": Configuring")
+ initialize(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ displayInfoLog(": Updating preference settings")
+ if (!state.prefsSetCount)
+ state.prefsSetCount = 1
+ else if (state.prefsSetCount < 3)
+ state.prefsSetCount = state.prefsSetCount + 1
+ initialize(false)
+ if (battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+ checkIntervalEvent("preferences updated")
+ displayInfoLog(": Info message logging enabled")
+ displayDebugLog(": Debug message logging enabled")
+}
+
+def initialize(newlyPaired) {
+ sendEvent(name: "DeviceWatch-Enroll", value: JsonOutput.toJson([protocol: "zigbee", scheme:"untracked"]), displayed: false)
+ clearButtonStatus()
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime(newlyPaired)
+ setNumButtons()
+}
+
+def setNumButtons() {
+ if (device.getDataValue("model")) {
+ def modelName = device.getDataValue("model")
+ def modelText = ""
+ if (!state.numButtons || state.numButtons == 7) {
+ if (modelName.startsWith("lumi.sensor_86sw2")) {
+ modelText = "Wireless Smart Light Switch WXKG02LM - dual button (2016 revision)"
+ state.numButtons = 3
+ }
+ else if (modelName.startsWith("lumi.sensor_86sw1")) {
+ modelText = "Wireless Smart Light Switch WXKG03LM - single button (2016 revision)"
+ state.numButtons = 1
+ }
+ else if (modelName.startsWith("lumi.remote.b186acn01")) {
+ modelText = "Wireless Smart Light Switch WXKG03LM - single button (2018 revision)"
+ state.numButtons = 2
+ }
+ else if (modelName.startsWith("lumi.remote.b286acn01")) {
+ modelText = "Wireless Smart Light Switch WXKG02LM - dual button (2018 revision)"
+ state.numButtons = 6
+ }
+ else {
+ state.numButtons = 3
+ }
+ displayInfoLog(": Model is Aqara $modelText.")
+ displayInfoLog(": Number of buttons set to ${state.numButtons}.")
+ sendEvent(name: "numberOfButtons", value: state.numButtons, displayed: false)
+ device.currentValue("numberOfButtons")?.times {
+ sendEvent(name: "button", value: "pushed", data: [buttonNumber: it+1], displayed: false)
+ }
+ }
+ }
+ else {
+ if (!state.numButtons) {
+ displayInfoLog(": Model is unknown, so number of buttons is set to default of 6.")
+ sendEvent(name: "numberOfButtons", value: 6, displayed: false)
+ state.numButtons = 7
+ }
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ if (text)
+ displayInfoLog(": Set health checkInterval when ${text}")
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-button-old-firmware.src/xiaomi-button-old-firmware.groovy b/devicetypes/bspranger/xiaomi-button-old-firmware.src/xiaomi-button-old-firmware.groovy
new file mode 100644
index 00000000..35e6827c
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-button-old-firmware.src/xiaomi-button-old-firmware.groovy
@@ -0,0 +1,363 @@
+/**
+ * Xiaomi Zigbee Button - model WXKG01LM
+ * OLD Device Handler for SmartThings - Firmware versions 24.x and older ONLY
+ * Version 1.2.1
+ *
+ * NOTE: Do NOT use this device handler on a SmartThings v2/v3 hub with firmware 25.20 or newer
+ * Instead use the xiaomi-button.groovy device driver
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub.
+ *
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Button Old Firmware", namespace: "bspranger", author: "bspranger") {
+ capability "Battery"
+ capability "Sensor"
+ capability "Button"
+ capability "Holdable Button"
+ capability "Actuator"
+ capability "Momentary"
+ capability "Configuration"
+ capability "Health Check"
+
+ attribute "lastCheckin", "string"
+ attribute "lastCheckinCoRE", "string"
+ attribute "lastPressed", "string"
+ attribute "lastPressedCoRE", "string"
+ attribute "lastReleasedCoRE", "string"
+ attribute "lastButtonMssg", "string"
+ attribute "batteryRuntime", "string"
+ attribute "buttonStatus", "enum", ["pushed", "held", "released"]
+
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "0104", inClusters: "0000,0003,FFFF,0019", outClusters: "0000,0004,0003,0006,0008,0005,0019", manufacturer: "LUMI", model: "lumi.sensor_switch", deviceJoinName: "Original Xiaomi Button"
+
+ command "resetBatteryRuntime"
+ }
+
+ simulator {
+ status "Press button": "on/off: 0"
+ status "Release button": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"buttonStatus", type: "lighting", width: 6, height: 4, canChangeIcon: false) {
+ tileAttribute ("device.buttonStatus", key: "PRIMARY_CONTROL") {
+ attributeState("default", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("pushed", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("held", label:'Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("released", label:'Released', action: "momentary.push", backgroundColor:"#ffffff", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonReleased.png")
+ }
+ tileAttribute("device.lastPressed", key: "SECONDARY_CONTROL") {
+ attributeState "lastPressed", label:'Last Pressed: ${currentValue}'
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("lastCheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "lastCheckin", label:'Last Event:\n${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+ main (["buttonStatus"])
+ details(["buttonStatus","battery","lastCheckin","batteryRuntime"])
+ }
+
+ preferences {
+ //Button Config
+ input description: "A button click always sends a 'button 1 pushed' event, but as a default if it is held for at least 2 seconds, a 'button 1 held' event is sent when released. The settings below allow changes to the minimum time needed for held and what type of event should be sent.", type: "paragraph", element: "paragraph", title: "BUTTON CONFIGURATION"
+ input name: "waittoHeld", type: "decimal", title: "Minimum hold time required for button held:", description: "Number of seconds (default = 2.0)", range: "0.1..120"
+ input name: "holdIsButton2", type: "bool", title: "On button hold, send 'button 2 pushed' event instead of 'button 1 held'?"
+
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Advanced Settings
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ //Battery Voltage Range
+ input description: "", type: "paragraph", element: "paragraph", title: "BATTERY VOLTAGE RANGE"
+ input name: "voltsmax", type: "decimal", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", type: "decimal", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", range: "2..2.7", defaultValue: 2.5
+ //Live Logging Message Display Config
+ input description: "These settings affect the display of messages in the Live Logging tab of the SmartThings IDE.", type: "paragraph", element: "paragraph", title: "LIVE LOGGING"
+ input name: "infoLogging", type: "bool", title: "Display info log messages?", defaultValue: true
+ input name: "debugLogging", type: "bool", title: "Display debug log messages?"
+ }
+}
+
+//adds functionality to press the centre tile as a virtualApp Button
+def push() {
+ displayInfoLog(": Virtual App Button Pressed")
+ sendEvent(name: "lastPressed", value: formatDate(), displayed: false)
+ sendEvent(name: "lastPressedCoRE", value: now(), displayed: false)
+ sendEvent(name: "button", value: "pushed", data: [buttonNumber: 1], descriptionText: "$device.displayName app button was pushed", isStateChange: true)
+ sendEvent(name: "lastReleasedCoRE", value: now(), displayed: false)
+ runIn(1, clearButtonStatus)
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ displayDebugLog(": Parsing '${description}'")
+ def result = [:]
+
+ // Any report - button press & Battery - results in a lastCheckin event and update to Last Checkin tile
+ sendEvent(name: "lastCheckin", value: formatDate(), displayed: false)
+ sendEvent(name: "lastCheckinCoRE", value: now(), displayed: false)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description == 'on/off: 0') {
+ updateLastPressed("Pressed")
+ } else if (description == 'on/off: 1') {
+ result = createButtonEvent()
+ updateLastPressed("Released")
+ } else if (description?.startsWith('catchall:')) {
+ result = parseCatchAllMessage(description)
+ } else if (description?.startsWith('read attr - raw: ')) {
+ result = parseReadAttrMessage(description)
+ }
+ if (result != [:]) {
+ displayDebugLog(": Creating event $result")
+ return createEvent(result)
+ } else
+ return [:]
+}
+
+// on any type of button pressed update lastPressed and lastPressedCoRE or lastReleasedCoRE to current date/time
+def updateLastPressed(pressType) {
+ if (pressType == "Pressed")
+ displayInfoLog(": Button press detected")
+ sendEvent(name: "lastPressed", value: formatDate(), displayed: false)
+ displayDebugLog(": Setting Last $pressType to current date/time")
+ sendEvent(name: "last${pressType}CoRE", value: now(), displayed: false)
+ sendEvent(name: "lastButtonMssg", value: now(), displayed: false)
+}
+
+private createButtonEvent() {
+ def timeDif = now() - device.latestState('lastButtonMssg').date.getTime()
+ def holdTimeMillisec = Math.round((settings.waittoHeld?:2.0) * 1000)
+ displayInfoLog(": Button release detected")
+ displayDebugLog(": Comparing time difference between this button release and last button message")
+ displayDebugLog(": Time difference = $timeDif ms, Hold time setting = $holdTimeMillisec ms")
+ // compare waittoHeld setting with difference between current time and lastButtonMssg
+ def buttonHeld = (timeDif >= holdTimeMillisec & timeDif < holdTimeMillisec + 10000) ? true : false
+ def eventValue = (buttonHeld & !(holdIsButton2 == true)) ? "held" : "pushed"
+ def buttonNum = (buttonHeld & holdIsButton2 == true) ? 2 : 1
+ def pressType = buttonHeld ? "held" : "pushed"
+ def descText = " was $pressType (button $buttonNum $eventValue)"
+ sendEvent(name: "buttonStatus", value: pressType, isStateChange: true, displayed: false)
+ runIn(1, clearButtonStatus)
+ displayInfoLog(descText)
+ return [
+ name: 'button',
+ value: eventValue,
+ data: [buttonNumber: buttonNum],
+ descriptionText: "$device.displayName$descText",
+ isStateChange: true
+ ]
+}
+
+def clearButtonStatus() {
+ sendEvent(name: "buttonStatus", value: "released", isStateChange: true, displayed: false)
+}
+
+private Map parseReadAttrMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ def data = ""
+ def modelName = ""
+ def model = value
+ Map resultMap = [:]
+
+ // Process message on short-button press containing model name and battery voltage report
+ if (cluster == "0000" && attrId == "0005") {
+ if (value.length() > 45) {
+ model = value.split("02FF")[0]
+ data = value.split("02FF")[1]
+ if (data[4..7] == "0121") {
+ def BatteryVoltage = (Integer.parseInt((data[10..11] + data[8..9]),16))
+ resultMap = getBatteryResult(BatteryVoltage)
+ }
+ data = ", data: ${value.split("02FF")[1]}"
+ }
+
+ // Parsing the model name
+ for (int i = 0; i < model.length(); i+=2) {
+ def str = model.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ displayDebugLog(" reported model: $modelName$data")
+ }
+ return resultMap
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts = voltsmin ? voltsmin : 2.5
+ def maxVolts = voltsmax ? voltsmax : 3.0
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+ def descText = "Battery at ${roundedPct}% (${rawVolts} Volts)"
+ displayInfoLog(": $descText")
+ return [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "$device.displayName $descText"
+ ]
+}
+
+private def displayDebugLog(message) {
+ if (debugLogging)
+ log.debug "${device.displayName}${message}"
+}
+
+private def displayInfoLog(message) {
+ if (infoLogging || state.prefsSetCount < 3)
+ log.info "${device.displayName}${message}"
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: formatDate(true))
+ displayInfoLog(": Setting Battery Changed to current date${newlyPaired}")
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.prefsSetCount = 0
+ displayInfoLog(": Installing")
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime(true)
+ checkIntervalEvent("")
+ sendEvent(name: "numberOfButtons", value: 1)
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ def numButtons = (holdIsButton2 == true) ? 2 : 1
+ displayInfoLog(": Configuring")
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime(true)
+ clearButtonStatus()
+ checkIntervalEvent("configured")
+ sendEvent(name: "numberOfButtons", value: numButtons)
+ sendEvent(name: "lastButtonMssg", value: now(), displayed: false)
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ def numButtons = (holdIsButton2 == true) ? 2 : 1
+ displayInfoLog(": Updating preference settings")
+ if (!state.prefsSetCount)
+ state.prefsSetCount = 1
+ else if (state.prefsSetCount < 3)
+ state.prefsSetCount = state.prefsSetCount + 1
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime()
+ if (battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+ clearButtonStatus()
+ sendEvent(name: "numberOfButtons", value: numButtons)
+ displayInfoLog(": Number of buttons = $numButtons")
+ displayInfoLog(": Info message logging enabled")
+ displayDebugLog(": Debug message logging enabled")
+ checkIntervalEvent("preferences updated")
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ if (text)
+ displayInfoLog(": Set health checkInterval when ${text}")
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-button.src/xiaomi-button.groovy b/devicetypes/bspranger/xiaomi-button.src/xiaomi-button.groovy
new file mode 100644
index 00000000..f92c078d
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-button.src/xiaomi-button.groovy
@@ -0,0 +1,393 @@
+/**
+ * Xiaomi Zigbee Button - model WXKG01LM
+ * Device Handler for SmartThings - Firmware version 25.20 and newer ONLY
+ * Version 1.3
+ *
+ * NOTE: Do NOT use this device handler on any SmartThings hub running Firmware 24.x and older
+ * Instead use the xiaomi-button-old-firmware device handler
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger, updated for changes in firmware 25.20 by veeceeoh
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * + As of March 2019, the SmartThings Samsung Connect mobile app does NOT support custom device handlers such as this one
+ * + The SmartThings Classic mobile app UI text/graphics is rendered differently on iOS vs Android devices - This is due to SmartThings, not this device handler
+ * + Pairing Xiaomi/Aqara devices can be difficult as they were not designed to use with a SmartThings hub.
+ * + The battery level is not reported at pairing. Wait for the first status report, 50-60 minutes after pairing.
+ * + Xiaomi devices do not respond to refresh requests
+ * + Most ZigBee repeater devices (generally mains-powered ZigBee devices) are NOT compatible with Xiaomi/Aqara devices, causing them to drop off the network.
+ * + Only XBee ZigBee modules, the IKEA Tradfri Outlet / Tradfri Bulb, and ST user @iharyadi's custom multi-sensor ZigBee repeater device are confirmed to be compatible.
+ *
+ */
+
+ import groovy.json.JsonOutput
+ import physicalgraph.zigbee.zcl.DataType
+
+metadata {
+ definition (name: "Xiaomi Button", namespace: "bspranger", author: "bspranger", minHubCoreVersion: "000.022.0002", ocfDeviceType: "x.com.st.d.remotecontroller") {
+ capability "Actuator"
+ capability "Battery"
+ capability "Button"
+ capability "Configuration"
+ capability "Health Check"
+ capability "Holdable Button"
+ capability "Momentary"
+ capability "Sensor"
+
+ attribute "lastCheckin", "string"
+ attribute "lastCheckinCoRE", "string"
+ attribute "lastPressed", "string"
+ attribute "lastPressedCoRE", "string"
+ attribute "lastReleasedCoRE", "string"
+ attribute "lastButtonMssg", "string"
+ attribute "batteryRuntime", "string"
+ attribute "buttonStatus", "enum", ["pushed", "held", "released", "double", "triple", "quadruple", "shizzle"]
+
+ fingerprint deviceId: "0104", inClusters: "0000,0003,FFFF,0019", outClusters: "0000,0004,0003,0006,0008,0005,0019", manufacturer: "LUMI", model: "lumi.sensor_switch", deviceJoinName: "Original Xiaomi Button"
+
+ command "resetBatteryRuntime"
+ }
+
+ simulator {
+ status "Press button": "on/off: 0"
+ status "Release button": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"buttonStatus", type: "lighting", width: 6, height: 4, canChangeIcon: false) {
+ tileAttribute ("device.buttonStatus", key: "PRIMARY_CONTROL") {
+ attributeState("default", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("pushed", label:'Pushed', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("held", label:'Held', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("released", label:'Released', action: "momentary.push", backgroundColor:"#ffffff", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonReleased.png")
+ attributeState("double", label:'Double-Clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("triple", label:'Triple-Clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("quadruple", label:'Quadruple-Clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ attributeState("shizzle", label:'Shizzle-Clicked', backgroundColor:"#00a0dc", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/ButtonPushed.png")
+ }
+ tileAttribute("device.lastPressed", key: "SECONDARY_CONTROL") {
+ attributeState "lastPressed", label:'Last Pressed: ${currentValue}'
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("lastCheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "lastCheckin", label:'Last Event:\n${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+ main (["buttonStatus"])
+ details(["buttonStatus","battery","lastCheckin","batteryRuntime"])
+ }
+
+ preferences {
+ //Button Config
+ input description: "As a default if the button is held for at least 2 seconds, a 'button 1 held' event is sent when released. The settings below allow changes to the minimum time needed for held.", type: "paragraph", element: "paragraph", title: "BUTTON CONFIGURATION"
+ input name: "waittoHeld", type: "decimal", title: "Minimum hold time required for button held:", description: "Number of seconds (default = 2.0)", range: "0.1..120"
+
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Advanced Settings
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ //Battery Voltage Range
+ input description: "", type: "paragraph", element: "paragraph", title: "BATTERY VOLTAGE RANGE"
+ input name: "voltsmax", type: "decimal", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", type: "decimal", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", range: "2..2.7", defaultValue: 2.5
+ //Live Logging Message Display Config
+ input description: "These settings affect the display of messages in the Live Logging tab of the SmartThings IDE.", type: "paragraph", element: "paragraph", title: "LIVE LOGGING"
+ input name: "infoLogging", type: "bool", title: "Display info log messages?", defaultValue: true
+ input name: "debugLogging", type: "bool", title: "Display debug log messages?"
+ }
+}
+
+//adds functionality to press the centre tile as a virtualApp Button
+def push() {
+ displayInfoLog(": Virtual App Button Pressed")
+ sendEvent(name: "lastPressed", value: formatDate(), displayed: false)
+ sendEvent(name: "lastPressedCoRE", value: now(), displayed: false)
+ sendEvent(name: "button", value: "pushed", data: [buttonNumber: 1], descriptionText: "$device.displayName app button was pushed", isStateChange: true)
+ sendEvent(name: "lastReleasedCoRE", value: now(), displayed: false)
+ runIn(1, clearButtonStatus)
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ displayDebugLog(": Parsing '${description}'")
+ def result = [:]
+
+ // Any report - button press & Battery - results in a lastCheckin event and update to Last Checkin tile
+ sendEvent(name: "lastCheckin", value: formatDate(), displayed: false)
+ sendEvent(name: "lastCheckinCoRE", value: now(), displayed: false)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description == 'on/off: 0') {
+ updateLastPressed("Pressed")
+ } else if (description == 'on/off: 1') {
+ result = createButtonEvent()
+ updateLastPressed("Released")
+ } else if (description?.startsWith('read attr - raw: ')) {
+ result = parseReadAttrMessage(description)
+ } else if (description?.startsWith('catchall:')) {
+ result = parseCatchAllMessage(description)
+ }
+ if (result != [:]) {
+ displayDebugLog(": Creating event $result")
+ return createEvent(result)
+ } else
+ return [:]
+}
+
+// on any type of button pressed update lastPressed and lastPressedCoRE or lastReleasedCoRE to current date/time
+def updateLastPressed(pressType) {
+ if (pressType == "Pressed")
+ displayInfoLog(": Single button press detected")
+ sendEvent(name: "lastPressed", value: formatDate(), displayed: false)
+ displayDebugLog(": Setting Last $pressType to current date/time")
+ sendEvent(name: "last${pressType}CoRE", value: now(), displayed: false)
+ sendEvent(name: "lastButtonMssg", value: now(), displayed: false)
+}
+
+private createButtonEvent() {
+ def timeDif = now() - device.latestState('lastButtonMssg').date.getTime()
+ def holdTimeMillisec = Math.round((settings.waittoHeld?:2.0) * 1000)
+ displayInfoLog(": Button release detected")
+ displayDebugLog(": Comparing time difference between this button release and last button message")
+ displayDebugLog(": Time difference = $timeDif ms, Hold time setting = $holdTimeMillisec ms")
+ // compare waittoHeld setting with difference between current time and lastButtonMssg
+ def buttonHeld = (timeDif >= holdTimeMillisec & timeDif < holdTimeMillisec + 10000) ? true : false
+ def pressType = buttonHeld ? "held" : "pushed"
+ def descText = " was $pressType (button 1 $pressType)"
+ sendEvent(name: "buttonStatus", value: pressType, isStateChange: true, displayed: false)
+ runIn(1, clearButtonStatus)
+ displayInfoLog(descText)
+ return [
+ name: 'button',
+ value: pressType,
+ data: [buttonNumber: 1],
+ descriptionText: "$device.displayName$descText",
+ isStateChange: true
+ ]
+}
+
+def clearButtonStatus() {
+ sendEvent(name: "buttonStatus", value: "released", isStateChange: true, displayed: false)
+}
+
+private Map parseReadAttrMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def valueHex = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ Map resultMap = [:]
+
+ // Process message for double-click, triple-click, quadruple-click, and 5 or more-click
+ if (cluster == "0006" && attrId == "8000") {
+ def buttonNum = (valueHex == "80") ? 5 : Integer.parseInt(valueHex, 16)
+ def clickType = [2: "double", 3: "triple", 4: "quadruple", 5: "shizzle"]
+ def descText = " was ${clickType[buttonNum]}-clicked (Button $buttonNum pushed)"
+ sendEvent(name: "buttonStatus", value: clickType[buttonNum], isStateChange: true, displayed: false)
+ runIn(1, clearButtonStatus)
+ displayInfoLog(descText)
+ resultMap = [
+ name: 'button',
+ value: 'pushed',
+ data: [buttonNumber: buttonNum],
+ descriptionText: "$device.displayName$descText",
+ isStateChange: true
+ ]
+ }
+ // Process message on short-button press containing model name and battery voltage report
+ else if (cluster == "0000" && attrId == "0005") {
+ def data = ""
+ def modelName = ""
+ def model = valueHex
+ if (valueHex.length() > 45) {
+ model = valueHex.split("02FF")[0]
+ data = valueHex.split("02FF")[1]
+ if (data[4..7] == "0121") {
+ def BatteryVoltage = (Integer.parseInt((data[10..11] + data[8..9]), 16))
+ resultMap = getBatteryResult(BatteryVoltage)
+ }
+ data = ", data: ${valueHex.split("02FF")[1]}"
+ }
+ // Parsing the model name
+ for (int i = 0; i < model.length(); i+=2) {
+ def str = model.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ displayDebugLog(" reported model: $modelName$data")
+ }
+ return resultMap
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts = voltsmin ? voltsmin : 2.5
+ def maxVolts = voltsmax ? voltsmax : 3.0
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+ def descText = "Battery at ${roundedPct}% (${rawVolts} Volts)"
+ displayInfoLog(": $descText")
+ return [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "$descText"
+ ]
+}
+
+private def displayDebugLog(message) {
+ if (debugLogging)
+ log.debug "${device.displayName}${message}"
+}
+
+private def displayInfoLog(message) {
+ if (infoLogging || state.prefsSetCount < 3)
+ log.info "${device.displayName}${message}"
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: formatDate(true))
+ displayInfoLog(": Setting Battery Changed to current date${newlyPaired}")
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.prefsSetCount = 0
+ displayInfoLog(": Installing")
+ // initialize battery replaced date
+ initialize(true)
+ // initialize default button states
+ sendEvent(name: "button", value: "pushed", data: [buttonNumber: 1], displayed: false)
+ checkIntervalEvent("")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ displayInfoLog(": Configuring")
+ initialize(false)
+ device.currentValue("numberOfButtons")?.times {
+ sendEvent(name: "button", value: "pushed", data: [buttonNumber: it+1], displayed: false)
+ }
+ sendEvent(name: "lastButtonMssg", value: now(), displayed: false)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ displayInfoLog(": Updating preference settings")
+ if (!state.prefsSetCount)
+ state.prefsSetCount = 1
+ else if (state.prefsSetCount < 3)
+ state.prefsSetCount = state.prefsSetCount + 1
+ initialize(false)
+ // set battery replaced date if user toggled preference setting
+ if (battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+ displayInfoLog(": Info message logging enabled")
+ displayDebugLog(": Debug message logging enabled")
+ checkIntervalEvent("preferences updated")
+}
+
+def initialize (paired) {
+ sendEvent(name: "DeviceWatch-Enroll", value: JsonOutput.toJson([protocol: "zigbee", scheme:"untracked"]), displayed: false)
+ // initialize battery replaced date if not yet set
+ if (!device.currentState('batteryRuntime')?.value)
+ resetBatteryRuntime(paired)
+ clearButtonStatus()
+ if (device.currentValue("numberOfButtons") != 5) {
+ sendEvent(name: "numberOfButtons", value: 5, displayed: false)
+ displayInfoLog(": Number of buttons set to 5")
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ if (text)
+ displayInfoLog(": Set health checkInterval when ${text}")
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-door-window-sensor.src/xiaomi-door-window-sensor.groovy b/devicetypes/bspranger/xiaomi-door-window-sensor.src/xiaomi-door-window-sensor.groovy
new file mode 100644
index 00000000..2071875c
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-door-window-sensor.src/xiaomi-door-window-sensor.groovy
@@ -0,0 +1,313 @@
+/**
+ * Xiaomi Door/Window Sensor
+ * Version 1.1
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub.
+ *
+ */
+metadata {
+ definition (name: "Xiaomi Door/Window Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Configuration"
+ capability "Sensor"
+ capability "Contact Sensor"
+ capability "Battery"
+ capability "Health Check"
+
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinDate", "Date"
+ attribute "lastOpened", "String"
+ attribute "lastOpenedDate", "Date"
+ attribute "batteryRuntime", "String"
+
+ fingerprint endpointId: "01", profileId: "0104", deviceId: "0104", inClusters: "0000, 0003, FFFF, 0019", outClusters: "0000, 0004, 0003, 0006, 0008, 0005 0019", manufacturer: "LUMI", model: "lumi.sensor_magnet", deviceJoinName: "Xiaomi Door Sensor"
+
+ command "resetBatteryRuntime"
+ command "resetClosed"
+ command "resetOpen"
+ }
+
+ simulator {
+ status "closed": "on/off: 0"
+ status "open": "on/off: 1"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"contact", type: "generic", width: 6, height: 4){
+ tileAttribute ("device.contact", key: "PRIMARY_CONTROL") {
+ attributeState "open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#e86d13"
+ attributeState "closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#00a0dc"
+ }
+ tileAttribute("device.lastOpened", key: "SECONDARY_CONTROL") {
+ attributeState("default", label:'Last Opened: ${currentValue}')
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors: [
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("spacer", "spacer", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("lastcheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "default", label:'Last Event:\n${currentValue}'
+ }
+ standardTile("resetClosed", "device.resetClosed", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetClosed", label: "Override Close", icon:"st.contact.contact.closed"
+ }
+ standardTile("resetOpen", "device.resetOpen", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetOpen", label: "Override Open", icon:"st.contact.contact.open"
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed:\n ${currentValue}'
+ }
+ main (["contact"])
+ details(["contact","battery","resetClosed","resetOpen","spacer","lastcheckin", "spacer", "spacer", "batteryRuntime", "spacer"])
+ }
+ preferences {
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\n US (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3, required: false
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5, required: false
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName}: Parsing '${description}'"
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+ // Any report - button press & Battery - results in a lastCheckin event and update to Last Checkin tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ Map map = [:]
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith('on/off: ')) {
+ map = parseCustomMessage(description)
+ if (map.value == "open") {
+ sendEvent(name: "lastOpened", value: now, displayed: false)
+ sendEvent(name: "lastOpenedDate", value: nowDate, displayed: false)
+ }
+ }
+ else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ }
+ else if (description?.startsWith("read attr - raw: ")) {
+ map = parseReadAttrMessage(description)
+ }
+ log.debug "${device.displayName}: Parse returned $map"
+ def results = map ? createEvent(map) : null
+
+ return results;
+}
+
+private Map parseReadAttrMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+ def result = [
+ name: 'Model',
+ value: ''
+ ]
+
+ if (cluster == "0000" && attrId == "0005") {
+ // Parsing the model
+ for (int i = 0; i < value.length(); i+=2) {
+ def str = value.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ result.value = result.value + NextChar
+ }
+ }
+ return result
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts
+ def maxVolts
+
+ if(voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if(voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange: true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ log.debug "${device.displayName}: ${result}"
+ return result
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+private Map parseCustomMessage(String description) {
+ def result
+ if (description?.startsWith('on/off: ')) {
+ if (description == 'on/off: 0') //contact closed
+ result = getContactResult("closed")
+ else if (description == 'on/off: 1') //contact opened
+ result = getContactResult("open")
+ return result
+ }
+}
+
+private Map getContactResult(value) {
+ def descriptionText = "${device.displayName} was ${value == 'open' ? 'opened' : 'closed'}"
+ return [
+ name: 'contact',
+ value: value,
+ isStateChange: true,
+ descriptionText: descriptionText
+ ]
+}
+
+def resetClosed() {
+ sendEvent(name: "contact", value: "closed", descriptionText: "${device.displayName} was manually reset to closed")
+}
+
+def resetOpen() {
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+ sendEvent(name: "lastOpened", value: now, displayed: false)
+ sendEvent(name: "lastOpenedDate", value: nowDate, displayed: false)
+ sendEvent(name: "contact", value: "open", descriptionText: "${device.displayName} was manually reset to open")
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("installed")
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated");
+ if(battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-mijia-honeywell-fire-detector.src/xiaomi-mijia-honeywell-fire-detector.groovy b/devicetypes/bspranger/xiaomi-mijia-honeywell-fire-detector.src/xiaomi-mijia-honeywell-fire-detector.groovy
new file mode 100644
index 00000000..7c2e1777
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-mijia-honeywell-fire-detector.src/xiaomi-mijia-honeywell-fire-detector.groovy
@@ -0,0 +1,377 @@
+/**
+ * Xiaomi Mijia Honeywell Fire Detector
+ * Version 0.51
+ *
+ * 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.
+ *
+ * Contributions to code from alecm, alixjg, bspranger, gn0st1c, Inpier, foz333, jmagnuson, KennethEvers, rinkek, ronvandegraaf, snalee, tmleaf
+ * Discussion board for this DH: https://community.smartthings.com/t/original-aqara-xiaomi-zigbee-sensors-contact-temp-motion-button-outlet-leak-etc/
+ *
+ * Useful Links:
+ * Review in english photos dimensions etc... https://blog.tlpa.nl/2017/11/12/xiaomi-also-mijia-and-honeywell-smart-fire-detector/
+ * Device purchased here (€20.54)... https://www.gearbest.com/alarm-systems/pp_615081.html
+ * RaspBee packet sniffer... https://github.com/dresden-elektronik/deconz-rest-plugin/issues/152
+ * Instructions in English.. http://files.xiaomi-mi.com/files/MiJia_Honeywell/MiJia_Honeywell_Smoke_Detector_EN.pdf
+ * Fire Certification is CCCF... https://www.china-certification.com/en/ccc-certification-for-fire-safety-products-cccf/
+ * ... in order to be covered by your insurance and for piece of mind, please also use correctly certified detectors if CCCF is not accepted in your country
+ *
+ * Battery: The device is powered by a CR123a
+ * ... battery life circa 5 years
+ *
+ * Todo:
+ * Possible to force alarm test from application?
+ * ... manual simulation mode activated by holding physical button for 3 seconds
+ * Possible to set installation site
+ * ... apparently with MiApp you can choose from 3 sites to adjust sensitivity
+ * ... Screenshot of app option here: http://www.cooltechbox.com/review-xiaomi-mijia-honeywell-smoke-detector/
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests // workaround... push physical button 1 time for refresh
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub, for this one, normally just tap main button 3 times
+ *
+ * Fingerprint Endpoint data:
+ * 01 - endpoint id
+ * 0104 - profile id
+ * 0402 - device id
+ * 01 - ignored
+ * 06 - number of in clusters
+ * 0000 0003 0012 0500 000C 0001 - inClusters
+ * 01 - number of out clusters
+ * 0019 - outClusters
+ * manufacturer "LUMI" - must match manufacturer field in fingerprint
+ * model "lumi.sensor_smoke" - must match model in fingerprint
+ *
+ *
+ * Change Log:
+ * 14.02.2018 - foz333 - Version 0.5 Released
+ * 19.02.2018 - test state tile added, smoke replaces fire for SHM support
+ * 23.02.2018 - new battery icon introdused, default volatge set to 3.25
+ */
+
+metadata {
+ definition (name: "Xiaomi Mijia Honeywell Fire Detector", namespace: "bspranger", author: "bspranger") {
+ capability "Battery" //attributes: battery
+ capability "Configuration" //commands: configure()
+ capability "Smoke Detector" //attributes: smoke ("detected","clear","tested")
+
+ capability "Health Check"
+ capability "Sensor"
+
+ command "resetClear"
+ command "resetSmoke"
+ command "resetBatteryRuntime"
+ command "enrollResponse"
+
+ attribute "lastTested", "String"
+ attribute "lastTestedDate", "Date"
+ attribute "lastCheckinDate", "Date"
+ attribute "lastCheckin", "string"
+ attribute "lastSmoke", "String"
+ attribute "lastSmokeDate", "Date"
+ attribute "batteryRuntime", "String"
+
+ fingerprint endpointId: "01", profileID: "0104", deviceID: "0402", inClusters: "0000,0003,0012,0500,000C,0001", outClusters: "0019", manufacturer: "LUMI", model: "lumi.sensor_smoke", deviceJoinName: "Xiaomi Honeywell Smoke Detector"
+ }
+
+ // simulator metadata
+ simulator {
+ }
+
+ preferences {
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\nUS (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?", description: ""
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts.\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3.25
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing)\nat __ volts. Range 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"smoke", type: "lighting", width: 6, height: 4) {
+ tileAttribute ("device.smoke", key: "PRIMARY_CONTROL") {
+ attributeState( "clear", label:'CLEAR', icon:"st.alarm.smoke.clear", backgroundColor:"#ffffff")
+ attributeState( "tested", label:"TEST", icon:"st.alarm.smoke.test", backgroundColor:"#e86d13")
+ attributeState( "detected", label:'SMOKE', icon:"st.alarm.smoke.smoke", backgroundColor:"#ed0920")
+ }
+ tileAttribute("device.lastSmoke", key: "SECONDARY_CONTROL") {
+ attributeState "default", label:'Smoke last detected:\n ${currentValue}'
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+/*
+ // Will only override applications settings not physical device
+ standardTile("resetClear", "device.resetSmoke", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetSmoke", label:'Override Clear', icon:"st.alarm.smoke.smoke"
+ }
+ standardTile("resetSmoke", "device.resetClear", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"resetClear", label:'Override Smoke', icon:"st.alarm.smoke.clear"
+ }
+*/
+ valueTile("lastTested", "device.lastTested", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "default", label:'Last Tested:\n ${currentValue}'
+ }
+ valueTile("spacer", "spacer", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("lastcheckin", "device.lastCheckin", inactiveLabel: false, decoration:"flat", width: 4, height: 1) {
+ state "lastcheckin", label:'Last Event:\n ${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration:"flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+
+ main (["smoke"])
+ details(["smoke", "battery",
+// "resetClear", "resetSmoke",
+ "lastTested", "lastcheckin", "spacer", "batteryRuntime", "spacer"])
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName}: Parsing description: ${description}"
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+
+ // Any report - test, smoke, clear in a lastCheckin event and update to Last Checkin tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ // getEvent automatically retrieves temp and humidity in correct unit as integer
+ Map map = zigbee.getEvent(description)
+
+ if (description?.startsWith('zone status')) {
+ map = parseZoneStatusMessage(description)
+ if (map.value == "detected") {
+ sendEvent(name: "lastSmoke", value: now, displayed: false)
+ sendEvent(name: "lastSmokeDate", value: nowDate, displayed: false)
+ } else if (map.value == "tested") {
+ sendEvent(name: "lastTested", value: now, displayed: false)
+ sendEvent(name: "lastTestedDate", value: nowDate, displayed: false)
+ }
+ } else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ } else if (description?.startsWith('read attr - raw:')) {
+ map = parseReadAttr(description)
+ } else if (description?.startsWith('enroll request')) {
+ List cmds = zigbee.enrollResponse()
+ log.debug "enroll response: ${cmds}"
+ result = cmds?.collect { new physicalgraph.device.HubAction(it) }
+ } else {
+ log.debug "${device.displayName}: was unable to parse ${description}"
+ sendEvent(name: "lastCheckin", value: now)
+ }
+ if (map) {
+ log.debug "${device.displayName}: Parse returned ${map}"
+ return createEvent(map)
+ } else {
+ return [:]
+ }
+}
+
+// Parse the IAS messages
+private Map parseZoneStatusMessage(String description) {
+ def result = [
+ name: 'smoke',
+ value: value,
+ descriptionText: 'smoke detected'
+ ]
+ if (description?.startsWith('zone status')) {
+ if (description?.startsWith('zone status 0x0002')) { // User Test
+ result.value = "tested"
+ result.descriptionText = "${device.displayName} has been tested"
+ } else if (description?.startsWith('zone status 0x0001')) { // smoke detected
+ result.value = "detected"
+ result.descriptionText = "${device.displayName} has detected smoke"
+ } else if (description?.startsWith('zone status 0x0000')) { // situation normal... no smoke
+ result.value = "clear"
+ result.descriptionText = "${device.displayName} is all clear"
+ }
+ return result
+ }
+ return [:]
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Original Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Parse raw data on reset button press
+private Map parseReadAttr(String description) {
+ Map resultMap = [:]
+
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+
+ log.debug "${device.displayName}: Parsing read attr: cluster: ${cluster}, attrId: ${attrId}, value: ${value}"
+
+ if (cluster == "0000" && attrId == "0005") {
+ def modelName = ""
+ // Parsing the model name
+ for (int i = 0; i < value.length(); i+=2) {
+ def str = value.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ log.debug "${device.displayName}: Reported model: ${modelName}"
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts
+ def maxVolts
+
+ if (voltsmin == null || voltsmin == ""){
+ minVolts = 2.5
+ } else {
+ minVolts = voltsmin
+ }
+
+ if (voltsmax == null || voltsmax == "") {
+ maxVolts = 3.0
+ } else {
+ maxVolts = voltsmax
+ }
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange: true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ return result
+}
+
+def resetClear() {
+ sendEvent(name:"smoke", value:"clear")
+}
+
+def resetSmoke() {
+ sendEvent(name:"smoke", value:"smoke")
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true){
+ checkIntervalEvent("installed")
+ }
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true){
+ checkIntervalEvent("configured")
+ }
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated")
+ if(battReset) {
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+// Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+private checkIntervalEvent(text) {
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ } else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset){
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ } else {
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ } else if (dateformat == "UK") {
+ if (batteryReset) {
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ } else {
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ } else {
+ if (batteryReset) {
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ } else {
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-motion-sensor.src/xiaomi-motion-sensor.groovy b/devicetypes/bspranger/xiaomi-motion-sensor.src/xiaomi-motion-sensor.groovy
new file mode 100644
index 00000000..6dc28332
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-motion-sensor.src/xiaomi-motion-sensor.groovy
@@ -0,0 +1,299 @@
+/**
+ * Xiaomi Motion Sensor
+ * Version 1.1
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad, adapted for use with Aqara model by bspranger
+ * Additional contributions to code by alecm, alixjg, bspranger, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub. See
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Motion Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Motion Sensor"
+ capability "Configuration"
+ capability "Battery"
+ capability "Sensor"
+ capability "Health Check"
+
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinDate", "String"
+ attribute "lastMotion", "String"
+ attribute "batteryRuntime", "String"
+
+ fingerprint profileId: "0104", deviceId: "0104", inClusters: "0000, 0003, FFFF, 0019", outClusters: "0000, 0004, 0003, 0006, 0008, 0005, 0019", manufacturer: "LUMI", model: "lumi.sensor_motion", deviceJoinName: "Xiaomi Motion"
+
+ command "resetBatteryRuntime"
+ command "stopMotion"
+
+ }
+
+ simulator {
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"motion", type: "generic", width: 6, height: 4) {
+ tileAttribute ("device.motion", key: "PRIMARY_CONTROL") {
+ attributeState "active", label:'motion', icon:"st.motion.motion.active", backgroundColor:"#00a0dc"
+ attributeState "inactive", label:'no motion', icon:"st.motion.motion.inactive", backgroundColor:"#ffffff"
+ }
+ tileAttribute("device.lastMotion", key: "SECONDARY_CONTROL") {
+ attributeState("default", label:'Last Motion: ${currentValue}')
+ }
+ }
+ valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors: [
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("spacer1", "spacer1", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("spacer2", "spacer2", decoration: "flat", inactiveLabel: false, width: 1, height: 2) {
+ state "default", label:''
+ }
+ standardTile("reset", "device.reset", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", action:"stopMotion", label: "Reset Motion", icon:"st.motion.motion.active"
+ }
+ valueTile("lastcheckin", "device.lastCheckin", decoration: "flat", inactiveLabel: false, width: 4, height: 1) {
+ state "default", label:'Last Event:\n ${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed:\n ${currentValue}'
+ }
+ main(["motion"])
+ details(["motion", "spacer2", "battery", "reset", "spacer2", "spacer1", "lastcheckin", "spacer1", "spacer1", "batteryRuntime", "spacer1"])
+ }
+
+ preferences {
+ //Reset to No Motion Config
+ input description: "This setting only changes how long MOTION DETECTED is reported in SmartThings. The sensor hardware always remains blind to motion for 60 seconds after any activity.", type: "paragraph", element: "paragraph", title: "MOTION RESET"
+ input "motionreset", "number", title: "", description: "Enter number of seconds (default = 60)", range: "1..7200"
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\n US (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?"
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3, required: false
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing) at __ volts\nRange 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5, required: false
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName} Parsing: $description"
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+
+ // Any report - motion, lux & Battery - results in a lastCheckin event and update to Last Event tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ Map map = [:]
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (description?.startsWith('read attr -')) {
+ map = parseReportAttributeMessage(description)
+ }
+ else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ }
+
+ log.debug "${device.displayName} Parse returned: $map"
+ def result = map ? createEvent(map) : null
+
+ return result
+}
+
+// Parse motion active report or model name message on reset button press
+private Map parseReportAttributeMessage(String description) {
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+
+ Map resultMap = [:]
+ def now = formatDate()
+
+ // The sensor only sends a motion detected message so the reset to no motion is performed in code
+ if (cluster == "0406" & value == "01") {
+ log.debug "${device.displayName} detected motion"
+ def seconds = motionreset ? motionreset : 60
+ resultMap = [
+ name: 'motion',
+ value: 'active',
+ descriptionText: "${device.displayName} detected motion"
+ ]
+ sendEvent(name: "lastMotion", value: now, displayed: false)
+ runIn(seconds, stopMotion)
+ }
+ else if (cluster == "0000" && attrId == "0005") {
+ def modelName = ""
+ // Parsing the model
+ for (int i = 0; i < value.length(); i+=2) {
+ def str = value.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ log.debug "${device.displayName} reported: cluster: ${cluster}, attrId: ${attrId}, model:${modelName}"
+ }
+ return resultMap
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts
+ def maxVolts
+
+ if (voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if (voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange: true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ return result
+}
+
+// If currently in 'active' motion detected state, stopMotion() resets to 'inactive' state and displays 'no motion'
+def stopMotion() {
+ if (device.currentState('motion')?.value == "active") {
+ def seconds = motionreset ? motionreset : 60
+ sendEvent(name:"motion", value:"inactive", isStateChange: true)
+ log.debug "${device.displayName} reset to no motion after ${seconds} seconds"
+ }
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("installed")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated")
+ if(battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-temperature-humidity-sensor.src/xiaomi-temperature-humidity-sensor.groovy b/devicetypes/bspranger/xiaomi-temperature-humidity-sensor.src/xiaomi-temperature-humidity-sensor.groovy
new file mode 100644
index 00000000..916c9d45
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-temperature-humidity-sensor.src/xiaomi-temperature-humidity-sensor.groovy
@@ -0,0 +1,404 @@
+/**
+ * Xiaomi Temperature Humidity Sensor - model WSDCGQ01LM
+ * Version 1.3.1
+ *
+ *
+ * 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.
+ *
+ * Original device handler code by a4refillpad
+ * Additional contributions to code by alecm, alixjg, bspranger, cscheiene, gn0st1c, foz333, jmagnuson, rinkek, ronvandegraaf, snalee, tmleafs, twonk, & veeceeoh
+ *
+ * Known issues:
+ * Xiaomi sensors do not seem to respond to refresh requests
+ * Inconsistent rendering of user interface text/graphics between iOS and Android devices - This is due to SmartThings, not this device handler
+ * Pairing Xiaomi sensors can be difficult as they were not designed to use with a SmartThings hub.
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Temperature Humidity Sensor", namespace: "bspranger", author: "bspranger") {
+ capability "Temperature Measurement"
+ capability "Relative Humidity Measurement"
+ capability "Sensor"
+ capability "Battery"
+ capability "Health Check"
+
+ attribute "lastCheckin", "String"
+ attribute "lastCheckinDate", "String"
+ attribute "maxTemp", "number"
+ attribute "minTemp", "number"
+ attribute "maxHumidity", "number"
+ attribute "minHumidity", "number"
+ attribute "multiAttributesReport", "String"
+ attribute "currentDay", "String"
+ attribute "batteryRuntime", "String"
+
+ fingerprint profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,FFFF", outClusters: "0000,0003,0019,FFFF", deviceJoinName: "Xiaomi Temp Sensor"
+ fingerprint profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,FFFF", outClusters: "0000,0003,0019,FFFF", manufacturer: "LUMI", model: "lumi.sensor_ht", deviceJoinName: "Xiaomi Temp Sensor"
+ fingerprint profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,FFFF,0012", outClusters: "0000,0004,0003,0005,0019,FFFF,0012", deviceJoinName: "Xiaomi Temp Sensor"
+ fingerprint profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,FFFF,0012", outClusters: "0000,0004,0003,0005,0019,FFFF,0012", model: "lumi.sens", deviceJoinName: "Xiaomi Temp Sensor"
+ fingerprint profileId: "0104", deviceId: "5F01", inClusters: "0000,0003,0019,FFFF,0012", outClusters: "0000,0004,0003,0005,0019,FFFF,0012", model: "lumi.sensor_ht", deviceJoinName: "Xiaomi Temp Sensor"
+
+ command "resetBatteryRuntime"
+}
+
+ // simulator metadata
+ simulator {
+ for (int i = 0; i <= 100; i += 10) {
+ status "${i}F": "temperature: $i F"
+ }
+
+ for (int i = 0; i <= 100; i += 10) {
+ status "${i}%": "humidity: ${i}%"
+ }
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"temperature", type:"generic", width:6, height:4) {
+ tileAttribute("device.temperature", key:"PRIMARY_CONTROL"){
+ attributeState("temperature", label:'${currentValue}°',
+ backgroundColors:[
+ [value: 31, color: "#153591"],
+ [value: 44, color: "#1e9cbb"],
+ [value: 59, color: "#90d2a7"],
+ [value: 74, color: "#44b621"],
+ [value: 84, color: "#f1d801"],
+ [value: 95, color: "#d04e00"],
+ [value: 96, color: "#bc2323"]
+ ]
+ )
+ }
+ tileAttribute("device.multiAttributesReport", key: "SECONDARY_CONTROL") {
+ attributeState("multiAttributesReport", label:'${currentValue}' //icon:"st.Weather.weather12",
+ )
+ }
+ }
+ valueTile("temperature2", "device.temperature", inactiveLabel: false) {
+ state "temperature", label:'${currentValue}°', icon: "st.Weather.weather2",
+ backgroundColors:[
+ [value: 31, color: "#153591"],
+ [value: 44, color: "#1e9cbb"],
+ [value: 59, color: "#90d2a7"],
+ [value: 74, color: "#44b621"],
+ [value: 84, color: "#f1d801"],
+ [value: 95, color: "#d04e00"],
+ [value: 96, color: "#bc2323"]
+ ]
+ }
+ valueTile("humidity", "device.humidity", inactiveLabel: false, width: 2, height: 2) {
+ state "humidity", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiHumidity.png",
+ backgroundColors:[
+ [value: 0, color: "#FFFCDF"],
+ [value: 4, color: "#FDF789"],
+ [value: 20, color: "#A5CF63"],
+ [value: 23, color: "#6FBD7F"],
+ [value: 56, color: "#4CA98C"],
+ [value: 59, color: "#0072BB"],
+ [value: 76, color: "#085396"]
+ ]
+ }
+ valueTile("battery", "device.battery", inactiveLabel: false, width: 2, height: 2) {
+ state "battery", label:'${currentValue}%', unit:"%", icon:"https://raw.githubusercontent.com/bspranger/Xiaomi/master/images/XiaomiBattery.png",
+ backgroundColors:[
+ [value: 10, color: "#bc2323"],
+ [value: 26, color: "#f1d801"],
+ [value: 51, color: "#44b621"]
+ ]
+ }
+ valueTile("spacer1", "spacer1", decoration: "flat", inactiveLabel: false, width: 1, height: 1) {
+ state "default", label:''
+ }
+ valueTile("spacer2", "spacer2", decoration: "flat", inactiveLabel: false, width: 1, height: 2) {
+ state "default", label:''
+ }
+ valueTile("lastcheckin", "device.lastCheckin", inactiveLabel: false, decoration:"flat", width: 4, height: 1) {
+ state "lastcheckin", label:'Last Event:\n ${currentValue}'
+ }
+ valueTile("batteryRuntime", "device.batteryRuntime", inactiveLabel: false, decoration: "flat", width: 4, height: 1) {
+ state "batteryRuntime", label:'Battery Changed: ${currentValue}'
+ }
+ main("temperature2")
+ details(["temperature", "spacer2", "battery", "humidity", "spacer2", "spacer1", "lastcheckin", "spacer1", "spacer1", "batteryRuntime", "spacer1"])
+ }
+ preferences {
+ //Button Config
+ input description: "The settings below customize additional infomation displayed in the main status tile.", type: "paragraph", element: "paragraph", title: "MAIN TILE DISPLAY"
+ input name: "displayTempInteger", type: "bool", title: "Display temperature as integer?", description:"NOTE: Takes effect on the next temperature report. High/Low temperatures are always displayed as integers."
+ input name: "displayTempHighLow", type: "bool", title: "Display high/low temperature?"
+ input name: "displayHumidHighLow", type: "bool", title: "Display high/low humidity?"
+ //Temp and Humidity Offsets
+ input description: "The settings below allow correction of variations in temperature and humidity by setting an offset. Examples: If the sensor consistently reports temperature 5 degrees too warm, enter '-5' for the Temperature Offset. If it reports humidity 3% too low, enter ‘3' for the Humidity Offset. NOTE: Changes will take effect on the NEXT temperature / humidity / pressure report.", type: "paragraph", element: "paragraph", title: "OFFSETS & UNITS"
+ input "tempOffset", "decimal", title:"Temperature Offset", description:"Adjust temperature by this many degrees", range:"*..*"
+ input "humidOffset", "number", title:"Humidity Offset", description:"Adjust humidity by this many percent", range: "*..*"
+ input description: "NOTE: The temperature unit (C / F) can be changed in the location settings for your hub.", type: "paragraph", element: "paragraph", title: ""
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\n US (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ //Battery Reset Config
+ input description: "If you have installed a new battery, the toggle below will reset the Changed Battery date to help remember when it was changed.", type: "paragraph", element: "paragraph", title: "CHANGED BATTERY DATE RESET"
+ input name: "battReset", type: "bool", title: "Battery Changed?", description: ""
+ //Battery Voltage Offset
+ input description: "Only change the settings below if you know what you're doing.", type: "paragraph", element: "paragraph", title: "ADVANCED SETTINGS"
+ input name: "voltsmax", title: "Max Volts\nA battery is at 100% at __ volts.\nRange 2.8 to 3.4", type: "decimal", range: "2.8..3.4", defaultValue: 3
+ input name: "voltsmin", title: "Min Volts\nA battery is at 0% (needs replacing)\nat __ volts. Range 2.0 to 2.7", type: "decimal", range: "2..2.7", defaultValue: 2.5
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName}: Parsing description: ${description}"
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+
+ // Any report - temp, humidity, pressure, & battery - results in a lastCheckin event and update to Last Checkin tile
+ // However, only a non-parseable report results in lastCheckin being displayed in events log
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ // Check if the min/max temp and min/max humidity should be reset
+ checkNewDay(now)
+
+ // getEvent automatically retrieves temp and humidity in correct unit as integer
+ Map map = zigbee.getEvent(description)
+
+ // Send message data to appropriate parsing function based on the type of report
+ if (map.name == "temperature") {
+ def temp = parseTemperature(description)
+ map.value = displayTempInteger ? (int) temp : temp
+ map.descriptionText = "${device.displayName} temperature is ${map.value}°${temperatureScale}"
+ map.translatable = true
+ updateMinMaxTemps(map.value)
+ } else if (map.name == "humidity") {
+ map.value = humidOffset ? (int) map.value + (int) humidOffset : (int) map.value
+ updateMinMaxHumidity(map.value)
+ } else if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ } else if (description?.startsWith('read attr - raw:')) {
+ map = parseReadAttr(description)
+ } else {
+ log.debug "${device.displayName}: was unable to parse ${description}"
+ sendEvent(name: "lastCheckin", value: now)
+ }
+
+ if (map) {
+ log.debug "${device.displayName}: Parse returned ${map}"
+ return createEvent(map)
+ } else
+ return [:]
+}
+
+// Calculate temperature with 0.1 precision in C or F unit as set by hub location settings
+private parseTemperature(String description) {
+ def temp = ((description - "temperature: ").trim()) as Float
+ def offset = tempOffset ? tempOffset : 0
+ temp = (temp > 100) ? (100 - temp) : temp
+ temp = (temperatureScale == "F") ? ((temp * 1.8) + 32) + offset : temp + offset
+ return temp.round(1)
+}
+
+// Check catchall for battery voltage data to pass to getBatteryResult for conversion to percentage report
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def catchall = zigbee.parse(description)
+ log.debug catchall
+
+ if (catchall.clusterId == 0x0000) {
+ def MsgLength = catchall.data.size()
+ // Xiaomi CatchAll does not have identifiers, first UINT16 is Battery
+ if ((catchall.data.get(0) == 0x01 || catchall.data.get(0) == 0x02) && (catchall.data.get(1) == 0xFF)) {
+ for (int i = 4; i < (MsgLength-3); i++) {
+ if (catchall.data.get(i) == 0x21) { // check the data ID and data type
+ // next two bytes are the battery voltage
+ resultMap = getBatteryResult((catchall.data.get(i+2)<<8) + catchall.data.get(i+1))
+ break
+ }
+ }
+ }
+ }
+ return resultMap
+}
+
+// Parse device name on short press of reset button
+private Map parseReadAttr(String description) {
+ Map resultMap = [:]
+
+ def cluster = description.split(",").find {it.split(":")[0].trim() == "cluster"}?.split(":")[1].trim()
+ def attrId = description.split(",").find {it.split(":")[0].trim() == "attrId"}?.split(":")[1].trim()
+ def value = description.split(",").find {it.split(":")[0].trim() == "value"}?.split(":")[1].trim()
+
+ if (cluster == "0000" && attrId == "0005") {
+ def modelName = ""
+
+ // Parsing the model name
+ for (int i = 0; i < value.length(); i+=2) {
+ def str = value.substring(i, i+2);
+ def NextChar = (char)Integer.parseInt(str, 16);
+ modelName = modelName + NextChar
+ }
+ log.debug "${device.displayName} reported: cluster: ${cluster}, attrId: ${attrId}, value: ${value}, model:${modelName}"
+ }
+}
+
+// Convert raw 4 digit integer voltage value into percentage based on minVolts/maxVolts range
+private Map getBatteryResult(rawValue) {
+ // raw voltage is normally supplied as a 4 digit integer that needs to be divided by 1000
+ // but in the case the final zero is dropped then divide by 100 to get actual voltage value
+ def rawVolts = rawValue / 1000
+ def minVolts
+ def maxVolts
+
+ if(voltsmin == null || voltsmin == "")
+ minVolts = 2.5
+ else
+ minVolts = voltsmin
+
+ if(voltsmax == null || voltsmax == "")
+ maxVolts = 3.0
+ else
+ maxVolts = voltsmax
+
+ def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
+ def roundedPct = Math.min(100, Math.round(pct * 100))
+
+ def result = [
+ name: 'battery',
+ value: roundedPct,
+ unit: "%",
+ isStateChange:true,
+ descriptionText : "${device.displayName} Battery at ${roundedPct}% (${rawVolts} Volts)"
+ ]
+
+ return result
+}
+
+// If the day of month has changed from that of previous event, reset the daily min/max temp and humidity values
+def checkNewDay(now) {
+ def oldDay = ((device.currentValue("currentDay")) == null) ? "32" : (device.currentValue("currentDay"))
+ def newDay = new Date(now).format("dd")
+ if (newDay != oldDay) {
+ resetMinMax()
+ sendEvent(name: "currentDay", value: newDay, displayed: false)
+ }
+}
+
+// Reset daily min/max temp and humidity values to the current temp/humidity values
+def resetMinMax() {
+ def currentTemp = device.currentValue('temperature')
+ def currentHumidity = device.currentValue('humidity')
+ currentTemp = currentTemp ? (int) currentTemp : currentTemp
+ log.debug "${device.displayName}: Resetting daily min/max values to current temperature of ${currentTemp}° and humidity of ${currentHumidity}%"
+ sendEvent(name: "maxTemp", value: currentTemp, displayed: false)
+ sendEvent(name: "minTemp", value: currentTemp, displayed: false)
+ sendEvent(name: "maxHumidity", value: currentHumidity, displayed: false)
+ sendEvent(name: "minHumidity", value: currentHumidity, displayed: false)
+ refreshMultiAttributes()
+}
+
+// Check new min or max temp for the day
+def updateMinMaxTemps(temp) {
+ temp = temp ? (int) temp : temp
+ if ((temp > device.currentValue('maxTemp')) || (device.currentValue('maxTemp') == null))
+ sendEvent(name: "maxTemp", value: temp, displayed: false)
+ if ((temp < device.currentValue('minTemp')) || (device.currentValue('minTemp') == null))
+ sendEvent(name: "minTemp", value: temp, displayed: false)
+ refreshMultiAttributes()
+}
+
+// Check new min or max humidity for the day
+def updateMinMaxHumidity(humidity) {
+ if ((humidity > device.currentValue('maxHumidity')) || (device.currentValue('maxHumidity') == null))
+ sendEvent(name: "maxHumidity", value: humidity, displayed: false)
+ if ((humidity < device.currentValue('minHumidity')) || (device.currentValue('minHumidity') == null))
+ sendEvent(name: "minHumidity", value: humidity, displayed: false)
+ refreshMultiAttributes()
+}
+
+// Update display of multiattributes in main tile
+def refreshMultiAttributes() {
+ def temphiloAttributes = displayTempHighLow ? (displayHumidHighLow ? "Today's High/Low: ${device.currentState('maxTemp')?.value}° / ${device.currentState('minTemp')?.value}°" : "Today's High: ${device.currentState('maxTemp')?.value}° / Low: ${device.currentState('minTemp')?.value}°") : ""
+ def humidhiloAttributes = displayHumidHighLow ? (displayTempHighLow ? " ${device.currentState('maxHumidity')?.value}% / ${device.currentState('minHumidity')?.value}%" : "Today's High: ${device.currentState('maxHumidity')?.value}% / Low: ${device.currentState('minHumidity')?.value}%") : ""
+ sendEvent(name: "multiAttributesReport", value: "${temphiloAttributes}${humidhiloAttributes}", displayed: false)
+}
+
+//Reset the date displayed in Battery Changed tile to current date
+def resetBatteryRuntime(paired) {
+ def now = formatDate(true)
+ def newlyPaired = paired ? " for newly paired sensor" : ""
+ sendEvent(name: "batteryRuntime", value: now)
+ log.debug "${device.displayName}: Setting Battery Changed to current date${newlyPaired}"
+}
+
+// installed() runs just after a sensor is paired using the "Add a Thing" method in the SmartThings mobile app
+def installed() {
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("installed")
+}
+
+// configure() runs after installed() when a sensor is paired
+def configure() {
+ log.debug "${device.displayName}: configuring"
+ state.battery = 0
+ if (!batteryRuntime) resetBatteryRuntime(true)
+ checkIntervalEvent("configured")
+ return
+}
+
+// updated() will run twice every time user presses save in preference settings page
+def updated() {
+ checkIntervalEvent("updated")
+ if(battReset){
+ resetBatteryRuntime()
+ device.updateSetting("battReset", false)
+ }
+}
+
+private checkIntervalEvent(text) {
+ // Device wakes up every 1 hours, this interval allows us to miss one wakeup notification before marking offline
+ log.debug "${device.displayName}: Configured health checkInterval when ${text}()"
+ sendEvent(name: "checkInterval", value: 2 * 60 * 60 + 2 * 60, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
+}
+
+def formatDate(batteryReset) {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ if (batteryReset)
+ return new Date().format("MMM dd yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ if (batteryReset)
+ return new Date().format("dd MMM yyyy", correctedTimezone)
+ else
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ if (batteryReset)
+ return new Date().format("yyyy MMM dd", correctedTimezone)
+ else
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/devicetypes/bspranger/xiaomi-zigbee-outlet.src/xiaomi-zigbee-outlet.groovy b/devicetypes/bspranger/xiaomi-zigbee-outlet.src/xiaomi-zigbee-outlet.groovy
new file mode 100644
index 00000000..84bb8a82
--- /dev/null
+++ b/devicetypes/bspranger/xiaomi-zigbee-outlet.src/xiaomi-zigbee-outlet.groovy
@@ -0,0 +1,246 @@
+/**
+ * Xiaomi Smart Plug - model ZNCZ02LM
+ * Device Handler for SmartThings
+ * Version 1.2
+ *
+ * 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.
+ *
+ * Based on original device handler by Lazcad / RaveTam
+ * Updates and contributions to code by a4refillpad, bspranger, marcos-mvs, mike-debney, Tiago_Goncalves, and veeceeoh
+ *
+ */
+
+metadata {
+ definition (name: "Xiaomi Zigbee Outlet", namespace: "bspranger", author: "bspranger") {
+ capability "Actuator"
+ capability "Configuration"
+ capability "Refresh"
+ capability "Switch"
+ capability "Temperature Measurement"
+ capability "Sensor"
+ capability "Power Meter"
+ capability "Energy Meter"
+
+ attribute "lastCheckin", "string"
+ attribute "lastCheckinDate", "String"
+ }
+
+ // simulator metadata
+ simulator {
+ // status messages
+ status "on": "on/off: 1"
+ status "off": "on/off: 0"
+ // reply messages
+ reply "zcl on-off on": "on/off: 1"
+ reply "zcl on-off off": "on/off: 0"
+ }
+
+ tiles(scale: 2) {
+ multiAttributeTile(name:"switch", type: "lighting", width: 6, height: 4, canChangeIcon: true){
+ tileAttribute ("device.switch", key: "PRIMARY_CONTROL") {
+ attributeState "on", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#00a0dc", nextState:"turningOff"
+ attributeState "off", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
+ attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#00a0dc", nextState:"turningOff"
+ attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
+ }
+ tileAttribute("device.lastCheckin", key: "SECONDARY_CONTROL") {
+ attributeState("default", label:'Last Update:\n ${currentValue}',icon: "st.Health & Wellness.health9")
+ }
+ }
+ valueTile("temperature", "device.temperature", width: 2, height: 2) {
+ state("temperature", label:'${currentValue}°',
+ backgroundColors:[
+ [value: 31, color: "#153591"],
+ [value: 44, color: "#1e9cbb"],
+ [value: 59, color: "#90d2a7"],
+ [value: 74, color: "#44b621"],
+ [value: 84, color: "#f1d801"],
+ [value: 95, color: "#d04e00"],
+ [value: 96, color: "#bc2323"]
+ ]
+ )
+ }
+ valueTile("power", "device.power", width: 2, height: 2) {
+ state("power", label:'${currentValue}W', backgroundColors:[
+ [value: 0, color: "#ffffff"],
+ [value: 1, color: "#00a0dc"]
+ ])
+ }
+ valueTile("energy", "device.energy", width: 2, height: 2) {
+ state("energy", label:'${currentValue}kWh')
+ }
+ standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
+ state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh"
+ }
+ main (["switch", "power"])
+ details(["switch", "power", "energy", "temperature", "refresh"])
+ }
+
+ preferences {
+ //Temp Offset Config
+ input description: "", type: "paragraph", element: "paragraph", title: "OFFSETS & UNITS"
+ input "tempOffset", "decimal", title:"Temperature Offset", description:"Adjust temperature by this many degrees", range:"*..*"
+ //Date & Time Config
+ input description: "", type: "paragraph", element: "paragraph", title: "DATE & CLOCK"
+ input name: "dateformat", type: "enum", title: "Set Date Format\n US (MDY) - UK (DMY) - Other (YMD)", description: "Date Format", options:["US","UK","Other"]
+ input name: "clockformat", type: "bool", title: "Use 24 hour clock?"
+ }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+ log.debug "${device.displayName}: Parsing message: '${description}'"
+ def value = zigbee.parse(description)?.text
+ log.debug "${device.displayName}: Zigbee parse value: $value"
+ Map map = [:]
+
+ // Determine current time and date in the user-selected date format and clock style
+ def now = formatDate()
+ def nowDate = new Date(now).getTime()
+
+ // The receipt of any message results in a lastCheckin (heartbeat) event
+ sendEvent(name: "lastCheckin", value: now, displayed: false)
+ sendEvent(name: "lastCheckinDate", value: nowDate, displayed: false)
+
+ if (description?.startsWith('catchall:')) {
+ map = parseCatchAllMessage(description)
+ }
+ else if (description?.startsWith('read attr -')) {
+ map = parseReportAttributeMessage(description)
+ }
+ else if (description?.startsWith('on/off: ')){
+ map = parseCustomMessage(description)
+ }
+
+ if (map) {
+ log.debug "${device.displayName}: Creating event ${map}"
+ return createEvent(map)
+ } else
+ return [:]
+}
+
+private Map parseCatchAllMessage(String description) {
+ Map resultMap = [:]
+ def zigbeeParse = zigbee.parse(description)
+ log.debug "${device.displayName}: Catchall parsed as $cluster"
+
+ if (zigbeeParse.clusterId == 0x0006 && zigbeeParse.command == 0x01){
+ def onoff = zigbeeParse.data[-1]
+ if (onoff == 1)
+ resultMap = createEvent(name: "switch", value: "on")
+ else if (onoff == 0)
+ resultMap = createEvent(name: "switch", value: "off")
+ }
+ return resultMap
+}
+
+private Map parseReportAttributeMessage(String description) {
+ Map descMap = (description - "read attr - ").split(",").inject([:]) {
+ map, param ->
+ def nameAndValue = param.split(":")
+ map += [(nameAndValue[0].trim()):nameAndValue[1].trim()]
+ }
+
+ Map resultMap = [:]
+
+ if (descMap.cluster == "0001" && descMap.attrId == "0020") {
+ resultMap = getBatteryResult(convertHexToInt(descMap.value / 2))
+ }
+ if (descMap.cluster == "0002" && descMap.attrId == "0000") {
+ def tempScale = getTemperatureScale()
+ def tempValue = zigbee.parseHATemperatureValue("temperature: " + (convertHexToInt(descMap.value) / 2), "temperature: ", tempScale) + (tempOffset ? tempOffset : 0)
+ resultMap = createEvent(name: "temperature", value: tempValue, unit: tempScale, translatable: true)
+ log.debug "${device.displayName}: Reported temperature is ${resultMap.value}°$tempScale"
+ }
+ else if (descMap.cluster == "0008" && descMap.attrId == "0000") {
+ resultMap = createEvent(name: "switch", value: "off")
+ }
+ else if (descMap.cluster == "000C" && descMap.attrId == "0055" && descMap.endpoint == "02") {
+ def wattage_int = Long.parseLong(descMap.value, 16)
+ def wattage = Float.intBitsToFloat(wattage_int.intValue())
+ wattage = Math.round(wattage * 10) * 0.1
+ resultMap = createEvent(name: "power", value: wattage, unit: 'W')
+ log.debug "${device.displayName}: Reported power use is ${wattage}W"
+ }
+ else if (descMap.cluster == "000C" && descMap.attrId == "0055" && descMap.endpoint == "03") {
+ def energy_int = Long.parseLong(descMap.value, 16)
+ def energy = Float.intBitsToFloat(energy_int.intValue())
+ energy = Math.round(energy * 100) * 0.0001
+ resultMap = createEvent(name: "energy", value: energy, unit: 'kWh')
+ log.debug "${device.displayName}: Reported energy usage is ${energy}kWh"
+ }
+ return resultMap
+}
+
+def off() {
+ log.debug "${device.displayName}: Turning switch off"
+ sendEvent(name: "switch", value: "off")
+ "st cmd 0x${device.deviceNetworkId} 1 6 0 {}"
+}
+
+def on() {
+ log.debug "${device.displayName}: Turning switch on"
+ sendEvent(name: "switch", value: "on")
+ "st cmd 0x${device.deviceNetworkId} 1 6 1 {}"
+}
+
+def refresh() {
+ log.debug "${device.displayName}: Attempting to refresh all values"
+ def refreshCmds = [
+ "st rattr 0x${device.deviceNetworkId} 1 6 0", "delay 500",
+ "st rattr 0x${device.deviceNetworkId} 1 6 0", "delay 250",
+ "st rattr 0x${device.deviceNetworkId} 1 2 0", "delay 250",
+ "st rattr 0x${device.deviceNetworkId} 1 1 0", "delay 250",
+ "st rattr 0x${device.deviceNetworkId} 1 0 0", "delay 250",
+ "st rattr 0x${device.deviceNetworkId} 2 0x000C 0x0055",
+ "delay 250",
+ "st rattr 0x${device.deviceNetworkId} 3 0x000C 0x0055"
+ ]
+ return refreshCmds
+}
+
+private Map parseCustomMessage(String description) {
+ def result
+ if (description?.startsWith('on/off: ')) {
+ if (description == 'on/off: 0')
+ result = createEvent(name: "switch", value: "off")
+ else if (description == 'on/off: 1')
+ result = createEvent(name: "switch", value: "on")
+ }
+ return result
+}
+
+private Integer convertHexToInt(hex) {
+ Integer.parseInt(hex,16)
+}
+
+def formatDate() {
+ def correctedTimezone = ""
+ def timeString = clockformat ? "HH:mm:ss" : "h:mm:ss aa"
+
+ // If user's hub timezone is not set, display error messages in log and events log, and set timezone to GMT to avoid errors
+ if (!(location.timeZone)) {
+ correctedTimezone = TimeZone.getTimeZone("GMT")
+ log.error "${device.displayName}: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app."
+ sendEvent(name: "error", value: "", descriptionText: "ERROR: Time Zone not set, so GMT was used. Please set up your location in the SmartThings mobile app.")
+ }
+ else {
+ correctedTimezone = location.timeZone
+ }
+ if (dateformat == "US" || dateformat == "" || dateformat == null) {
+ return new Date().format("EEE MMM dd yyyy ${timeString}", correctedTimezone)
+ }
+ else if (dateformat == "UK") {
+ return new Date().format("EEE dd MMM yyyy ${timeString}", correctedTimezone)
+ }
+ else {
+ return new Date().format("EEE yyyy MMM dd ${timeString}", correctedTimezone)
+ }
+}
diff --git a/images/ButtonPushed.png b/images/ButtonPushed.png
new file mode 100644
index 00000000..bf5aa9b0
Binary files /dev/null and b/images/ButtonPushed.png differ
diff --git a/images/ButtonReleased.png b/images/ButtonReleased.png
new file mode 100644
index 00000000..c9de509b
Binary files /dev/null and b/images/ButtonReleased.png differ
diff --git a/images/README.md b/images/README.md
new file mode 100644
index 00000000..24e4a37f
--- /dev/null
+++ b/images/README.md
@@ -0,0 +1 @@
+Images for Readme.md
diff --git a/images/XiaomiBattery.png b/images/XiaomiBattery.png
new file mode 100644
index 00000000..10adde72
Binary files /dev/null and b/images/XiaomiBattery.png differ
diff --git a/images/XiaomiHumidity.png b/images/XiaomiHumidity.png
new file mode 100644
index 00000000..40c2cd3d
Binary files /dev/null and b/images/XiaomiHumidity.png differ
diff --git a/images/XiaomiPressure.png b/images/XiaomiPressure.png
new file mode 100644
index 00000000..8d5aecb0
Binary files /dev/null and b/images/XiaomiPressure.png differ
diff --git a/images/aqarabutton.jpg b/images/aqarabutton.jpg
new file mode 100644
index 00000000..29104420
Binary files /dev/null and b/images/aqarabutton.jpg differ
diff --git a/images/aqaradoor.jpg b/images/aqaradoor.jpg
new file mode 100644
index 00000000..bf0bdca2
Binary files /dev/null and b/images/aqaradoor.jpg differ
diff --git a/images/aqaramotion.jpg b/images/aqaramotion.jpg
new file mode 100644
index 00000000..2ff1c13c
Binary files /dev/null and b/images/aqaramotion.jpg differ
diff --git a/images/aqaratemp.jpg b/images/aqaratemp.jpg
new file mode 100644
index 00000000..f20e9f19
Binary files /dev/null and b/images/aqaratemp.jpg differ
diff --git a/images/aqarawater.jpg b/images/aqarawater.jpg
new file mode 100644
index 00000000..d64aa8fc
Binary files /dev/null and b/images/aqarawater.jpg differ
diff --git a/images/button.jpg b/images/button.jpg
new file mode 100644
index 00000000..5d6585df
Binary files /dev/null and b/images/button.jpg differ
diff --git a/images/door.jpg b/images/door.jpg
new file mode 100644
index 00000000..0fd7a03d
Binary files /dev/null and b/images/door.jpg differ
diff --git a/images/motion.jpg b/images/motion.jpg
new file mode 100644
index 00000000..5ba99612
Binary files /dev/null and b/images/motion.jpg differ
diff --git a/images/outlet.jpg b/images/outlet.jpg
new file mode 100644
index 00000000..55233b8b
Binary files /dev/null and b/images/outlet.jpg differ
diff --git a/images/smoke.jpg b/images/smoke.jpg
new file mode 100644
index 00000000..c4745bc0
Binary files /dev/null and b/images/smoke.jpg differ
diff --git a/images/temp.jpg b/images/temp.jpg
new file mode 100644
index 00000000..46539e56
Binary files /dev/null and b/images/temp.jpg differ