/** * Copyright 2015 SmartThings * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * * Gentle Wake Up * * Author: Steve Vlaminck * Date: 2013-03-11 * * https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime.png * https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime%402x.png * Gentle Wake Up turns on your lights slowly, allowing you to wake up more * naturally. Once your lights have reached full brightness, optionally turn on * more things, or send yourself a text for a more gentle nudge into the waking * world (you may want to set your normal alarm as a backup plan). * */ definition( name: "Gentle Wake Up", namespace: "smartthings", author: "SmartThings", description: "Dim your lights up slowly, allowing you to wake up more naturally.", category: "Health & Wellness", iconUrl: "https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime.png", iconX2Url: "https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime@2x.png" ) preferences { page(name: "rootPage") page(name: "schedulingPage") page(name: "completionPage") page(name: "numbersPage") } def rootPage() { dynamicPage(name: "rootPage", title: "", install: true, uninstall: true) { section { input(name: "dimmers", type: "capability.switchLevel", title: "Dimmers", description: null, multiple: true, required: true, submitOnChange: true) } if (dimmers) { section { href(name: "toNumbersPage", page: "numbersPage", title: "Duration & Direction", description: numbersPageHrefDescription(), state: "complete") } section { href(name: "toSchedulingPage", page: "schedulingPage", title: "Rules For Automatically Dimming Your Lights", description: schedulingHrefDescription(), state: schedulingHrefDescription() ? "complete" : "") } section { href(name: "toCompletionPage", title: "Completion Actions (Optional)", page: "completionPage", state: completionHrefDescription() ? "complete" : "", description: completionHrefDescription()) } section { // TODO: fancy label label(title: "Label this SmartApp", required: false, defaultValue: "") } } } } def numbersPage() { dynamicPage(name:"numbersPage", title:"") { section { paragraph(name: "pGraph", title: "These lights will dim", fancyDeviceString(dimmers)) } section { input(name: "duration", type: "number", title: "For this many minutes", description: "30", required: false, defaultValue: 30) } section { input(name: "startLevel", type: "number", range: "0..99", title: "From this level", defaultValue: defaultStart(), description: "Current Level", required: false, multiple: false) input(name: "endLevel", type: "number", range: "0..99", title: "To this level", defaultValue: defaultEnd(), description: "Between 0 and 99", required: true, multiple: false) } def colorDimmers = dimmersWithSetColorCommand() if (colorDimmers) { section { input(name: "colorize", type: "bool", title: "Gradually change the color of ${fancyDeviceString(colorDimmers)}", description: null, required: false, defaultValue: "true") } } } } def defaultStart() { if (usesOldSettings() && direction && direction == "Down") { return 99 } return 0 } def defaultEnd() { if (usesOldSettings() && direction && direction == "Down") { return 0 } return 99 } def startLevelLabel() { if (usesOldSettings()) { // using old settings if (direction && direction == "Down") { // 99 -> 1 return "99%" } return "0%" } return hasStartLevel() ? "${startLevel}%" : "Current Level" } def endLevelLabel() { if (usesOldSettings()) { if (direction && direction == "Down") { // 99 -> 1 return "0%" } return "99%" } return "${endLevel}%" } def schedulingPage() { dynamicPage(name: "schedulingPage", title: "Rules For Automatically Dimming Your Lights") { section { input(name: "days", type: "enum", title: "Allow Automatic Dimming On These Days", description: "Every day", required: false, multiple: true, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]) } section { input(name: "modeStart", title: "Start when entering this mode", type: "mode", required: false, mutliple: false, submitOnChange: true) if (modeStart) { input(name: "modeStop", title: "Stop when leaving '${modeStart}' mode", type: "bool", required: false) } } section { input(name: "startTime", type: "time", title: "Start Dimming At This Time", description: null, required: false) } } } def completionPage() { dynamicPage(name: "completionPage", title: "Completion Rules") { section("Switches") { input(name: "completionSwitches", type: "capability.switch", title: "Set these switches", description: null, required: false, multiple: true, submitOnChange: true) if (completionSwitches || androidClient()) { input(name: "completionSwitchesState", type: "enum", title: "To", description: null, required: false, multiple: false, options: ["on", "off"], style: "segmented", defaultValue: "on") input(name: "completionSwitchesLevel", type: "number", title: "Optionally, Set Dimmer Levels To", description: null, required: false, multiple: false, range: "(0..99)") } } section("Notifications") { input("recipients", "contact", title: "Send notifications to") { input(name: "completionPhoneNumber", type: "phone", title: "Text This Number", description: "Phone number", required: false) input(name: "completionPush", type: "bool", title: "Send A Push Notification", description: "Phone number", required: false) } input(name: "completionMusicPlayer", type: "capability.musicPlayer", title: "Speak Using This Music Player", required: false) input(name: "completionMessage", type: "text", title: "With This Message", description: null, required: false) } section("Modes and Phrases") { input(name: "completionMode", type: "mode", title: "Change ${location.name} Mode To", description: null, required: false) input(name: "completionPhrase", type: "enum", title: "Execute The Phrase", description: null, required: false, multiple: false, options: location.helloHome.getPhrases().label) } section("Delay") { input(name: "completionDelay", type: "number", title: "Delay This Many Minutes Before Executing These Actions", description: "0", required: false) } } } // ======================================================== // Handlers // ======================================================== def installed() { log.debug "Installing 'Gentle Wake Up' with settings: ${settings}" initialize() } def updated() { log.debug "Updating 'Gentle Wake Up' with settings: ${settings}" unschedule() initialize() } private initialize() { stop() if (startTime) { log.debug "scheduling dimming routine to run at $startTime" schedule(startTime, "scheduledStart") } // TODO: make this an option subscribe(app, appHandler) subscribe(location, locationHandler) } def appHandler(evt) { log.debug "appHandler evt: ${evt.value}" if (evt.value == "touch") { if (atomicState.running) { stop() } else { start() } } } def locationHandler(evt) { log.debug "locationHandler evt: ${evt.value}" if (!modeStart) { return } def isSpecifiedMode = (evt.value == modeStart) def modeStopIsTrue = (modeStop && modeStop != "false") if (isSpecifiedMode && canStartAutomatically()) { start() } else if (!isSpecifiedMode && modeStopIsTrue) { stop() } } // ======================================================== // Scheduling // ======================================================== def scheduledStart() { if (canStartAutomatically()) { start() } } def start() { log.trace "START" setLevelsInState() atomicState.running = true atomicState.start = new Date().getTime() schedule("0 * * * * ?", "healthCheck") increment() } def stop() { log.trace "STOP" atomicState.running = false atomicState.start = 0 unschedule("healthCheck") } private healthCheck() { log.trace "'Gentle Wake Up' healthCheck" if (!atomicState.running) { return } increment() } // ======================================================== // Setting levels // ======================================================== private increment() { if (!atomicState.running) { return } def percentComplete = completionPercentage() if (percentComplete > 99) { percentComplete = 99 } updateDimmers(percentComplete) if (percentComplete < 99) { def runAgain = stepDuration() log.debug "Rescheduling to run again in ${runAgain} seconds" runIn(runAgain, 'increment', [overwrite: true]) } else { int completionDelay = completionDelaySeconds() if (completionDelay) { log.debug "Finished with steps. Scheduling completion for ${completionDelay} second(s) from now" runIn(completionDelay, 'completion', [overwrite: true]) unschedule("healthCheck") // don't let the health check start incrementing again while we wait for the delayed execution of completion } else { log.debug "Finished with steps. Execution completion" completion() } } } def updateDimmers(percentComplete) { dimmers.each { dimmer -> def nextLevel = dynamicLevel(dimmer, percentComplete) if (nextLevel == 0) { dimmer.off() } else { def shouldChangeColors = (colorize && colorize != "false") def canChangeColors = hasSetColorCommand(dimmer) log.debug "Setting ${deviceLabel(dimmer)} to ${nextLevel}" if (shouldChangeColors && canChangeColors) { dimmer.setColor([hue: getHue(dimmer, nextLevel), saturation: 100, level: nextLevel]) } else { dimmer.setLevel(nextLevel) } } } } int dynamicLevel(dimmer, percentComplete) { def start = atomicState.startLevels[dimmer.id] def end = dynamicEndLevel() if (!percentComplete) { return start } def totalDiff = end - start def actualPercentage = percentComplete / 100 def percentOfTotalDiff = totalDiff * actualPercentage (start + percentOfTotalDiff) as int } // ======================================================== // Completion // ======================================================== private completion() { log.trace "Starting completion block" if (!atomicState.running) { return } stop() handleCompletionSwitches() handleCompletionMessaging() handleCompletionModesAndPhrases() } private handleCompletionSwitches() { completionSwitches.each { completionSwitch -> def isDimmer = hasSetLevelCommand(completionSwitch) if (completionSwitchesLevel && isDimmer) { completionSwitch.setLevel(completionSwitchesLevel) } else { def command = completionSwitchesState ?: "on" completionSwitch."${command}"() } } } private handleCompletionMessaging() { if (completionMessage) { if (location.contactBookEnabled) { sendNotificationToContacts(completionMessage, recipients) } else { if (completionPhoneNumber) { sendSms(completionPhoneNumber, completionMessage) } if (completionPush) { sendPush(completionMessage) } } if (completionMusicPlayer) { speak(completionMessage) } } } private handleCompletionModesAndPhrases() { if (completionMode) { setLocationMode(completionMode) } if (completionPhrase) { location.helloHome.execute(completionPhrase) } } def speak(message) { def sound = textToSpeech(message) def soundDuration = (sound.duration as Integer) + 2 log.debug "Playing $sound.uri" completionMusicPlayer.playTrack(sound.uri) log.debug "Scheduled resume in $soundDuration sec" runIn(soundDuration, resumePlaying, [overwrite: true]) } def resumePlaying() { log.trace "resumePlaying()" def sonos = completionMusicPlayer if (sonos) { def currentTrack = sonos.currentState("trackData").jsonValue if (currentTrack.status == "playing") { sonos.playTrack(currentTrack) } else { sonos.setTrack(currentTrack) } } } // ======================================================== // Helpers // ======================================================== def setLevelsInState() { def startLevels = [:] dimmers.each { dimmer -> if (usesOldSettings()) { startLevels[dimmer.id] = defaultStart() } else if (hasStartLevel()) { startLevels[dimmer.id] = startLevel } else { def dimmerIsOff = dimmer.currentValue("switch") == "off" startLevels[dimmer.id] = dimmerIsOff ? 0 : dimmer.currentValue("level") } } atomicState.startLevels = startLevels } def canStartAutomatically() { def today = new Date().format("EEEE") log.debug "today: ${today}, days: ${days}" if (!days || days.contains(today)) {// if no days, assume every day return true } log.trace "should not run" return false } def completionPercentage() { log.trace "checkingTime" if (!atomicState.running) { return } int now = new Date().getTime() int diff = now - atomicState.start int totalRunTime = totalRunTimeMillis() int percentOfRunTime = (diff / totalRunTime) * 100 log.debug "percentOfRunTime: ${percentOfRunTime}" percentOfRunTime } int totalRunTimeMillis() { int minutes = sanitizeInt(duration, 30) def seconds = minutes * 60 def millis = seconds * 1000 return millis as int } int dynamicEndLevel() { if (usesOldSettings()) { if (direction && direction == "Down") { return 0 } return 99 } return endLevel as int } def getHue(dimmer, level) { def start = atomicState.startLevels[dimmer.id] as int def end = dynamicEndLevel() if (start > end) { return getDownHue(level) } else { return getUpHue(level) } } def getUpHue(level) { getBlueHue(level) } def getDownHue(level) { getRedHue(level) } private getBlueHue(level) { if (level < 5) return 72 if (level < 10) return 71 if (level < 15) return 70 if (level < 20) return 69 if (level < 25) return 68 if (level < 30) return 67 if (level < 35) return 66 if (level < 40) return 65 if (level < 45) return 64 if (level < 50) return 63 if (level < 55) return 62 if (level < 60) return 61 if (level < 65) return 60 if (level < 70) return 59 if (level < 75) return 58 if (level < 80) return 57 if (level < 85) return 56 if (level < 90) return 55 if (level < 95) return 54 if (level >= 95) return 53 } private getRedHue(level) { if (level < 6) return 1 if (level < 12) return 2 if (level < 18) return 3 if (level < 24) return 4 if (level < 30) return 5 if (level < 36) return 6 if (level < 42) return 7 if (level < 48) return 8 if (level < 54) return 9 if (level < 60) return 10 if (level < 66) return 11 if (level < 72) return 12 if (level < 78) return 13 if (level < 84) return 14 if (level < 90) return 15 if (level < 96) return 16 if (level >= 96) return 17 } private hasSetLevelCommand(device) { def isDimmer = false device.supportedCommands.each { if (it.name.contains("setLevel")) { isDimmer = true } } return isDimmer } private hasSetColorCommand(device) { def hasColor = false device.supportedCommands.each { if (it.name.contains("setColor")) { hasColor = true } } return hasColor } private dimmersWithSetColorCommand() { def colorDimmers = [] dimmers.each { dimmer -> if (hasSetColorCommand(dimmer)) { colorDimmers << dimmer } } return colorDimmers } private int sanitizeInt(i, int defaultValue = 0) { try { if (!i) { return defaultValue } else { return i as int } } catch (Exception e) { log.debug e return defaultValue } } private completionDelaySeconds() { int completionDelayMinutes = sanitizeInt(completionDelay) int completionDelaySeconds = (completionDelayMinutes * 60) return completionDelaySeconds ?: 0 } private stepDuration() { int minutes = sanitizeInt(duration, 30) int stepDuration = (minutes * 60) / 100 return stepDuration ?: 1 } private debug(message) { log.debug "${message}\nstate: ${state}" } public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } public humanReadableStartDate() { new Date().parse(smartThingsDateFormat(), startTime).format("h:mm a", timeZone(startTime)) } def fancyString(listOfStrings) { def fancify = { list -> return list.collect { def label = it if (list.size() > 1 && it == list[-1]) { label = "and ${label}" } label }.join(", ") } return fancify(listOfStrings) } def fancyDeviceString(devices = []) { fancyString(devices.collect { deviceLabel(it) }) } def deviceLabel(device) { return device.label ?: device.name } def schedulingHrefDescription() { def descriptionParts = [] if (days) { descriptionParts << "On ${fancyString(days)}," } descriptionParts << "${fancyDeviceString(dimmers)} will start dimming" if (startTime) { descriptionParts << "at ${humanReadableStartDate()}" } if (modeStart) { if (startTime) { descriptionParts << "or" } descriptionParts << "when ${location.name} enters '${modeStart}' mode" } if (descriptionParts.size() <= 1) { // dimmers will be in the list no matter what. No rules are set if only dimmers are in the list return null } return descriptionParts.join(" ") } def completionHrefDescription() { def descriptionParts = [] def example = "Switch1 will be turned on. Switch2, Switch3, and Switch4 will be dimmed to 50%. The message '' will be spoken, sent as a text, and sent as a push notification. The mode will be changed to ''. The phrase '' will be executed" if (completionSwitches) { def switchesList = [] def dimmersList = [] completionSwitches.each { def isDimmer = completionSwitchesLevel ? hasSetLevelCommand(it) : false if (isDimmer) { dimmersList << deviceLabel(it) } if (!isDimmer) { switchesList << deviceLabel(it) } } if (switchesList) { descriptionParts << "${fancyString(switchesList)} will be turned ${completionSwitchesState ?: 'on'}." } if (dimmersList) { descriptionParts << "${fancyString(dimmersList)} will be dimmed to ${completionSwitchesLevel}%." } } if (completionMessage && (completionPhoneNumber || completionPush || completionMusicPlayer)) { def messageParts = [] if (completionMusicPlayer) { messageParts << "spoken" } if (completionPhoneNumber) { messageParts << "sent as a text" } if (completionPush) { messageParts << "sent as a push notification" } descriptionParts << "The message '${completionMessage}' will be ${fancyString(messageParts)}." } if (completionMode) { descriptionParts << "The mode will be changed to '${completionMode}'." } if (completionPhrase) { descriptionParts << "The phrase '${completionPhrase}' will be executed." } return descriptionParts.join(" ") } def numbersPageHrefDescription() { def title = "All dimmers will dim for ${duration ?: '30'} minutes from ${startLevelLabel()} to ${endLevelLabel()}" if (colorize) { def colorDimmers = dimmersWithSetColorCommand() if (colorDimmers == dimmers) { title += " and will gradually change color." } else { title += ".\n${fancyDeviceString(colorDimmers)} will gradually change color." } } return title } def hueSatToHex(h, s) { def convertedRGB = hslToRgb(h, s, 0.5) return rgbToHex(convertedRGB) } def hslToRgb(h, s, l) { def r, g, b; if (s == 0) { r = g = b = l; // achromatic } else { def hue2rgb = { p, q, t -> if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; } def q = l < 0.5 ? l * (1 + s) : l + s - l * s; def p = 2 * l - q; r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3); } return [r * 255, g * 255, b * 255]; } def rgbToHex(red, green, blue) { def toHex = { int n = it as int; n = Math.max(0, Math.min(n, 255)); def hexOptions = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"] def firstDecimal = ((n - n % 16) / 16) as int def secondDecimal = (n % 16) as int return "${hexOptions[firstDecimal]}${hexOptions[secondDecimal]}" } def rgbToHex = { r, g, b -> return toHex(r) + toHex(g) + toHex(b) } return rgbToHex(red, green, blue) } def usesOldSettings() { !hasEndLevel() } def hasStartLevel() { return (startLevel != null && startLevel != "") } def hasEndLevel() { return (endLevel != null && endLevel != "") }