Checking in all the SmartThings apps; both official and third-party.
[smartapps.git] / third-party / QuirkyTripper.groovy
diff --git a/third-party/QuirkyTripper.groovy b/third-party/QuirkyTripper.groovy
new file mode 100755 (executable)
index 0000000..b11c153
--- /dev/null
@@ -0,0 +1,253 @@
+/**
+ *  Quirky/Wink Tripper Contact Sensor
+ *
+ *  Copyright 2015 Mitch Pond, SmartThings
+ *
+ *  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.
+ *
+ */
+
+metadata {
+       definition (name: "Quirky/Wink Tripper", namespace: "mitchpond", author: "Mitch Pond") {
+    
+               capability "Contact Sensor"
+               capability "Battery"
+               capability "Configuration"
+               capability "Sensor"
+    
+               attribute "tamper", "string"
+    
+               command "configure"
+               command "resetTamper"
+        
+               fingerprint endpointId: "01", profileId: "0104", deviceId: "0402", inClusters: "0000,0001,0003,0500,0020,0B05", outClusters: "0003,0019"
+       }
+
+       // simulator metadata
+       simulator {}
+
+       // UI tile definitions
+       tiles {
+               standardTile("contact", "device.contact", width: 2, height: 2, canChangeIcon: true) {
+                       state("open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#ffa81e")
+                       state("closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#79b821")
+               }
+        
+               valueTile("battery", "device.battery", decoration: "flat") {
+                       state "battery", label:'${currentValue}% battery', unit:""
+               }
+        
+               standardTile("tamper", "device.tamper") {
+                       state "OK", label: "Tamper OK", icon: "st.security.alarm.on", backgroundColor:"#79b821", decoration: "flat"
+                       state "tampered", label: "Tampered", action: "resetTamper", icon: "st.security.alarm.off", backgroundColor:"#ffa81e", decoration: "flat"
+               }
+        
+               main ("contact")
+               details(["contact","battery","tamper"])
+       }
+}
+
+// Parse incoming device messages to generate events
+def parse(String description) {
+       //log.debug "description: $description"
+
+       def results = []
+       if (description?.startsWith('catchall:')) {
+               results = parseCatchAllMessage(description)
+       }
+       else if (description?.startsWith('read attr -')) {
+               results = parseReportAttributeMessage(description)
+       }
+       else if (description?.startsWith('zone status')) {
+               results = parseIasMessage(description)
+       }
+
+       log.debug "Parse returned $results"
+
+       if (description?.startsWith('enroll request')) {
+               List cmds = enrollResponse()
+               log.debug "enroll response: ${cmds}"
+               results = cmds?.collect { new physicalgraph.device.HubAction(it) }
+       }
+       return results
+}
+
+//Initializes device and sets up reporting
+def configure() {
+       String zigbeeId = swapEndianHex(device.hub.zigbeeId)
+       log.debug "Confuguring Reporting, IAS CIE, and Bindings."
+    
+       def cmd = [
+               "zcl global write 0x500 0x10 0xf0 {${zigbeeId}}", "delay 200",
+               "send 0x${device.deviceNetworkId} 1 1", "delay 1500",
+       
+               "zcl global send-me-a-report 0x500 0x0012 0x19 0 0xFF {}", "delay 200", //get notified on tamper
+               "send 0x${device.deviceNetworkId} 1 1", "delay 1500",
+               
+               "zcl global send-me-a-report 1 0x20 0x20 5 3600 {}", "delay 200", //battery report request
+               "send 0x${device.deviceNetworkId} 1 1", "delay 1500",
+       
+               "zdo bind 0x${device.deviceNetworkId} 1 1 0x500 {${device.zigbeeId}} {}", "delay 500",
+               "zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}", "delay 500",
+               "st rattr 0x${device.deviceNetworkId} 1 1 0x20"
+               ]
+       cmd
+}
+
+//Sends IAS Zone Enroll response
+def enrollResponse() {
+       log.debug "Sending enroll response"
+       [       
+       "raw 0x500 {01 23 00 00 00}", "delay 200",
+       "send 0x${device.deviceNetworkId} 1 1"
+       ]
+}
+
+private Map parseCatchAllMessage(String description) {
+       def results = [:]
+       def cluster = zigbee.parse(description)
+       if (shouldProcessMessage(cluster)) {
+               switch(cluster.clusterId) {
+                       case 0x0001:
+                               log.debug "Received a catchall message for battery status. This should not happen."
+                               results << createEvent(getBatteryResult(cluster.data.last()))
+                               break
+            }
+        }
+
+       return results
+}
+
+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
+}
+
+private 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"
+
+       def results = []
+    
+       if (descMap.cluster == "0001" && descMap.attrId == "0020") {
+               log.debug "Received battery level report"
+               results = createEvent(getBatteryResult(Integer.parseInt(descMap.value, 16)))
+       }
+
+       return results
+}
+
+private parseIasMessage(String description) {
+       List parsedMsg = description.split(' ')
+       String msgCode = parsedMsg[2]
+       int status = Integer.decode(msgCode)
+       def linkText = getLinkText(device)
+
+       def results = []
+       //log.debug(description)
+       if (status & 0b00000001) {results << createEvent(getContactResult('open'))}
+       else if (~status & 0b00000001) results << createEvent(getContactResult('closed'))
+
+       if (status & 0b00000100) {
+               //log.debug "Tampered"
+            results << createEvent([name: "tamper", value:"tampered"])
+       }
+       else if (~status & 0b00000100) {
+               //don't reset the status here as we want to force a manual reset
+               //log.debug "Not tampered"
+               //results << createEvent([name: "tamper", value:"OK"])
+       }
+       
+       if (status & 0b00001000) {
+               //battery reporting seems unreliable with these devices. However, they do report when low.
+               //Just in case the battery level reporting has stopped working, we'll at least catch the low battery warning.
+               //
+               //** Commented this out as this is currently conflicting with the battery level report **/
+               //log.debug "${linkText} reports low battery!"
+               //results << createEvent([name: "battery", value: 10])
+       }
+       else if (~status & 0b00001000) {
+               //log.debug "${linkText} battery OK"
+       }
+       //log.debug results
+       return results
+}
+
+//Converts the battery level response into a percentage to display in ST
+//and creates appropriate message for given level
+//**real-world testing with this device shows that 2.4v is about as low as it can go **/
+
+private getBatteryResult(rawValue) {
+       def linkText = getLinkText(device)
+
+       def result = [name: 'battery']
+
+       def volts = rawValue / 10
+       def descriptionText
+       if (volts > 3.5) {
+               result.descriptionText = "${linkText} battery has too much power (${volts} volts)."
+       }
+       else {
+               def minVolts = 2.4
+               def maxVolts = 3.0
+               def pct = (volts - minVolts) / (maxVolts - minVolts)
+               result.value = Math.min(100, (int) pct * 100)
+               result.descriptionText = "${linkText} battery was ${result.value}%"
+       }
+
+       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
+               ]
+}
+
+//Resets the tamper switch state
+private resetTamper(){
+       log.debug "Tamper alarm reset."
+       sendEvent([name: "tamper", value:"OK"])
+}
+
+private hex(value) {
+       new BigInteger(Math.round(value).toString()).toString(16)
+}
+
+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
+}