diff --git a/smartapps/torick-net/smartthingstostart.src/smartthingstostart.groovy b/smartapps/torick-net/smartthingstostart.src/smartthingstostart.groovy new file mode 100644 index 0000000..df7b855 --- /dev/null +++ b/smartapps/torick-net/smartthingstostart.src/smartthingstostart.groovy @@ -0,0 +1,373 @@ +/** + * SmartThingsToStart REST Api + * + * Copyright 2017 Dr1rrb + * + * 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. + * + */ +definition( + name: "SmartThingsToStart", + namespace: "torick.net", + author: "Dr1rrb", + description: "SmartThingsToStart REST Api", + category: "My Apps", + iconUrl: "http://smartthingstostartproxy.azurewebsites.net/Assets/AppLogo.png", + iconX2Url: "http://smartthingstostartproxy.azurewebsites.net/Assets/AppLogo@2X.png", + iconX3Url: "http://smartthingstostartproxy.azurewebsites.net/Assets/AppLogo@3X.png", + oauth: true) + + +preferences { + section("Control these devices") { + input "switches", "capability.switch", title: "Select switches", multiple: true, required: false + input "bubls", "capability.bulb", title: "Select bubls", hideWhenEmpty: true, multiple: true, required: false + input "lights", "capability.light", title: "Select lights", hideWhenEmpty: true, multiple: true, required: false + input "outlets", "capability.outlet", title: "Select outlets", hideWhenEmpty: true, multiple: true, required: false + input "relaySwitches", "capability.relaySwitch", title: "Select relay switches", hideWhenEmpty: true, multiple: true, required: false + } +} + +mappings { + path("/infos") { + action: [GET: "retreiveServerInfos"] + } + path("/items") { + action: [GET: "retreiveDevicesAndRoutines"] + } + path("/device/:id") { + action: [GET: "retreiveDevice"] + } + path("/device/:id/subscription/:subscriptionId") { + action: [ + PUT: "updateOrCreateSubscription", + POST: "updateOrCreateSubscription", + ] + } + // TODO + //path("/device/:id/unsubscribe") { + // action: [POST: "unsubscribeFromDevice"] + //} + path("/device/:id/:command") { + action: [ PUT: "updateDevice" ] + } + path("/routine/:id/execute") { + action: [PUT: "executeRoutine"] + } +} + +// Region: App lifecycle +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + //state.pushChannels = [:] + initialize() +} + +def initialize() { + def channels = state.pushChannels = state.pushChannels ?: [:]; + channels.each + { + def device = findDevice(it.key); + if (device != null) + { + subscribeToDevice(device); + } + } +} + + +// Region: Http request handlers +def retreiveServerInfos() +{ + return [version: 1] +} + +def retreiveDevicesAndRoutines() { + def details = params.details == "true" ? true : false; + + return [ + devices: getDevices().collect { getDeviceInfos(it, details) }, + routines: location.helloHome?.getPhrases().collect { getRoutineInfos(it, details) } + ]; +} + +def retreiveDevice() { + def device = getDevice(params.id); + def details = params.details == "true" ? true : false; + + return getDeviceInfos(device, details); +} + +def updateOrCreateSubscription() { + def device = getDevice(params.id); + def channelUri = notNull("channelUri", request.JSON?.channelUri); + def token = notNull("token", request.JSON?.token); + + log.debug "Subscribing to device '${device.id}' (target: '${channelUri}' / token: '${token}')" + + // Get or create the push notification channel from / into the local state + def subscriptionId = params.subscriptionId ?: UUID.randomUUID().toString() ; + def allSubscriptions = state.pushChannels ?: (state.pushChannels = [:]); + def deviceSubscriptions = allSubscriptions[device.id] ?: (allSubscriptions[device.id] = []); + def subscription = deviceSubscriptions.find { it.id == subscriptionId }; + if (subscription == null) + { + deviceSubscriptions << [ + id: subscriptionId, + deviceId: device.id, + channelUri: channelUri, + token: token + ]; + } + else + { + subscription["channelUri"] = channelUri; + subscription["token"] = token; + } + + log.debug "Active subscriptions: \n" + state.pushChannels.collect { "** Device ${it.key} **\n" + it.value.collect{c -> "- - - > ${c.channelUri} : ${c.token.substring(0, 10)}..."}.join('\n') + "\n***************************" }.join('\n\n') + + // (Re)create the subscription(s) + subscribeToDevice(device) + + return [subscriptionId: subscriptionId]; +} + +def subscribeToDevice(device) +{ + log.debug "Subscribing to device '${device.id}'" + + unsubscribe(device); + subscribe(device, "switch", switchStateChanged) + + if (device.hasCapability("Color Control")) + { + log.debug "Device '${device.id}' has also the color capability. Subscribe to it." + subscribe(device, "color", colorStateChanged) + } +} + +def switchStateChanged(eventArgs) { sendPushNotification("switch", eventArgs) } +def colorStateChanged(eventArgs) { sendPushNotification("color", eventArgs) } + +def updateDevice() { + def device = getDevice(params.id) + def command = notNull("command", params.command) + + log.debug "Executing '${command}' on device '${device.id}'." + + switch(command) { + case "on": + case "On": + device.on() + break + + case "off": + case "Off": + device.off() + break + + case "toggle": + case "Toggle": + if (device.currentSwitch == "on") + device.off(); + else + device.on(); + break; + + default: + httpError(501, "'${command}' is not a valid command for '${device.id}'") + } + + return getDeviceInfos(device); +} + +def executeRoutine() { + def routine = getRoutine(params.id); + log.debug "Executing routine '${routine.id}' (${routine.label})" + + location.helloHome?.execute(routine.id) +} + +// Region: Get device +def getDevices() +{ + return switches + + bubls + + lights + + outlets + + relaySwitches; +} + +def findDevice(deviceId) +{ + notNull("deviceId", deviceId); + + return getDevices().find { it.id == deviceId }; +} + +def getDevice(deviceId) +{ + def device = findDevice(deviceId); + if (device == null) + { + httpError(404, "Device '${deviceId}' not found.") + } + return device; +} + +// Region: Get routine +def findRoutine(routineId) +{ + return location.helloHome?.getPhrases().find{ it.id == routineId}; +} + +def getRoutine(routineId) +{ + def routine = findRoutine(routineId); + if (routine == null) + { + httpError(404, "Routine '${routineId}' not found.") + } + return routine; +} + +// Region: Parameters assertion helpers +def notNull(parameterName, value) +{ + if(value == null || value == "") + { + httpError(404, "Missing parameter '${parameterName}'.") + } + return value; +} + +// Region: Get infos +def getDeviceInfos(device, details = false) +{ + def infos = [ + id: device.id, + name: device.displayName, + state: device.currentValue("switch"), + color: device.currentValue("color"), + hue: device.currentValue("hue"), + saturation: device.currentValue("saturation"), + capabilities: device.capabilities.collect { getCapabilityInfos(it, details) } + ] + + if (details) + { + infos["attributes"] = device.supportedAttributes.collect { getAttributeInfos(it, details) } + infos["commands"] = device.supportedCommands.collect { getCommandInfos(it, details) } + } + + return infos; +} + +def getCapabilityInfos(capablity, details = false) +{ + def infos = [name: capablity.name] + + if(details) + { + infos["attributes"] = capablity.attributes.collect { getAttributeInfos(it, details) } + infos["commands"] = capablity.commands.collect { getCommandInfos(it, details) } + } + + return infos; +} + +def getCommandInfos(command, details = false) +{ + return [ + name: command.name, + arguments: command.arguments + ] +} + +def getAttributeInfos(attribute, details = false) +{ + return [ + name: attribute.name, + arguments: attribute.dataType, + values: attribute.values + ] +} + +def getRoutineInfos(routine, details = false) +{ + def infos = [ + id: routine.id, + name: routine.label + ]; + + if (details) + { + infos["hasSecureActions"] = routine.hasSecureActions; + infos["action"] = routine.action; + } + + return infos; +} + +// Region: Push notification +def sendPushNotification(capability, eventArgs) +{ + def deviceId = eventArgs.deviceId; + log.debug "Received notification for '${capability}' for device '${deviceId}'."; + + def subscriptions = state.pushChannels.get(deviceId); + if (subscriptions == null || subscriptions.empty) + { + log.error "No subscription found for device ${deviceId}, unsubscribing!"; + unsubscribe(eventArgs.device); + + return; + } + + subscriptions.groupBy { it.channelUri }.each { sendPushNotification(capability, eventArgs, it.key, it.value) } +} + +def sendPushNotification(capability, eventArgs, channelUri, subscriptions) +{ + try { + def request = [ + uri: channelUri, + //headers: [name: "Authorization", value: "Bearer ${subscription.token}"], + body: [ + location: [ + id: eventArgs.locationId, + ], + device: getDeviceInfos(eventArgs.device), + event: [ + source: capability, + date: eventArgs.isoDate, + value: eventArgs.value, + name: eventArgs.name, + ], + subscriptions: subscriptions.collect { [id: it.id, token: it.token] } + ] + ] + + // Async post is still in beta stage ... + httpPostJson(request) { resp -> log.debug "response: ${resp.status}." } + + } catch (e) { + log.error "Failed to push notification: ${e}" + } +} \ No newline at end of file