1 import javax.crypto.Mac;
2 import javax.crypto.spec.SecretKeySpec;
3 import java.security.InvalidKeyException;
6 * OpenT2T SmartApp Test
8 * Copyright 2016 OpenT2T
10 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
11 * in compliance with the License. You may obtain a copy of the License at:
13 * http://www.apache.org/licenses/LICENSE-2.0
15 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
16 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
17 * for the specific language governing permissions and limitations under the License.
21 name: "OpenT2T SmartApp Test",
24 description: "Test app to test end to end SmartThings scenarios via OpenT2T",
25 category: "SmartThings Labs",
26 iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png",
27 iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png",
28 iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png")
30 /** --------------------+---------------+-----------------------+------------------------------------
31 * Device Type | Attribute Name| Commands | Attribute Values
32 * --------------------+---------------+-----------------------+------------------------------------
33 * switches | switch | on, off | on, off
34 * motionSensors | motion | | active, inactive
35 * contactSensors | contact | | open, closed
36 * presenceSensors | presence | | present, 'not present'
37 * temperatureSensors | temperature | | <numeric, F or C according to unit>
38 * accelerationSensors | acceleration | | active, inactive
39 * waterSensors | water | | wet, dry
40 * lightSensors | illuminance | | <numeric, lux>
41 * humiditySensors | humidity | | <numeric, percent>
42 * locks | lock | lock, unlock | locked, unlocked
43 * garageDoors | door | open, close | unknown, closed, open, closing, opening
44 * cameras | image | take | <String>
45 * thermostats | thermostat | setHeatingSetpoint, | temperature, heatingSetpoint, coolingSetpoint,
46 * | | setCoolingSetpoint, | thermostatSetpoint, thermostatMode,
47 * | | off, heat, cool, auto,| thermostatFanMode, thermostatOperatingState
48 * | | emergencyHeat, |
49 * | | setThermostatMode, |
50 * | | fanOn, fanAuto, |
52 * | | setThermostatFanMode |
53 * --------------------+---------------+-----------------------+------------------------------------
58 section("Allow OpenT2T to control these things...") {
59 input "contactSensors", "capability.contactSensor", title: "Which Contact Sensors", multiple: true, required: false, hideWhenEmpty: true
60 input "garageDoors", "capability.garageDoorControl", title: "Which Garage Doors?", multiple: true, required: false, hideWhenEmpty: true
61 input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false, hideWhenEmpty: true
62 input "cameras", "capability.videoCapture", title: "Which Cameras?", multiple: true, required: false, hideWhenEmpty: true
63 input "motionSensors", "capability.motionSensor", title: "Which Motion Sensors?", multiple: true, required: false, hideWhenEmpty: true
64 input "presenceSensors", "capability.presenceSensor", title: "Which Presence Sensors", multiple: true, required: false, hideWhenEmpty: true
65 input "switches", "capability.switch", title: "Which Switches and Lights?", multiple: true, required: false, hideWhenEmpty: true
66 input "thermostats", "capability.thermostat", title: "Which Thermostat?", multiple: true, required: false, hideWhenEmpty: true
67 input "waterSensors", "capability.waterSensor", title: "Which Water Leak Sensors?", multiple: true, required: false, hideWhenEmpty: true
73 inputList += contactSensors ?: []
74 inputList += garageDoors ?: []
75 inputList += locks ?: []
76 inputList += cameras ?: []
77 inputList += motionSensors ?: []
78 inputList += presenceSensors ?: []
79 inputList += switches ?: []
80 inputList += thermostats ?: []
81 inputList += waterSensors ?: []
85 //API external Endpoints
93 path("/devices/:id") {
105 path("/deviceSubscription") {
108 POST : "registerDeviceChange",
109 DELETE: "unregisterDeviceChange"
112 path("/locationSubscription") {
115 POST : "registerDeviceGraph",
116 DELETE: "unregisterDeviceGraph"
122 log.debug "Installing with settings: ${settings}"
127 log.debug "Updating with settings: ${settings}"
129 //Initialize state variables if didn't exist.
130 if (state.deviceSubscriptionMap == null) {
131 state.deviceSubscriptionMap = [:]
132 log.debug "deviceSubscriptionMap created."
134 if (state.locationSubscriptionMap == null) {
135 state.locationSubscriptionMap = [:]
136 log.debug "locationSubscriptionMap created."
138 if (state.verificationKeyMap == null) {
139 state.verificationKeyMap = [:]
140 log.debug "verificationKeyMap created."
144 registerAllDeviceSubscriptions()
148 log.debug "Initializing with settings: ${settings}"
149 state.deviceSubscriptionMap = [:]
150 log.debug "deviceSubscriptionMap created."
151 state.locationSubscriptionMap = [:]
152 log.debug "locationSubscriptionMap created."
153 state.verificationKeyMap = [:]
154 log.debug "verificationKeyMap created."
155 registerAllDeviceSubscriptions()
158 /*** Subscription Functions ***/
160 //Subscribe events for all devices
161 def registerAllDeviceSubscriptions() {
162 registerChangeHandler(inputs)
165 //Endpoints function: Subscribe to events from a specific device
166 def registerDeviceChange() {
167 def subscriptionEndpt = params.subscriptionURL
168 def deviceId = params.deviceId
169 def myDevice = findDevice(deviceId)
171 if (myDevice == null) {
172 httpError(404, "Cannot find device with device ID ${deviceId}.")
175 def theAtts = myDevice.supportedAttributes
177 theAtts.each { att ->
178 subscribe(myDevice, att.name, deviceEventHandler)
180 log.info "Subscribing for ${myDevice.displayName}"
182 if (subscriptionEndpt != null) {
183 if (state.deviceSubscriptionMap[deviceId] == null) {
184 state.deviceSubscriptionMap.put(deviceId, [subscriptionEndpt])
185 log.info "Added subscription URL: ${subscriptionEndpt} for ${myDevice.displayName}"
186 } else if (!state.deviceSubscriptionMap[deviceId].contains(subscriptionEndpt)) {
187 state.deviceSubscriptionMap[deviceId] << subscriptionEndpt
188 log.info "Added subscription URL: ${subscriptionEndpt} for ${myDevice.displayName}"
191 if (params.key != null) {
192 state.verificationKeyMap[subscriptionEndpt] = params.key
193 log.info "Added verification key: ${params.key} for ${subscriptionEndpt}"
197 httpError(500, "something went wrong: $e")
200 log.info "Current subscription map is ${state.deviceSubscriptionMap}"
201 log.info "Current verification key map is ${state.verificationKeyMap}"
205 //Endpoints function: Unsubscribe to events from a specific device
206 def unregisterDeviceChange() {
207 def subscriptionEndpt = params.subscriptionURL
208 def deviceId = params.deviceId
209 def myDevice = findDevice(deviceId)
211 if (myDevice == null) {
212 httpError(404, "Cannot find device with device ID ${deviceId}.")
216 if (subscriptionEndpt != null && subscriptionEndpt != "undefined") {
217 if (state.deviceSubscriptionMap[deviceId]?.contains(subscriptionEndpt)) {
218 if (state.deviceSubscriptionMap[deviceId].size() == 1) {
219 state.deviceSubscriptionMap.remove(deviceId)
221 state.deviceSubscriptionMap[deviceId].remove(subscriptionEndpt)
223 state.verificationKeyMap.remove(subscriptionEndpt)
224 log.info "Removed subscription URL: ${subscriptionEndpt} for ${myDevice.displayName}"
227 state.deviceSubscriptionMap.remove(deviceId)
228 log.info "Unsubscriping for ${myDevice.displayName}"
231 httpError(500, "something went wrong: $e")
234 log.info "Current subscription map is ${state.deviceSubscriptionMap}"
235 log.info "Current verification key map is ${state.verificationKeyMap}"
238 //Endpoints function: Subscribe to device additiona/removal updated in a location
239 def registerDeviceGraph() {
240 def subscriptionEndpt = params.subscriptionURL
242 if (subscriptionEndpt != null && subscriptionEndpt != "undefined") {
243 subscribe(location, "DeviceCreated", locationEventHandler, [filterEvents: false])
244 subscribe(location, "DeviceUpdated", locationEventHandler, [filterEvents: false])
245 subscribe(location, "DeviceDeleted", locationEventHandler, [filterEvents: false])
247 if (state.locationSubscriptionMap[location.id] == null) {
248 state.locationSubscriptionMap.put(location.id, [subscriptionEndpt])
249 log.info "Added subscription URL: ${subscriptionEndpt} for Location ${location.name}"
250 } else if (!state.locationSubscriptionMap[location.id].contains(subscriptionEndpt)) {
251 state.locationSubscriptionMap[location.id] << subscriptionEndpt
252 log.info "Added subscription URL: ${subscriptionEndpt} for Location ${location.name}"
255 if (params.key != null) {
256 state.verificationKeyMap[subscriptionEndpt] = params.key
257 log.info "Added verification key: ${params.key} for ${subscriptionEndpt}"
260 log.info "Current location subscription map is ${state.locationSubscriptionMap}"
261 log.info "Current verification key map is ${state.verificationKeyMap}"
264 httpError(400, "missing input parameter: subscriptionURL")
268 //Endpoints function: Unsubscribe to events from a specific device
269 def unregisterDeviceGraph() {
270 def subscriptionEndpt = params.subscriptionURL
273 if (subscriptionEndpt != null && subscriptionEndpt != "undefined") {
274 if (state.locationSubscriptionMap[location.id]?.contains(subscriptionEndpt)) {
275 if (state.locationSubscriptionMap[location.id].size() == 1) {
276 state.locationSubscriptionMap.remove(location.id)
278 state.locationSubscriptionMap[location.id].remove(subscriptionEndpt)
280 state.verificationKeyMap.remove(subscriptionEndpt)
281 log.info "Removed subscription URL: ${subscriptionEndpt} for Location ${location.name}"
284 httpError(400, "missing input parameter: subscriptionURL")
287 httpError(500, "something went wrong: $e")
290 log.info "Current location subscription map is ${state.locationSubscriptionMap}"
291 log.info "Current verification key map is ${state.verificationKeyMap}"
294 //When events are triggered, send HTTP post to web socket servers
295 def deviceEventHandler(evt) {
296 def evtDevice = evt.device
297 def evtDeviceType = getDeviceType(evtDevice)
300 if (evt.data != null) {
301 def evtData = parseJson(evt.data)
302 log.info "Received event for ${evtDevice.displayName}, data: ${evtData}, description: ${evt.descriptionText}"
305 if (evtDeviceType == "thermostat") {
306 deviceData = [name: evtDevice.displayName, id: evtDevice.id, status: evtDevice.status, deviceType: evtDeviceType, manufacturer: evtDevice.manufacturerName, model: evtDevice.modelName, attributes: deviceAttributeList(evtDevice, evtDeviceType), locationMode: getLocationModeInfo(), locationId: location.id]
308 deviceData = [name: evtDevice.displayName, id: evtDevice.id, status: evtDevice.status, deviceType: evtDeviceType, manufacturer: evtDevice.manufacturerName, model: evtDevice.modelName, attributes: deviceAttributeList(evtDevice, evtDeviceType), locationId: location.id]
311 def params = [body: deviceData]
313 //send event to all subscriptions urls
314 log.debug "Current subscription urls for ${evtDevice.displayName} is ${state.deviceSubscriptionMap[evtDevice.id]}"
315 state.deviceSubscriptionMap[evtDevice.id].each {
317 if (state.verificationKeyMap[it] != null) {
318 def key = state.verificationKeyMap[it]
319 params.header = [Signature: ComputHMACValue(key, groovy.json.JsonOutput.toJson(params.body))]
321 log.trace "POST URI: ${params.uri}"
322 log.trace "Header: ${params.header}"
323 log.trace "Payload: ${params.body}"
325 httpPostJson(params) { resp ->
326 log.trace "response status code: ${resp.status}"
327 log.trace "response data: ${resp.data}"
330 log.error "something went wrong: $e"
335 def locationEventHandler(evt) {
336 log.info "Received event for location ${location.name}/${location.id}, Event: ${evt.name}, description: ${evt.descriptionText}, apiServerUrl: ${apiServerUrl("")}"
338 case "DeviceCreated":
339 case "DeviceDeleted":
340 def evtDevice = evt.device
341 def evtDeviceType = getDeviceType(evtDevice)
342 def params = [body: [eventType: evt.name, deviceId: evtDevice.id, locationId: location.id]]
344 if (evt.name == "DeviceDeleted" && state.deviceSubscriptionMap[deviceId] != null) {
345 state.deviceSubscriptionMap.remove(evtDevice.id)
348 state.locationSubscriptionMap[location.id].each {
350 if (state.verificationKeyMap[it] != null) {
351 def key = state.verificationKeyMap[it]
352 params.header = [Signature: ComputHMACValue(key, groovy.json.JsonOutput.toJson(params.body))]
354 log.trace "POST URI: ${params.uri}"
355 log.trace "Header: ${params.header}"
356 log.trace "Payload: ${params.body}"
358 httpPostJson(params) { resp ->
359 log.trace "response status code: ${resp.status}"
360 log.trace "response data: ${resp.data}"
363 log.error "something went wrong: $e"
366 case "DeviceUpdated":
372 private ComputHMACValue(key, data) {
374 SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA1")
375 Mac mac = Mac.getInstance("HmacSHA1")
376 mac.init(secretKeySpec)
377 byte[] digest = mac.doFinal(data.getBytes("UTF-8"))
378 return byteArrayToString(digest)
379 } catch (InvalidKeyException e) {
380 log.error "Invalid key exception while converting to HMac SHA1"
384 private def byteArrayToString(byte[] data) {
385 BigInteger bigInteger = new BigInteger(1, data)
386 String hash = bigInteger.toString(16)
390 /*** Device Query/Update Functions ***/
392 //Endpoints function: return all device data in json format
396 def deviceType = getDeviceType(it)
397 if (deviceType == "thermostat") {
398 deviceData << [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType), locationMode: getLocationModeInfo()]
400 deviceData << [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType)]
404 log.debug "getDevices, return: ${deviceData}"
408 //Endpoints function: get device data
410 def it = findDevice(params.id)
411 def deviceType = getDeviceType(it)
413 if (deviceType == "thermostat") {
414 device = [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType), locationMode: getLocationModeInfo()]
416 device = [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType)]
419 log.debug "getDevice, return: ${device}"
423 //Endpoints function: update device data
424 void updateDevice() {
425 def device = findDevice(params.id)
430 def commandList = mapDeviceCommands(command, value)
431 command = commandList[0]
432 value = commandList[1]
434 if (command == "setAwayMode") {
435 log.info "Setting away mode to ${value}"
436 if (location.modes?.find { it.name == value }) {
437 location.setMode(value)
439 } else if (command == "thermostatSetpoint") {
440 switch (device.currentThermostatMode) {
442 log.info "Update: ${device.displayName}, [${command}, ${value}]"
443 device.setCoolingSetpoint(value)
446 case "emergency heat":
447 log.info "Update: ${device.displayName}, [${command}, ${value}]"
448 device.setHeatingSetpoint(value)
451 httpError(501, "this mode: ${device.currentThermostatMode} does not allow changing thermostat setpoint.")
454 } else if (!device) {
455 log.error "updateDevice, Device not found"
456 httpError(404, "Device not found")
457 } else if (!device.hasCommand(command)) {
458 log.error "updateDevice, Device does not have the command"
459 httpError(404, "Device does not have such command")
461 if (command == "setColor") {
462 log.info "Update: ${device.displayName}, [${command}, ${value}]"
463 device."$command"(hex: value)
464 } else if (value.isNumber()) {
465 def intValue = value as Integer
466 log.info "Update: ${device.displayName}, [${command}, ${intValue}(int)]"
467 device."$command"(intValue)
469 log.info "Update: ${device.displayName}, [${command}, ${value}]"
470 device."$command"(value)
472 log.info "Update: ${device.displayName}, [${command}]"
480 /*** Private Functions ***/
482 //Return current location mode info
483 private getLocationModeInfo() {
484 return [mode: location.mode, supported: location.modes.name]
487 //Map each device to a type given it's capabilities
488 private getDeviceType(device) {
490 def capabilities = device.capabilities
491 log.debug "capabilities: [${device}, ${capabilities}]"
492 log.debug "supported commands: [${device}, ${device.supportedCommands}]"
494 //Loop through the device capability list to determine the device type.
495 capabilities.each { capability ->
496 switch (capability.name.toLowerCase()) {
498 deviceType = "switch"
500 //If the device also contains "Switch Level" capability, identify it as a "light" device.
501 if (capabilities.any { it.name.toLowerCase() == "switch level" }) {
503 //If the device also contains "Power Meter" capability, identify it as a "dimmerSwitch" device.
504 if (capabilities.any { it.name.toLowerCase() == "power meter" }) {
505 deviceType = "dimmerSwitch"
513 case "garageDoorControl":
514 deviceType = "garageDoor"
520 deviceType = "camera"
523 deviceType = "thermostat"
525 case "acceleration sensor":
526 case "contact sensor":
527 case "motion sensor":
528 case "presence sensor":
530 deviceType = "genericSensor"
539 //Return a specific device give the device ID.
540 private findDevice(deviceId) {
541 return inputs?.find { it.id == deviceId }
544 //Return a list of device attributes
545 private deviceAttributeList(device, deviceType) {
546 def attributeList = [:]
547 def allAttributes = device.supportedAttributes
548 allAttributes.each { attribute ->
550 def currentState = device.currentState(attribute.name)
551 if (currentState != null) {
552 switch (attribute.name) {
554 attributeList.putAll([(attribute.name): currentState.value, 'temperatureScale': location.temperatureScale])
557 attributeList.putAll([(attribute.name): currentState.value])
560 if (deviceType == "genericSensor") {
561 def key = attribute.name + "_lastUpdated"
562 attributeList.putAll([(key): currentState.isoDate])
565 attributeList.putAll([(attribute.name): null]);
568 attributeList.putAll([(attribute.name): null]);
574 //Map device command and value.
575 //input command and value are from UWP,
576 //returns resultCommand and resultValue that corresponds with function and value in SmartApps
577 private mapDeviceCommands(command, value) {
578 log.debug "mapDeviceCommands: [${command}, ${value}]"
579 def resultCommand = command
580 def resultValue = value
583 if (value == 1 || value == "1" || value == "on") {
586 } else if (value == 0 || value == "0" || value == "off") {
587 resultCommand = "off"
593 resultCommand = "setLevel"
597 resultCommand = "setHue"
601 resultCommand = "setSaturation"
604 case "colorTemperature":
605 resultCommand = "setColorTemperature"
609 resultCommand = "setColor"
611 // thermostat attributes
613 resultCommand = "setThermostatMode"
617 resultCommand = "setThermostatFanMode"
621 resultCommand = "setAwayMode"
624 case "coolingSetpoint":
625 resultCommand = "setCoolingSetpoint"
628 case "heatingSetpoint":
629 resultCommand = "setHeatingSetpoint"
632 case "thermostatSetpoint":
633 resultCommand = "thermostatSetpoint"
638 if (value == 1 || value == "1" || value == "lock") {
639 resultCommand = "lock"
641 } else if (value == 0 || value == "0" || value == "unlock") {
642 resultCommand = "unlock"
650 return [resultCommand, resultValue]