Update Switches.groovy
[smartapps.git] / third-party / ecobeeGenerateMonthlyStats.groovy
1 /**
2  *  ecobeeGenerateMonthlyStats
3  *
4  *  Copyright 2015 Yves Racine
5  *  LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/
6  *
7  *  Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret 
8  *  in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. 
9  *  Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered 
10  *  to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without
11  *  Developer's written consent.
12  *
13  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
14  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
15  *
16  *  Software Distribution is restricted and shall be done only with Developer's written approval.
17  * 
18  *  N.B. Requires MyEcobee device ( v5.0 and higher) available at 
19  *          http://www.ecomatiqhomes.com/#!store/tc3yr 
20  */
21 import java.text.SimpleDateFormat 
22 definition(
23     name: "${get_APP_NAME()}",
24     namespace: "yracine",
25     author: "Yves Racine",
26     description: "This smartapp allows a ST user to generate monthly runtime stats (by scheduling or based on custom dates) on their devices controlled by ecobee such as a heating & cooling component,fan, dehumidifier/humidifier/HRV/ERV. ",
27     category: "My Apps",
28     iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png",
29     iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png"
30 )
31
32 preferences {
33         section("About") {
34                 paragraph "${get_APP_NAME()}, the smartapp that generates monthly runtime reports about your ecobee components"
35                 paragraph "Version 1.8"
36                 paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " 
37                         href url: "https://www.paypal.me/ecomatiqhomes",
38                                 title:"Paypal donation..."
39                 paragraph "Copyright©2016 Yves Racine"
40                         href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..."  
41                                 description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md"
42         }
43
44         section("Generate monthly stats for this ecobee thermostat") {
45                 input "ecobee", "device.myEcobeeDevice", title: "Ecobee?"
46
47         }
48         section("Date for the initial run = YYYY-MM-DD") {
49                 input "givenEndDate", "text", title: "End Date [default=today]", required: false
50         }        
51         section("Time for the initial run (24HR)" ) {
52                 input "givenEndTime", "text", title: "End time [default=00:00]", required: false
53         }        
54         section( "Notifications" ) {
55                 input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes", "No"]], required: false, default:"No"
56                 input "phoneNumber", "phone", title: "Send a text message?", required: false
57         }
58         section("Detailed Notifications") {
59                 input "detailedNotif", "bool", title: "Detailed Notifications?", required:false
60         }
61         section("Enable Amazon Echo/Ask Alexa Notifications for ecobee stats reporting (optional)") {
62                 input (name:"askAlexaFlag", title: "Ask Alexa verbal Notifications [default=false]?", type:"bool",
63                         description:"optional",required:false)
64                 input (name:"listOfMQs",  type:"enum", title: "List of the Ask Alexa Message Queues (default=Primary)", options: state?.askAlexaMQ, multiple: true, required: false,
65                         description:"optional")            
66                 input "AskAlexaExpiresInDays", "number", title: "Ask Alexa's messages expiration in days (optional,default=2 days)?", required: false
67         }
68     
69 }
70
71 def installed() {
72         log.debug "Installed with settings: ${settings}"
73
74         initialize()
75 }
76
77 def updated() {
78         log.debug "Updated with settings: ${settings}"
79
80         unsubscribe()
81         unschedule()    
82         initialize()
83 }
84
85 def initialize() {
86         atomicState?.timestamp=''
87         atomicState?.componentAlreadyProcessed=''
88         atomicState?.retries=0  
89
90
91         runIn((1*60),   "generateStats") // run 1 minute later as it requires notification.     
92         subscribe(app, appTouch)
93         atomicState?.poll = [ last: 0, rescheduled: now() ]
94
95         //Subscribe to different events (ex. sunrise and sunset events) to trigger rescheduling if needed
96         subscribe(location, "sunset", rescheduleIfNeeded)
97         subscribe(location, "mode", rescheduleIfNeeded)
98         subscribe(location, "sunsetTime", rescheduleIfNeeded)
99
100         subscribe(location, "askAlexaMQ", askAlexaMQHandler)
101         rescheduleIfNeeded()   
102 }
103
104 def askAlexaMQHandler(evt) {
105         if (!evt) return
106         switch (evt.value) {
107                 case "refresh":
108                 state?.askAlexaMQ = evt.jsonData && evt.jsonData?.queues ? evt.jsonData.queues : []
109                 log.info("askAlexaMQHandler>refresh value=$state?.askAlexaMQ")        
110                 break
111         }
112 }
113
114
115 def rescheduleIfNeeded(evt) {
116         if (evt) log.debug("rescheduleIfNeeded>$evt.name=$evt.value")
117         Integer delay = (24*60) // By default, do it every day
118         BigDecimal currentTime = now()    
119         BigDecimal lastPollTime = (currentTime - (atomicState?.poll["last"]?:0))  
120  
121         if (lastPollTime != currentTime) {    
122                 Double lastPollTimeInMinutes = (lastPollTime/60000).toDouble().round(1)      
123                 log.info "rescheduleIfNeeded>last poll was  ${lastPollTimeInMinutes.toString()} minutes ago"
124         }
125         if (((atomicState?.poll["last"]?:0) + (delay * 60000) < currentTime) && canSchedule()) {
126                 log.info "rescheduleIfNeeded>scheduling dailyRun in ${delay} minutes.."
127                 schedule("0 30 0 * * ?", dailyRun)    
128         }
129     
130         // Update rescheduled state
131     
132         if (!evt) atomicState?.poll["rescheduled"] = now()
133 }
134    
135
136 def appTouch(evt) {
137         atomicState?.timestamp=''
138         atomicState?.componentAlreadyProcessed=''
139         generateStats()
140 }
141
142
143 void reRunIfNeeded() {
144         if (detailedNotif) {    
145                 log.debug("reRunIfNeeded>About to call generateStats() with state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}")
146         }    
147         generateStats()
148    
149 }
150
151 void dailyRun() {
152         Integer delay = (24*60) // By default, do it every day
153         atomicState?.poll["last"] = now()
154                 
155         //schedule the rescheduleIfNeeded() function
156     
157         if (((atomicState?.poll["rescheduled"]?:0) + (delay * 60000)) < now()) {
158                 log.info "takeAction>scheduling rescheduleIfNeeded() in ${delay} minutes.."
159 //       generate the stats every day at 0:30 
160                 schedule("0 30 0 * * ?", rescheduleIfNeeded)    
161                 // Update rescheduled state
162                 atomicState?.poll["rescheduled"] = now()
163         }
164         settings.givenEndDate=null
165         settings.givenEndTime=null
166         if (detailedNotif) {    
167                 log.debug("dailyRun>For $ecobee, about to call generateStats() with settings.givenEndDate=${settings.givenEndDate}")
168         }    
169         atomicState?.componentAlreadyProcessed=''
170         atomicState?.retries=0  
171         generateStats()
172     
173 }
174
175
176 private String formatISODateInLocalTime(dateInString, timezone='') {
177         def myTimezone=(timezone)?TimeZone.getTimeZone(timezone):location.timeZone 
178         if ((dateInString==null) || (dateInString.trim()=="")) {
179                 return (new Date().format("yyyy-MM-dd HH:mm:ss", myTimezone))
180         }    
181         SimpleDateFormat ISODateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
182         Date ISODate = ISODateFormat.parse(dateInString)
183         String dateInLocalTime =new Date(ISODate.getTime()).format("yyyy-MM-dd HH:mm:ss", myTimezone)
184         log.debug("formatDateInLocalTime>dateInString=$dateInString, dateInLocalTime=$dateInLocalTime")    
185         return dateInLocalTime
186 }
187
188
189 private def formatDate(dateString) {
190         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm zzz")
191         Date aDate = sdf.parse(dateString)
192         return aDate
193 }
194
195 private def get_nextComponentStats(component='') {
196         if (detailedNotif) {
197                 log.debug "get_nextComponentStats>About to get ${component}'s next component from components table"
198         }
199
200         def nextInLine=[:]
201
202         def components = [
203                 '': 
204                         [position:1, next: 'auxHeat1'
205                         ], 
206                 'auxHeat1': 
207                         [position:2, next: 'auxHeat2'
208                         ], 
209                 'auxHeat2': 
210                         [position:3, next: 'auxHeat3'
211                         ], 
212                 'auxHeat3': 
213                         [position:4, next: 'compCool2'
214                         ], 
215                 'compCool2': 
216                         [position:5, next: 'compCool1'
217                         ],
218                 'compCool1': 
219                         [position:6, next: 'done'
220                         ], 
221                 ]
222         try {
223                 nextInLine = components.getAt(component)
224         } catch (any) {
225                 nextInLine=[position:1, next:'auxHeat1']
226                 if (detailedNotif) {
227                         log.debug "get_nextComponentStats>${component} not found, nextInLine=${nextInLine}"
228                 }
229         }          
230         if (detailedNotif) {
231                 log.debug "get_nextComponentStats>got ${component}'s next component from components table= ${nextInLine}"
232         }
233         return nextInLine
234                             
235 }
236
237
238 void generateStats() {
239         String dateInLocalTime = new Date().format("yyyy-MM-dd", location.timeZone) 
240         def delay = 2  // 2-minute delay for rerun
241         def MAX_POSITION=6    
242         def MAX_RETRIES=4    
243         float runtimeTotalAvgMonthly    
244         String mode= ecobee.currentThermostatMode    
245         atomicState?.retries=   ((atomicState?.retries==null) ?:0) +1
246
247         try {
248                 unschedule(reRunIfNeeded)
249         } catch (e) {
250     
251                 if (detailedNotif) {    
252                         log.debug("${get_APP_NAME()}>Exception $e while unscheduling reRunIfNeeded")
253                 }       
254         }    
255     
256         if (atomicState?.retries >= MAX_RETRIES) { 
257                 if (detailedNotif) {    
258                         log.debug("${get_APP_NAME()}>Max retries reached, exiting")
259                         send("max retries reached ${atomicState?.retries}), exiting")
260                 }       
261         }       
262         def component = atomicState?.componentAlreadyProcessed
263         def nextComponent  = get_nextComponentStats(component) // get nextComponentToBeProcessed        
264         if (detailedNotif) {    
265                 log.debug("${get_APP_NAME()}>for $ecobee, about to process nextComponent=${nextComponent}, state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}")
266         }       
267         if (atomicState?.timestamp == dateInLocalTime && nextComponent.position >=MAX_POSITION) {
268                 return // the monthly stats are already generated 
269         } else {        
270                 // schedule a rerun till the stats are generated properly
271                 schedule("0 0/${delay} * * * ?", reRunIfNeeded)
272         }       
273     
274         String timezone = new Date().format("zzz", location.timeZone)
275         String dateAtMidnight = dateInLocalTime + " 00:00 " + timezone    
276         if (detailedNotif) {
277                 log.debug("${get_APP_NAME()}>date at Midnight= ${dateAtMidnight}")
278         }        
279         Date endDate = formatDate(dateAtMidnight) 
280       
281       
282         def reportEndDate = (settings.givenEndDate) ?: endDate.format("yyyy-MM-dd", location.timeZone) 
283         def reportEndTime=(settings.givenEndTime) ?:"00:00"    
284         def dateTime = reportEndDate + " " + reportEndTime + " " + timezone
285         endDate = formatDate(dateTime)
286         Date aMonthAgo= endDate -30    
287
288
289         component = 'auxHeat1'
290 //      if ((mode in ['auto','heat','off']) && (nextComponent?.position <= 1)) { 
291         if (nextComponent?.position <= 1) { 
292                 generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days
293                 runtimeTotalAvgMonthly = (ecobee.currentAuxHeat1RuntimeAvgMonthly)? ecobee.currentAuxHeat1RuntimeAvgMonthly.toFloat().round(2):0
294                 atomicState?.componentAlreadyProcessed=component
295                 if (runtimeTotalAvgMonthly) {
296                         send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag
297                 }     
298         }
299     
300         int heatStages = ecobee.currentHeatStages.toInteger()
301     
302     
303         component = 'auxHeat2'
304 //      if ((mode in ['auto','heat', 'off']) && (heatStages >1) && (nextComponent.position <= 2)) { 
305         if ((heatStages >1) && (nextComponent.position <= 2)) { 
306                 generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days
307                 runtimeTotalAvgMonthly = (ecobee.currentAuxHeat2RuntimeAvgMonthly)? ecobee.currentAuxHeat2RuntimeAvgMonthly.toFloat().round(2):0
308                 atomicState?.componentAlreadyProcessed=component
309                 if (runtimeTotalAvgMonthly) {
310                         send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag
311                 }     
312         }     
313
314         component = 'auxHeat3'
315 //      if ((mode in ['auto','heat', 'off']) && (heatStages >2) && (nextComponent.position <= 3)) { 
316         if ((heatStages >2) && (nextComponent.position <= 3)) { 
317                 generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days
318                 runtimeTotalAvgMonthly = (ecobee.currentAuxHeat3RuntimeAvgMonthly)? ecobee.currentAuxHeat3RuntimeAvgMonthly.toFloat().round(2):0
319                 atomicState?.componentAlreadyProcessed=component
320                 if (runtimeTotalAvgMonthly) {
321                         send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag
322                 }     
323         }     
324
325 // Get the compCool1's runtime for startDate-endDate period
326
327         int coolStages = ecobee.currentCoolStages.toInteger()
328
329
330 //      Get the compCool2's runtime for startDate-endDate period
331         component = 'compCool2'
332 //      if ((mode in ['auto','cool', 'off']) && (coolStages >1) && (nextComponent.position <= 4)) {
333         if ((coolStages >1) && (nextComponent.position <= 4)) {
334                 generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days
335                 runtimeTotalAvgMonthly = (ecobee.currentCompCool2RuntimeAvgMonthly)? ecobee.currentCompCool2RuntimeAvgMonthly.toFloat().round(2):0
336                 atomicState?.componentAlreadyProcessed=component
337                 if (runtimeTotalAvgMonthly) {
338                         send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag
339                 }     
340         } 
341         component = 'compCool1'
342 //      if ((mode in ['auto','cool', 'off']) && (nextComponent.position <= 5)) {
343         if (nextComponent.position <= 5) {
344                 generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days
345                 runtimeTotalAvgMonthly = (ecobee.currentCompCool1RuntimeAvgMonthly)? ecobee.currentCompCool1RuntimeAvgMonthly.toFloat().round(2):0
346                 atomicState?.componentAlreadyProcessed=component
347                 if (runtimeTotalAvgMonthly) {
348                         send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag
349                 }     
350         }        
351         
352         
353         component=atomicState?.componentAlreadyProcessed    
354         nextComponent  = get_nextComponentStats(component) // get nextComponentToBeProcessed    
355 //      int endPosition= (mode=='heat') ? ((heatStages>2) ? 4 : (heatStages>1)? 3:2) :MAX_POSITION     
356         if (nextComponent.position >=MAX_POSITION) {
357                 send "generated all ${ecobee}'s monthly stats since ${String.format('%tF', aMonthAgo)}"
358                 unschedule(reRunIfNeeded) // No need to reschedule again as the stats are completed.
359                 atomicatomicState?.timestamp = dateInLocalTime // save the date to avoid re-execution.
360         }
361
362
363 }
364
365 void generateRuntimeReport(component, startDate, endDate, frequence='daily') {
366
367         if (detailedNotif) {
368                 log.debug("generateRuntimeReport>For component ${component}, about to call getReportData with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}")
369                 send ("For component ${component}, about to call getReportData with aMonthAgo in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}")
370         }        
371         ecobee.getReportData("", startDate, endDate, null, null, component,false)
372         if (detailedNotif) {
373                 log.debug("generateRuntimeReport>For component ${component}, about to call generateReportRuntimeEvents with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}")
374                 send ("For component ${component}, about to call generateReportRuntimeEvents with aMonthAgo in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}")
375         }        
376         ecobee.generateReportRuntimeEvents(component, startDate,endDate, 0, null,frequence)
377
378
379 }
380
381 private send(msg, askAlexa=false) {
382         def message = "${get_APP_NAME()}>${msg}"
383         if (sendPushMessage == "Yes") {
384                         sendPush(message)
385         }
386         if (askAlexa) {
387                 def expiresInDays=(AskAlexaExpiresInDays)?:2    
388                 sendLocationEvent(
389                         name: "AskAlexaMsgQueue", 
390                         value: "${get_APP_NAME()}", 
391                         isStateChange: true, 
392                         descriptionText: msg, 
393                         data:[
394                                 queues: listOfMQs,
395                                 expires: (expiresInDays*24*60*60)  /* Expires after 2 days by default */
396                         ]
397                 )
398         } /* End if Ask Alexa notifications*/
399
400         if (phone) {
401                 log.debug("sending text message")
402                 sendSms(phone, message)
403         }
404     
405         log.debug msg
406     
407 }
408
409 private def get_APP_NAME() {
410         return "ecobeeGenerateMonthlyStats"
411 }