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