Update step-notifier.groovy
[smartapps.git] / official / speaker-weather-forecast.groovy
1 /**
2  *  Copyright 2015 SmartThings
3  *
4  *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5  *  in compliance with the License. You may obtain a copy of the License at:
6  *
7  *      http://www.apache.org/licenses/LICENSE-2.0
8  *
9  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11  *  for the specific language governing permissions and limitations under the License.
12  *
13  *  Speaker Weather Forecast
14  *
15  *  Author: SmartThings
16  *  Date: 2014-1-29
17  */
18 definition(
19     name: "Speaker Weather Forecast",
20     namespace: "smartthings",
21     author: "SmartThings",
22     description: "Play a weather report through your Speaker when the mode changes or other events occur",
23     category: "SmartThings Labs",
24     iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos.png",
25     iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos@2x.png"
26 )
27
28 preferences {
29         page(name: "mainPage", title: "Play the weather report on your speaker", install: true, uninstall: true)
30         page(name: "chooseTrack", title: "Select a song or station")
31         page(name: "timeIntervalInput", title: "Only during a certain time") {
32                 section {
33                         input "starting", "time", title: "Starting", required: false
34                         input "ending", "time", title: "Ending", required: false
35                 }
36         }
37 }
38
39 def mainPage() {
40         dynamicPage(name: "mainPage") {
41                 def anythingSet = anythingSet()
42                 if (anythingSet) {
43                         section("Play weather report when"){
44                                 ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true
45                                 ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true
46                                 ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true
47                                 ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true
48                                 ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true
49                                 ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true
50                                 ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true
51                                 ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true
52                                 ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true
53                                 ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true
54                                 ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production
55                                 ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true
56                                 ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false
57                         }
58                 }
59                 def hideable = anythingSet || app.installationState == "COMPLETE"
60                 def sectionTitle = anythingSet ? "Select additional triggers" : "Play weather report when..."
61
62                 section(sectionTitle, hideable: hideable, hidden: true){
63                         ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true
64                         ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true
65                         ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true
66                         ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true
67                         ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true
68                         ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true
69                         ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true
70                         ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true
71                         ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true
72                         ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true
73                         ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production
74                         ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true
75                         ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false
76                 }
77                 section {
78                         input("forecastOptions", "enum", defaultValue: "0", title: "Weather report options", description: "Select one or more", multiple: true,
79                                 options: [
80                                         ["0": "Current Conditions"],
81                                         ["1": "Today's Forecast"],
82                                         ["2": "Tonight's Forecast"],
83                                         ["3": "Tomorrow's Forecast"],
84                                 ]
85                         )
86                 }
87                 section {
88                         input "sonos", "capability.musicPlayer", title: "On this Speaker player", required: true
89                 }
90                 section("More options", hideable: true, hidden: true) {
91                         input "resumePlaying", "bool", title: "Resume currently playing music after weather report finishes", required: false, defaultValue: true
92                         href "chooseTrack", title: "Or play this music or radio station", description: song ? state.selectedSong?.station : "Tap to set", state: song ? "complete" : "incomplete"
93
94                         input "zipCode", "text", title: "Zip Code", required: false
95                         input "volume", "number", title: "Temporarily change volume", description: "0-100%", required: false
96                         input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false
97                         href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete"
98                         input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false,
99                                 options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
100                         if (settings.modes) {
101                 input "modes", "mode", title: "Only when mode is", multiple: true, required: false
102             }
103                         input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false
104                 }
105                 section([mobileOnly:true]) {
106                         label title: "Assign a name", required: false
107                         mode title: "Set for specific mode(s)"
108                 }
109         }
110 }
111
112 def chooseTrack() {
113         dynamicPage(name: "chooseTrack") {
114                 section{
115                         input "song","enum",title:"Play this track", required:true, multiple: false, options: songOptions()
116                 }
117         }
118 }
119
120 private anythingSet() {
121         for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","timeOfDay","triggerModes"]) {
122                 if (settings[name]) {
123                         return true
124                 }
125         }
126         return false
127 }
128
129 private ifUnset(Map options, String name, String capability) {
130         if (!settings[name]) {
131                 input(options, name, capability)
132         }
133 }
134
135 private ifSet(Map options, String name, String capability) {
136         if (settings[name]) {
137                 input(options, name, capability)
138         }
139 }
140
141 def installed() {
142         log.debug "Installed with settings: ${settings}"
143         subscribeToEvents()
144 }
145
146 def updated() {
147         log.debug "Updated with settings: ${settings}"
148         unsubscribe()
149         unschedule()
150         subscribeToEvents()
151 }
152
153 def subscribeToEvents() {
154         subscribe(app, appTouchHandler)
155         subscribe(contact, "contact.open", eventHandler)
156         subscribe(contactClosed, "contact.closed", eventHandler)
157         subscribe(acceleration, "acceleration.active", eventHandler)
158         subscribe(motion, "motion.active", eventHandler)
159         subscribe(mySwitch, "switch.on", eventHandler)
160         subscribe(mySwitchOff, "switch.off", eventHandler)
161         subscribe(arrivalPresence, "presence.present", eventHandler)
162         subscribe(departurePresence, "presence.not present", eventHandler)
163         subscribe(smoke, "smoke.detected", eventHandler)
164         subscribe(smoke, "smoke.tested", eventHandler)
165         subscribe(smoke, "carbonMonoxide.detected", eventHandler)
166         subscribe(water, "water.wet", eventHandler)
167         subscribe(button1, "button.pushed", eventHandler)
168
169         if (triggerModes) {
170                 subscribe(location,modeChangeHandler)
171         }
172
173         if (timeOfDay) {
174                 schedule(timeOfDay, scheduledTimeHandler)
175         }
176
177         if (song) {
178                 saveSelectedSong()
179         }
180 }
181
182 def eventHandler(evt) {
183         log.trace "eventHandler($evt?.name: $evt?.value)"
184         if (allOk) {
185                 log.trace "allOk"
186                 def lastTime = state[frequencyKey(evt)]
187                 if (oncePerDayOk(lastTime)) {
188                         if (frequency) {
189                                 if (lastTime == null || now() - lastTime >= frequency * 60000) {
190                                         takeAction(evt)
191                                 }
192                                 else {
193                                         log.debug "Not taking action because $frequency minutes have not elapsed since last action"
194                                 }
195                         }
196                         else {
197                                 takeAction(evt)
198                         }
199                 }
200                 else {
201                         log.debug "Not taking action because it was already taken today"
202                 }
203         }
204 }
205
206 def modeChangeHandler(evt) {
207         log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)"
208         if (evt.value in triggerModes) {
209                 eventHandler(evt)
210         }
211 }
212
213 def scheduledTimeHandler() {
214         eventHandler(null)
215 }
216
217 def appTouchHandler(evt) {
218         takeAction(evt)
219 }
220
221 private takeAction(evt) {
222
223         loadText()
224
225         if (song) {
226                 sonos.playSoundAndTrack(state.sound.uri, state.sound.duration, state.selectedSong, volume)
227         }
228         else if (resumePlaying){
229                 sonos.playTrackAndResume(state.sound.uri, state.sound.duration, volume)
230         }
231         else if (volume) {
232                 sonos.playTrackAtVolume(state.sound.uri, volume)
233         }
234         else {
235                 sonos.playTrack(state.sound.uri)
236         }
237
238         if (frequency || oncePerDay) {
239                 state[frequencyKey(evt)] = now()
240         }
241 }
242
243 private songOptions() {
244
245         // Make sure current selection is in the set
246
247         def options = new LinkedHashSet()
248         if (state.selectedSong?.station) {
249                 options << state.selectedSong.station
250         }
251         else if (state.selectedSong?.description) {
252                 // TODO - Remove eventually? 'description' for backward compatibility
253                 options << state.selectedSong.description
254         }
255
256         // Query for recent tracks
257         def states = sonos.statesSince("trackData", new Date(0), [max:30])
258         def dataMaps = states.collect{it.jsonValue}
259         options.addAll(dataMaps.collect{it.station})
260
261         log.trace "${options.size()} songs in list"
262         options.take(20) as List
263 }
264
265 private saveSelectedSong() {
266         try {
267                 def thisSong = song
268                 log.info "Looking for $thisSong"
269                 def songs = sonos.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue}
270                 log.info "Searching ${songs.size()} records"
271
272                 def data = songs.find {s -> s.station == thisSong}
273                 log.info "Found ${data?.station}"
274                 if (data) {
275                         state.selectedSong = data
276                         log.debug "Selected song = $state.selectedSong"
277                 }
278                 else if (song == state.selectedSong?.station) {
279                         log.debug "Selected existing entry '$song', which is no longer in the last 20 list"
280                 }
281                 else {
282                         log.warn "Selected song '$song' not found"
283                 }
284         }
285         catch (Throwable t) {
286                 log.error t
287         }
288 }
289
290 private frequencyKey(evt) {
291         "lastActionTimeStamp"
292 }
293
294 private dayString(Date date) {
295         def df = new java.text.SimpleDateFormat("yyyy-MM-dd")
296         if (location.timeZone) {
297                 df.setTimeZone(location.timeZone)
298         }
299         else {
300                 df.setTimeZone(TimeZone.getTimeZone("America/New_York"))
301         }
302         df.format(date)
303 }
304
305 private oncePerDayOk(Long lastTime) {
306         def result = true
307         if (oncePerDay) {
308                 result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true
309                 log.trace "oncePerDayOk = $result"
310         }
311         result
312 }
313
314 // TODO - centralize somehow
315 private getAllOk() {
316         modeOk && daysOk && timeOk
317 }
318
319 private getModeOk() {
320         def result = !modes || modes.contains(location.mode)
321         log.trace "modeOk = $result"
322         result
323 }
324
325 private getDaysOk() {
326         def result = true
327         if (days) {
328                 def df = new java.text.SimpleDateFormat("EEEE")
329                 if (location.timeZone) {
330                         df.setTimeZone(location.timeZone)
331                 }
332                 else {
333                         df.setTimeZone(TimeZone.getTimeZone("America/New_York"))
334                 }
335                 def day = df.format(new Date())
336                 result = days.contains(day)
337         }
338         log.trace "daysOk = $result"
339         result
340 }
341
342 private getTimeOk() {
343         def result = true
344         if (starting && ending) {
345                 def currTime = now()
346                 def start = timeToday(starting, location?.timeZone).time
347                 def stop = timeToday(ending, location?.timeZone).time
348                 result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start
349         }
350         log.trace "timeOk = $result"
351         result
352 }
353
354 private hhmm(time, fmt = "h:mm a")
355 {
356         def t = timeToday(time, location.timeZone)
357         def f = new java.text.SimpleDateFormat(fmt)
358         f.setTimeZone(location.timeZone ?: timeZone(time))
359         f.format(t)
360 }
361
362 private getTimeLabel()
363 {
364         (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : ""
365 }
366 // TODO - End Centralize
367
368 private loadText() {
369         if (location.timeZone || zipCode) {
370                 def weather = getWeatherFeature("forecast", zipCode)
371                 def current = getWeatherFeature("conditions", zipCode)
372                 def isMetric = location.temperatureScale == "C"
373                 def delim = ""
374                 def sb = new StringBuilder()
375                 list(forecastOptions).sort().each {opt ->
376                         if (opt == "0") {
377                                 if (isMetric) {
378                         sb << "The current temperature is ${Math.round(current.current_observation.temp_c)} degrees."
379                 }
380                 else {
381                         sb << "The current temperature is ${Math.round(current.current_observation.temp_f)} degrees."
382                 }
383                                 delim = " "
384                         }
385                         else if (opt == "1") {
386                                 sb << delim
387                                 sb << "Today's forecast is "
388                                 if (isMetric) {
389                         sb << weather.forecast.txt_forecast.forecastday[0].fcttext_metric 
390                 }
391                 else {
392                         sb << weather.forecast.txt_forecast.forecastday[0].fcttext
393                 }
394                         }
395                         else if (opt == "2") {
396                                 sb << delim
397                                 sb << "Tonight will be "
398                                 if (isMetric) {
399                         sb << weather.forecast.txt_forecast.forecastday[1].fcttext_metric 
400                 }
401                 else {
402                         sb << weather.forecast.txt_forecast.forecastday[1].fcttext
403                 }
404                         }
405                         else if (opt == "3") {
406                                 sb << delim
407                                 sb << "Tomorrow will be "
408                                 if (isMetric) {
409                         sb << weather.forecast.txt_forecast.forecastday[2].fcttext_metric 
410                 }
411                 else {
412                         sb << weather.forecast.txt_forecast.forecastday[2].fcttext
413                 }
414                         }
415                 }
416
417                 def msg = sb.toString()
418         msg = msg.replaceAll(/([0-9]+)C/,'$1 degrees') // TODO - remove after next release
419                 log.debug "msg = ${msg}"
420                 state.sound = textToSpeech(msg, true)
421         }
422         else {
423                 state.sound = textToSpeech("Please set the location of your hub with the SmartThings mobile app, or enter a zip code to receive weather forecasts.")
424         }
425 }
426
427 private list(String s) {
428         [s]
429 }
430 private list(l) {
431         l
432 }