diff --git a/devicetypes/com-lametric/lametric.src/lametric.groovy b/devicetypes/com-lametric/lametric.src/lametric.groovy new file mode 100644 index 0000000..93d6dd5 --- /dev/null +++ b/devicetypes/com-lametric/lametric.src/lametric.groovy @@ -0,0 +1,126 @@ +/** + * LaMetric + * + * Copyright 2016 Smart Atoms Ltd. + * Author: Mykola Kirichuk + * + * 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: "LaMetric", namespace: "com.lametric", author: "Mykola Kirichuk") { + capability "Actuator" + capability "Notification" + capability "Polling" + capability "Refresh" + + attribute "currentIP", "string" + attribute "serialNumber", "string" + attribute "volume", "string" + attribute "mode", "enum", ["offline","online"] + + command "setOffline" + command "setOnline" + } + + simulator { + // TODO: define status and reply messages here + } + + tiles (scale: 2){ + // TODO: define your main and details tiles here + tiles(scale: 2) { + multiAttributeTile(name:"rich-control"){ + tileAttribute ("mode", key: "PRIMARY_CONTROL") { + attributeState "online", label: "LaMetric", action: "", icon: "https://developer.lametric.com/assets/smart_things/time_100.png", backgroundColor: "#F3C200" + attributeState "offline", label: "LaMetric", action: "", icon: "https://developer.lametric.com/assets/smart_things/time_100.png", backgroundColor: "#F3F3F3" + } + tileAttribute ("serialNumber", key: "SECONDARY_CONTROL") { + attributeState "default", label:'SN: ${currentValue}' + } + } + valueTile("serialNumber", "device.serialNumber", decoration: "flat", height: 1, width: 2, inactiveLabel: false) { + state "default", label:'SN: ${currentValue}' + } + valueTile("networkAddress", "device.currentIP", decoration: "flat", height: 2, width: 4, inactiveLabel: false) { + state "default", label:'${currentValue}', height: 1, width: 2, inactiveLabel: false + } + + main (["rich-control"]) + details(["rich-control","networkAddress"]) + } + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + if (description) + { + unschedule("setOffline") + } + // TODO: handle 'battery' attribute + // TODO: handle 'button' attribute + // TODO: handle 'status' attribute + // TODO: handle 'level' attribute + // TODO: handle 'level' attribute + +} + +// handle commands +def setOnline() +{ + log.debug("set online"); + sendEvent(name:"mode", value:"online") + unschedule("setOffline") +} +def setOffline(){ + log.debug("set offline"); + sendEvent(name:"mode", value:"offline") +} + +def setLevel(level) { + log.debug "Executing 'setLevel' ${level}" + // TODO: handle 'setLevel' command +} + +def deviceNotification(notif) { + log.debug "Executing 'deviceNotification' ${notif}" + // TODO: handle 'deviceNotification' command + def result = parent.sendNotificationMessageToDevice(device.deviceNetworkId, notif); + log.debug ("result ${result}"); + log.debug parent; + return result; +} + +def poll() { + // TODO: handle 'poll' command + log.debug "Executing 'poll'" + if (device.currentValue("currentIP") != "Offline") + { + runIn(30, setOffline) + } + parent.poll(device.deviceNetworkId) +} + +def refresh() { + log.debug "Executing 'refresh'" +// log.debug "${device?.currentIP}" + log.debug "${device?.currentValue("currentIP")}" + log.debug "${device?.currentValue("serialNumber")}" + log.debug "${device?.currentValue("volume")}" +// poll() + +} + +/*def setLevel() { + log.debug "Executing 'setLevel'" + // TODO: handle 'setLevel' command +}*/ \ No newline at end of file diff --git a/smartapps/com-lametric/lametric-connect.src/lametric-connect.groovy b/smartapps/com-lametric/lametric-connect.src/lametric-connect.groovy new file mode 100644 index 0000000..2f148e1 --- /dev/null +++ b/smartapps/com-lametric/lametric-connect.src/lametric-connect.groovy @@ -0,0 +1,864 @@ +/** + * LaMetric (Connect) + * + * Copyright 2016 Smart Atoms Ltd. + * Author: Mykola Kirichuk + * + * 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. + * + */ + +import groovy.json.JsonOutput + +definition( + name: "LaMetric (Connect)", + namespace: "com.lametric", + author: "Mykola Kirichuk", + description: "Lametric connect", + category: "Fun & Social", + iconUrl: "https://developer.lametric.com/assets/smart_things/smart_things_60.png", + iconX2Url: "https://developer.lametric.com/assets/smart_things/smart_things_120.png", + iconX3Url: "https://developer.lametric.com/assets/smart_things/smart_things_120.png", + singleInstance: true) + { + appSetting "clientId" + appSetting "clientSecret" + } + +preferences { + page(name: "auth", title: "LaMetric", nextPage:"", content:"authPage", uninstall: true, install:true) + page(name:"deviceDiscovery", title:"Device Setup", content:"deviceDiscovery", refreshTimeout:5); +} + +mappings { + path("/oauth/initialize") {action: [GET: "oauthInitUrl"]} + path("/oauth/callback") {action: [GET: "callback"]} +} + + +def getEventNameListOfUserDeviceParsed(){ "EventListOfUserRemoteDevicesParsed" } +def getEventNameTokenRefreshed(){ "EventAuthTokenRefreshed" } + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + sendEvent(name:"Updated", value:true) + unsubscribe() + initialize() + + +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. + log.debug("initialize"); + state.subscribe = false; + if (selecteddevice) { + addDevice() + subscribeNetworkEvents(true) + refreshDevices(); + } +} + +/** + * Get the name of the new device to instantiate in the user's smartapps + * This must be an app owned by the namespace (see #getNameSpace). + * + * @return name + */ + +def getDeviceName() { + return "LaMetric" +} + +/** + * Returns the namespace this app and siblings use + * + * @return namespace + */ +def getNameSpace() { + return "com.lametric" +} + + +/** + * Returns all discovered devices or an empty array if none + * + * @return array of devices + */ +def getDevices() { + state.remoteDevices = state.remoteDevices ?: [:] +} + +/** + * Returns an array of devices which have been verified + * + * @return array of verified devices + */ +def getVerifiedDevices() { + getDevices().findAll{ it?.value?.verified == true } +} + +/** + * Generates a Map object which can be used with a preference page + * to represent a list of devices detected and verified. + * + * @return Map with zero or more devices + */ +Map getSelectableDevice() { + def devices = getVerifiedDevices() + def map = [:] + devices.each { + def value = "${it.value.name}" + def key = it.value.id + map["${key}"] = value + } + map +} + +/** + * Starts the refresh loop, making sure to keep us up-to-date with changes + * + */ +private refreshDevices(){ + log.debug "refresh device list" + listOfUserRemoteDevices() + //every 30 min + runIn(1800, "refreshDevices") +} + +/** + * The deviceDiscovery page used by preferences. Will automatically + * make calls to the underlying discovery mechanisms as well as update + * whenever new devices are discovered AND verified. + * + * @return a dynamicPage() object + */ +/****************************************************************************************************************** + DEVICE DISCOVERY AND VALIDATION +******************************************************************************************************************/ +def deviceDiscovery() +{ +// if(canInstallLabs()) + if (1) + { +// userDeviceList(); + log.debug("deviceDiscovery") + def refreshInterval = 3 // Number of seconds between refresh + int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int + state.deviceRefreshCount = deviceRefreshCount + refreshInterval + + def devices = getSelectableDevice() + def numFound = devices.size() ?: 0 + + // Make sure we get location updates (contains LAN data such as SSDP results, etc) + subscribeNetworkEvents() + + //device discovery request every 15s +// if((deviceRefreshCount % 15) == 0) { +// discoverLaMetrics() +// } + + // Verify request every 3 seconds except on discoveries + if(((deviceRefreshCount % 5) == 0)) { + verifyDevices() + } + + log.trace "Discovered devices: ${devices}" + + return dynamicPage(name:"deviceDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) { + section("Please wait while we discover your ${getDeviceName()}. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selecteddevice", "enum", required:false, title:"Select ${getDeviceName()} (${numFound} found)", multiple:true, options:devices + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"deviceDiscovery", title:"Upgrade needed!", nextPage:"", install:true, uninstall: true) { + section("Upgrade") { + paragraph "$upgradeNeeded" + } + } + } +} + +/** + +/** + * Starts a subscription for network events + * + * @param force If true, will unsubscribe and subscribe if necessary (Optional, default false) + */ +private subscribeNetworkEvents(force=false) { + if (force) { + unsubscribe() + state.subscribe = false + } + if(!state.subscribe) { + log.debug("subscribe on network events") + subscribe(location, null, locationHandler, [filterEvents:false]) +// subscribe(app, appHandler) + state.subscribe = true + } +} + +private verifyDevices() +{ + log.debug "verify.devices" + def devices = getDevices(); + for (it in devices) { + log.trace ("verify device ${it.value}") + def localIp = it?.value?.ipv4_internal; + def apiKey = it?.value?.api_key; + getAllInfoFromDevice(localIp, apiKey); + } +} +def appHandler(evt) +{ + log.debug("application event handler ${evt.name}") + if (evt.name == eventNameListOfUserDeviceParsed) + { + log.debug ("new account device list received ${evt.value}") + def newRemoteDeviceList + try { + newRemoteDeviceList = parseJson(evt.value) + } catch (e) + { + log.debug "Wrong value ${e}" + } + if (newRemoteDeviceList) + { + def remoteDevices = getDevices(); + newRemoteDeviceList.each{deviceInfo -> + if (deviceInfo) { + def device = remoteDevices[deviceInfo.id]?:[:]; + log.debug "before list ${device} ${deviceInfo} ${deviceInfo.id} ${remoteDevices[deviceInfo.id]}"; + deviceInfo.each() { + device[it.key] = it.value; + } + remoteDevices[deviceInfo.id] = device; + } else { + log.debug ("empty device info") + } + } + verifyDevices(); + } else { + log.debug "wrong value ${newRemoteDeviceList}" + } + } else if (evt.name == getEventNameTokenRefreshed()) + { + log.debug "token refreshed" + state.refreshToken = evt.refreshToken + state.authToken = evt.access_token + } +} + +def locationHandler(evt) +{ + log.debug("network event handler ${evt.name}") + if (evt.name == "ssdpTerm") + { + log.debug "ignore ssdp" + } else { + def lanEvent = parseLanMessage(evt.description, true) + log.debug lanEvent.headers; + if (lanEvent.body) + { + log.trace "lan event ${lanEvent}"; + def parsedJsonBody; + try { + parsedJsonBody = parseJson(lanEvent.body); + } catch (e) + { + log.debug ("not json responce ignore $e"); + } + if (parsedJsonBody) + { + log.trace (parsedJsonBody) + log.debug("responce for device ${parsedJsonBody?.server_id}") + //put or post response + if (parsedJsonBody.success) + { + + } else { + //poll response + log.debug "poll responce" + log.debug ("poll responce ${parsedJsonBody?.info}") + def deviceId = parsedJsonBody?.info?.server_id; + if (deviceId) + { + def devices = getDevices(); + def device = devices."${deviceId}"; + + device.verified = true; + device.dni = [device.serial_number, device.id].join('.') + device.hub = evt?.hubId; + device.volume = parsedJsonBody?.audio?.volume; + log.debug "verified device ${deviceId}" + def childDevice = getChildDevice(device.dni) + //update device info + if (childDevice) + { + log.debug("send event to ${childDevice}") + childDevice.sendEvent(name:"currentIP",value:device?.ipv4_internal); + childDevice.sendEvent(name:"volume",value:device?.volume); + childDevice.setOnline(); + } + log.trace device + } + } + } + } + } +} + +/** + * Adds the child devices based on the user's selection + * + * Uses selecteddevice defined in the deviceDiscovery() page + */ +def addDevice() { + def devices = getVerifiedDevices() + def devlist + log.trace "Adding childs" + + // If only one device is selected, we don't get a list (when using simulator) + if (!(selecteddevice instanceof List)) { + devlist = [selecteddevice] + } else { + devlist = selecteddevice + } + + log.trace "These are being installed: ${devlist}" + log.debug ("devlist" + devlist) + devlist.each { dni -> + def newDevice = devices[dni]; + if (newDevice) + { + def d = getChildDevice(newDevice.dni) + if(!d) { + log.debug ("get child devices" + getChildDevices()) + log.trace "concrete device ${newDevice}" + def deviceName = newDevice.name + d = addChildDevice(getNameSpace(), getDeviceName(), newDevice.dni, newDevice.hub, [label:"${deviceName}"]) + def childDevice = getChildDevice(d.deviceNetworkId) + childDevice.sendEvent(name:"serialNumber", value:newDevice.serial_number) + log.trace "Created ${d.displayName} with id $dni" + } else { + log.trace "${d.displayName} with id $dni already exists" + } + } + } +} + + +//****************************************************************************************************************** +// OAUTH +//****************************************************************************************************************** + +def getServerUrl() { "https://graph.api.smartthings.com" } +def getShardUrl() { getApiServerUrl() } +def getCallbackUrl() { "https://graph.api.smartthings.com/oauth/callback" } +def getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${shardUrl}" } +def getApiEndpoint() { "https://developer.lametric.com" } +def getTokenUrl() { "${apiEndpoint}${apiTokenPath}" } +def getAuthScope() { [ "basic", "devices_read" ] } +def getSmartThingsClientId() { appSettings.clientId } +def getSmartThingsClientSecret() { appSettings.clientSecret } +def getApiTokenPath() { "/api/v2/oauth2/token" } +def getApiUserMeDevicesList() { "/api/v2/users/me/devices" } + +def toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} +def composeScope(List scopes) +{ + def result = ""; + scopes.each(){ scope -> + result += "${scope} " + } + if (result.length()) + return result.substring(0, result.length() - 1); + return ""; +} + +def authPage() { + log.debug "authPage()" + + if(!state.accessToken) { //this is to access token for 3rd party to make a call to connect app + state.accessToken = createAccessToken() + } + + def description + def uninstallAllowed = false + def oauthTokenProvided = false + + if(state.authToken) { + description = "You are connected." + uninstallAllowed = true + oauthTokenProvided = true + } else { + description = "Click to enter LaMetric Credentials" + } + + def redirectUrl = buildRedirectUrl + log.debug "RedirectUrl = ${redirectUrl}" + // get rid of next button until the user is actually auth'd + if (!oauthTokenProvided) { + return dynamicPage(name: "auth", title: "Login", nextPage: "", uninstall:uninstallAllowed) { + section(){ + paragraph "Tap below to log in to the LaMatric service and authorize SmartThings access. Be sure to scroll down on page 2 and press the 'Allow' button." + href url:redirectUrl, style:"embedded", required:true, title:"LaMetric", description:description + } + } + } else { + subscribeNetworkEvents() + listOfUserRemoteDevices() + return deviceDiscovery(); + /* + return deviceDiscovery(); + + def stats = getEcobeeThermostats() + log.debug "thermostat list: $stats" + log.debug "sensor list: ${sensorsDiscovered()}" + return dynamicPage(name: "auth", title: "Select Your Thermostats", uninstall: true) { + section(""){ + paragraph "Tap below to see the list of ecobee thermostats available in your ecobee account and select the ones you want to connect to SmartThings." + input(name: "thermostats", title:"", type: "enum", required:true, multiple:true, description: "Tap to choose", metadata:[values:stats]) + } + + def options = sensorsDiscovered() ?: [] + def numFound = options.size() ?: 0 + if (numFound > 0) { + section(""){ + paragraph "Tap below to see the list of ecobee sensors available in your ecobee account and select the ones you want to connect to SmartThings." + input(name: "ecobeesensors", title:"Select Ecobee Sensors (${numFound} found)", type: "enum", required:false, description: "Tap to choose", multiple:true, options:options) + } + } + }*/ + } +} + + +private refreshAuthToken() { + log.debug "refreshing auth token" + + if(!state.refreshToken) { + log.warn "Can not refresh OAuth token since there is no refreshToken stored" + } else { + def refreshParams = [ + method: 'POST', + uri : apiEndpoint, + path : apiTokenPath, + body : [grant_type: 'refresh_token', + refresh_token: "${state.refreshToken}", + client_id : smartThingsClientId, + client_secret: smartThingsClientSecret, + redirect_uri: callbackUrl], + ] + + log.debug refreshParams + + def notificationMessage = "is disconnected from SmartThings, because the access credential changed or was lost. Please go to the LaMetric (Connect) SmartApp and re-enter your account login credentials." + //changed to httpPost + try { + def jsonMap + httpPost(refreshParams) { resp -> + if(resp.status == 200) { + log.debug "Token refreshed...calling saved RestAction now! $resp.data" + jsonMap = resp.data + if(resp.data) { + state.refreshToken = resp?.data?.refresh_token + state.authToken = resp?.data?.access_token + if(state.action && state.action != "") { + log.debug "Executing next action: ${state.action}" + + "${state.action}"() + + state.action = "" + } + + } else { + log.warn ("No data in refresh token!"); + } + state.action = "" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "refreshAuthToken() >> Error: e.statusCode ${e.statusCode}" + log.debug e.response.data; + def reAttemptPeriod = 300 // in sec + if (e.statusCode != 401) { // this issue might comes from exceed 20sec app execution, connectivity issue etc. + runIn(reAttemptPeriod, "refreshAuthToken") + } else if (e.statusCode == 401) { // unauthorized + state.reAttempt = state.reAttempt + 1 + log.warn "reAttempt refreshAuthToken to try = ${state.reAttempt}" + if (state.reAttempt <= 3) { + runIn(reAttemptPeriod, "refreshAuthToken") + } else { + sendPushAndFeeds(notificationMessage) + state.reAttempt = 0 + } + } + } + } +} + +def callback() { + log.debug "callback()>> params: $params, params.code ${params.code}" + + def code = params.code + def oauthState = params.state + + if (oauthState == state.oauthInitState){ + + def tokenParams = [ + grant_type: "authorization_code", + code : code, + client_id : smartThingsClientId, + client_secret: smartThingsClientSecret, + redirect_uri: callbackUrl + ] + log.trace tokenParams + log.trace tokenUrl + try { + httpPost(uri: tokenUrl, body: tokenParams) { resp -> + log.debug "swapped token: $resp.data" + state.refreshToken = resp.data.refresh_token + state.authToken = resp.data.access_token + } + } catch (e) + { + log.debug "fail ${e}"; + } + if (state.authToken) { + success() + } else { + fail() + } + } else { + log.error "callback() failed oauthState != state.oauthInitState" + } + +} + +def oauthInitUrl() { + log.debug "oauthInitUrl with callback: ${callbackUrl}" + + state.oauthInitState = UUID.randomUUID().toString() + + def oauthParams = [ + response_type: "code", + scope: composeScope(authScope), + client_id: smartThingsClientId, + state: state.oauthInitState, + redirect_uri: callbackUrl + ] + log.debug oauthParams + log.debug "${apiEndpoint}/api/v2/oauth2/authorize?${toQueryString(oauthParams)}" + + redirect(location: "${apiEndpoint}/api/v2/oauth2/authorize?${toQueryString(oauthParams)}") +} + +def success() { + def message = """ +

Your LaMetric Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + connectionStatus(message) +} + +def fail() { + def message = """ +

The connection could not be established!

+

Click 'Done' to return to the menu.

+ """ + connectionStatus(message) +} + +def connectionStatus(message, redirectUrl = null) { + def redirectHtml = "" + if (redirectUrl) { + redirectHtml = """ + + """ + } + + def html = """ + + + + + + + + +
+
+ +
+ +
+ +
+ ${message} +
+
+
+ +""" + render contentType: 'text/html', data: html +} + + + +//****************************************************************************************************************** +// LOCAL API +//****************************************************************************************************************** + +def getLocalApiDeviceInfoPath() { "/api/v2/info" } +def getLocalApiSendNotificationPath() { "/api/v2/notifications" } +def getLocalApiIndexPath() { "/api/v2" } +def getLocalApiUser() { "dev" } + + +void requestDeviceInfo(localIp, apiKey) +{ + if (localIp && apiKey) + { + log.debug("request info ${localIp}"); + def command = new physicalgraph.device.HubAction([ + method: "GET", + path: localApiDeviceInfoPath, + headers: [ + HOST: "${localIp}:8080", + Authorization: "Basic ${"${localApiUser}:${apiKey}".bytes.encodeBase64()}" + ]]) + log.debug command + sendHubCommand(command) + command; + } else { + log.debug ("Unknown api key or ip address ${localIp} ${apiKey}") + } +} + +def sendNotificationMessageToDevice(dni, data) +{ + log.debug "send something" + def device = resolveDNI2Device(dni); + def localIp = device?.ipv4_internal; + def apiKey = device?.api_key; + if (localIp && apiKey) + { + log.debug "send notification message to device ${localIp}:8080 ${data}" + sendHubCommand(new physicalgraph.device.HubAction([ + method: "POST", + path: localApiSendNotificationPath, + body: data, + headers: [ + HOST: "${localIp}:8080", + Authorization: "Basic ${"${localApiUser}:${apiKey}".bytes.encodeBase64()}" + ]])) + } +} + +def getAllInfoFromDevice(localIp, apiKey) +{ + log.debug "send something" + if (localIp && apiKey) + { + sendHubCommand(new physicalgraph.device.HubAction([ + method: "GET", + path: localApiIndexPath, + query:[info:1,bluetooth:1,wifi:1,audio:1,display:1], + headers: [ + HOST: "${localIp}:8080", + Authorization: "Basic ${"${localApiUser}:${apiKey}".bytes.encodeBase64()}" + ]])) + } +} +//****************************************************************************************************************** +// DEVICE HANDLER COMMANDs API +//****************************************************************************************************************** + +def resolveDNI2Device(dni) +{ + getDevices().find { it?.value?.dni == dni }?.value; +} + +def requestRefreshDeviceInfo (dni) +{ + log.debug "device ${dni} request refresh"; +// def devices = getDevices(); +// def concreteDevice = devices[dni]; +// requestDeviceInfo(conreteDevice); +} + +private poll(dni) { + def device = resolveDNI2Device(dni); + def localIp = device?.ipv4_internal; + def apiKey = device?.api_key; + getAllInfoFromDevice(localIp, apiKey); +} + +//****************************************************************************************************************** +// CLOUD METHODS +//****************************************************************************************************************** + + +void listOfUserRemoteDevices() +{ + log.debug "get user device list" + def deviceList = [] + if (state.accessToken) + { + def deviceListParams = [ + uri: apiEndpoint, + path: apiUserMeDevicesList, + headers: ["Content-Type": "text/json", "Authorization": "Bearer ${state.authToken}"] + ] + log.debug "making request ${deviceListParams}" + def result; + try { + httpGet(deviceListParams){ resp -> + if (resp.status == 200) + { + deviceList = resp.data + + def remoteDevices = getDevices(); + for (deviceInfo in deviceList) { + if (deviceInfo) + { + def device = remoteDevices."${deviceInfo.id}"?:[:]; + log.debug "before list ${device} ${deviceInfo} ${deviceInfo.id} ${remoteDevices[deviceInfo.id]}"; + for (it in deviceInfo ) { + device."${it.key}" = it.value; + } + remoteDevices."${deviceInfo.id}" = device; + } else { + log.debug ("empty device info") + } + } +// state.remoteDevices = remoteDevices; + verifyDevices(); +// return +// def serializedData = new JsonOutput().toJson(notification); +// app.sendEvent(name: "EventListOfUserRemoteDevicesParsed", value: serializedData) +// app.sendEvent(name: "parsed", value: true) + // log.debug "Sending 'save new list' event ${result}" + } else { + log.debug "http status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) + { + log.debug("failed to get device list ${e}") + def status = e.response.status + if (status == 401) { + state.action = "refreshDevices" + log.debug "Refreshing your auth_token!" + refreshAuthToken() + } + return; + } + } else { + log.debug ("no access token to fetch user device list"); + return; + } +} \ No newline at end of file diff --git a/smartapps/com-lametric/lametric-notifier.src/lametric-notifier.groovy b/smartapps/com-lametric/lametric-notifier.src/lametric-notifier.groovy new file mode 100644 index 0000000..55f0f50 --- /dev/null +++ b/smartapps/com-lametric/lametric-notifier.src/lametric-notifier.groovy @@ -0,0 +1,549 @@ +/** + * Lametric Notifier + * + * Copyright 2016 Smart Atoms Ltd. + * Author: Mykola Kirichuk + * + * 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. + * + */ + import groovy.json.JsonOutput + +definition( + name: "LaMetric Notifier", + namespace: "com.lametric", + author: "Mykola Kirichuk", + description: "Notify about changes with sound and message on your LaMetric", + category: "Fun & Social", + iconUrl: "https://developer.lametric.com/assets/smart_things/weather_60.png", + iconX2Url: "https://developer.lametric.com/assets/smart_things/weather_120.png", + iconX3Url: "https://developer.lametric.com/assets/smart_things/weather_120.png") + + +preferences { + page(name: "mainPage", title: "Show a message on your LaMetric when something happens", install: true, uninstall: true) + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + + + + +def getSoundList() { + [ + "none":"No Sound", + "car" : "Car", + "cash" : "Cash Register", + "cat" : "Cat Meow", + "dog" : "Dog Bark", + "dog2" : "Dog Bark 2", + "letter_email" : "The mail has arrived", + "knock-knock" : "Knocking Sound", + "bicycle" : "Bicycle", + "negative1" : "Negative 1", + "negative2" : "Negative 2", + "negative3" : "Negative 3", + "negative4" : "Negative 4", + "negative5" : "Negative 5", + "lose1" : "Lose 1", + "lose2" : "Lose 2", + "energy" : "Energy", + "water1" : "Water 1", + "water2" : "Water 2", + "notification" : "Notification 1", + "notification2" : "Notification 2", + "notification3" : "Notification 3", + "notification4" : "Notification 4", + "open_door" : "Door unlocked", + "win" : "Win", + "win2" : "Win 2", + "positive1" : "Positive 1", + "positive2" : "Positive 2", + "positive3" : "Positive 3", + "positive4" : "Positive 4", + "positive5" : "Positive 5", + "positive6" : "Positive 6", + "statistic" : "Page turning", + "wind" : "Wind", + "wind_short" : "Small Wind", + ] +} + +def getControlToAttributeMap(){ + [ + "motion": "motion.active", + "contact": "contact.open", + "contactClosed": "contact.close", + "acceleration": "acceleration.active", + "mySwitch": "switch.on", + "mySwitchOff": "switch.off", + "arrivalPresence": "presence.present", + "departurePresence": "presence.not present", + "smoke": "smoke.detected", + "smoke1": "smoke.tested", + "water": "water.wet", + "button1": "button.pushed", + "triggerModes": "mode", + "timeOfDay": "time", + ] +} + +def getPriorityList(){ + [ + "warning":"Not So Important (may be ignored at night)", + "critical": "Very Important" + ] +} + +def getIconsList(){ + state.icons = state.icons?:["1":"default"] +} + + +def getIconLabels() { + state.iconLabels = state.iconLabels?:["1":"Default Icon"] +} + +def getSortedIconLabels() { + state.iconLabels = state.iconLabels?:["1":"Default Icon"] + state.iconLabels.sort {a,b -> a.key.toInteger() <=> b.key.toInteger()}; +} +def getLametricHost() { "https://developer.lametric.com" } +def getDefaultIconData() { """data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAe0lEQVQYlWNUVFBgYGBgYNi6bdt/BiTg7eXFyMDAwMCELBmz7z9DzL7/KBoYr127BpeEgbV64QzfRVYxMDAwMLAgSy5xYoSoeMPAwPkmjOG7yCqIgjf8WVC90xnQAdwKj7OZcMGD8m/hVjDBXLvDGKEbJunt5cXISMibAF0FMibYF7nMAAAAAElFTkSuQmCC""" } + +def mainPage() { + def iconRequestOptions = [headers: ["Accept": "application/json"], + uri: "${lametricHost}/api/v2/icons", query:["fields":"id,title,type,code"]]; + + def icons = getIconsList(); + def iconLabels = getIconLabels(); + if (icons?.size() <= 2) + { + log.debug iconRequestOptions + try { + httpGet(iconRequestOptions) { resp -> + int i = 2; + resp.data.data.each(){ + def iconId = it?.id + def iconType = it?.type + def prefix = "i" + if (iconId) + { + if (iconType == "movie") + { + prefix = "a" + } + def iconurl = "${lametricHost}/content/apps/icon_thumbs/${prefix}${iconId}_icon_thumb_big.png"; + icons["$i"] = it.code + iconLabels["$i"] = it.title + } else { + log.debug "wrong id" + } + ++i; + } + } + } catch (e) + { + log.debug "fail ${e}"; + } + } + dynamicPage(name: "mainPage") { + def anythingSet = anythingSet() + def notificationMessage = defaultNotificationMessage(); + log.debug "set $anythingSet" + if (anythingSet) { + section("Show message when"){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true , submitOnChange:true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true, submitOnChange:true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true, submitOnChange:true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true, submitOnChange:true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true, submitOnChange:true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true, submitOnChange:true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true, submitOnChange:true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true, submitOnChange:true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true, submitOnChange:true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true, submitOnChange:true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true, submitOnChange:true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false, submitOnChange:true + } + } + def hideable = anythingSet || app.installationState == "COMPLETE" + def sectionTitle = anythingSet ? "Select additional triggers" : "Show message when..." + + section(sectionTitle, hideable: hideable, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true, submitOnChange:true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true, submitOnChange:true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true, submitOnChange:true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true, submitOnChange:true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true, submitOnChange:true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true, submitOnChange:true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true, submitOnChange:true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true, submitOnChange:true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true, submitOnChange:true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true, submitOnChange:true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true, submitOnChange:true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false, submitOnChange:true + } + + section (title:"Select LaMetrics"){ + input "selectedDevices", "capability.notification", required: true, multiple:true + } + section (title: "Configure message"){ + input "defaultMessage", "bool", title: "Use Default Text:\n\"$notificationMessage\"", required: false, defaultValue: true, submitOnChange:true + def showMessageInput = (settings["defaultMessage"] == null || settings["defaultMessage"] == true) ? false : true; + if (showMessageInput) + { + input "customMessage","text",title:"Use Custom Text", defaultValue:"", required:false, multiple: false + } + input "selectedIcon", "enum", title: "With Icon", required: false, multiple: false, defaultValue:"1", options: getSortedIconLabels() + input "selectedSound", "enum", title: "With Sound", required: true, defaultValue:"none" , options: soundList + input "showPriority", "enum", title: "Is This Notification Very Important?", required: true, multiple:false, defaultValue: "warning", options: priorityList + } + section("More options", hideable: true, hidden: true) { + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + if (settings.modes) { + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +private songOptions() { + log.trace "song option" + // Make sure current selection is in the set + + def options = new LinkedHashSet() + if (state.selectedSong?.station) { + options << state.selectedSong.station + } + else if (state.selectedSong?.description) { + // TODO - Remove eventually? 'description' for backward compatibility + options << state.selectedSong.description + } + + // Query for recent tracks + def states = sonos.statesSince("trackData", new Date(0), [max:30]) + def dataMaps = states.collect{it.jsonValue} + options.addAll(dataMaps.collect{it.station}) + + log.trace "${options.size()} songs in list" + options.take(20) as List +} + +private anythingSet() { + for (it in controlToAttributeMap) { + log.debug ("key ${it.key} value ${settings[it.key]} ${settings[it.key]?true:false}") + if (settings[it.key]) { + log.debug constructMessageFor(it.value, settings[it.key]) + return true + } + } + return false +} + +def defaultNotificationMessage(){ + def message = ""; + for (it in controlToAttributeMap) { + if (settings[it.key]) { + message = constructMessageFor(it.value, settings[it.key]) + break; + } + } + return message; +} + +def constructMessageFor(group, device) +{ + log.debug ("$group $device") + def message; + def firstDevice; + if (device instanceof List) + { + firstDevice = device[0]; + } else { + firstDevice = device; + } + switch(group) + { + case "motion.active": + message = "Motion detected by $firstDevice.displayName at $location.name" + break; + case "contact.open": + message = "Openning detected by $firstDevice.displayName at $location.name" + break; + case "contact.closed": + message = "Closing detected by $firstDevice.displayName at $location.name" + break; + case "acceleration.active": + message = "Acceleration detected by $firstDevice.displayName at $location.name" + break; + case "switch.on": + message = "$firstDevice.displayName turned on at $location.name" + break; + case "switch.off": + message = "$firstDevice.displayName turned off at $location.name" + break; + case "presence.present": + message = "$firstDevice.displayName detected arrival at $location.name" + break; + case "presence.not present": + message = "$firstDevice.displayName detected departure at $location.name" + break; + case "smoke.detected": + message = "Smoke detected by $firstDevice.displayName at $location.name" + break; + case "smoke.tested": + message = "Smoke tested by $firstDevice.displayName at $location.name" + break; + case "water.wet": + message = "Dampness detected by $firstDevice.displayName at $location.name" + break; + case "button.pushed": + message = "$firstDevice.displayName pushed at $location.name" + break; + case "time": + break; +// case "mode": +// message = "Mode changed to ??? at $location.name" + break; + } + return message; +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace "subscribe to events" + log.debug "${contact} ${contactClosed} ${mySwitch} ${mySwitchOff} ${acceleration}${arrivalPresence} ${button1}" +// subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt) { + log.trace "eventHandler(${evt?.name}: ${evt?.value})" + def name = evt?.name; + def value = evt?.value; + + if (allOk) { + log.trace "allOk" +// def lastTime = state[frequencyKey(evt)] +// if (oncePerDayOk(lastTime)) { + /* + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + else { + log.debug "Not taking action because $frequency minutes have not elapsed since last action" + } +/* } + else {*/ + takeAction(evt) +// } + } + else { + log.debug "Not taking action because it was already taken today" + } +} +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt?.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + + log.trace "takeAction()" + def messageToShow + if (defaultMessage) + { + messageToShow = constructMessageFor("${evt.name}.${evt.value}", evt.device); + } else { + messageToShow = customMessage; + } + if (messageToShow) + { + log.debug "text ${messageToShow}" + def notification = [:]; + def frame1 = [:]; + frame1.text = messageToShow; + if (selectedIcon != "1") + { + frame1.icon = state.icons[selectedIcon]; + } else { + frame1.icon = defaultIconData; + } + def soundId = sound; + def sound = [:]; + sound.id = selectedSound; + sound.category = "notifications"; + def frames = []; + frames << frame1; + def model = [:]; + model.frames = frames; + if (selectedSound != "none") + { + model.sound = sound; + } + notification.model = model; + notification.priority = showPriority; + def serializedData = new JsonOutput().toJson(notification); + + selectedDevices.each { lametricDevice -> + log.trace "send notification to ${lametricDevice} ${serializedData}" + lametricDevice.deviceNotification(serializedData) + } + } else { + log.debug "No message to show" + } + + log.trace "Exiting takeAction()" +} + +private frequencyKey(evt) { + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = true + if (oncePerDay) { + result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + } + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +private getDaysOk() { + def result = true + if (days) { + def df = new java.text.SimpleDateFormat("EEEE") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + log.trace "daysOk = $result" + result +} + +private getTimeOk() { + def result = true + if (starting && ending) { + def currTime = now() + def start = timeToday(starting, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).time + result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start + } + log.trace "timeOk = $result" + result +} + +private hhmm(time, fmt = "h:mm a") +{ + def t = timeToday(time, location.timeZone) + def f = new java.text.SimpleDateFormat(fmt) + f.setTimeZone(location.timeZone ?: timeZone(time)) + f.format(t) +} + +private getTimeLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +}