/** * 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) } }