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 = """
+
+
+
+
+
+
+
+
+
+
+"""
+ 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") : ""
+}