Update speaker-weather-forecast.groovy
[smartapps.git] / official / life360-connect.groovy
1 /**
2  *  life360
3  *
4  *  Copyright 2014 Jeff's Account
5  *
6  *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
7  *  in compliance with the License. You may obtain a copy of the License at:
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
12  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
13  *  for the specific language governing permissions and limitations under the License.
14  *
15  */
16
17 definition(
18     name: "Life360 (Connect)",
19     namespace: "smartthings",
20     author: "SmartThings",
21     description: "Life360 Service Manager",
22         category: "SmartThings Labs",
23     iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/life360.png",
24     iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/life360@2x.png",
25     oauth: [displayName: "Life360", displayLink: "Life360"],
26     singleInstance: true
27 ) {
28         appSetting "clientId"
29         appSetting "clientSecret"
30 }
31
32 preferences {
33         page(name: "Credentials", title: "Life360 Authentication", content: "authPage", nextPage: "listCirclesPage", install: false)
34     page(name: "listCirclesPage", title: "Select Life360 Circle", nextPage: "listPlacesPage", content: "listCircles", install: false)
35     page(name: "listPlacesPage", title: "Select Life360 Place", nextPage: "listUsersPage", content: "listPlaces", install: false)
36     page(name: "listUsersPage", title: "Select Life360 Users", content: "listUsers", install: true)
37 }
38
39 //      page(name: "Credentials", title: "Enter Life360 Credentials", content: "getCredentialsPage", nextPage: "listCirclesPage", install: false)  
40 //    page(name: "page3", title: "Select Life360 Users", content: "listUsers")
41
42 mappings {
43
44         path("/placecallback") {
45                 action: [
46               POST: "placeEventHandler",
47               GET: "placeEventHandler"
48                 ]
49         }
50     
51     path("/receiveToken") {
52                 action: [
53             POST: "receiveToken",
54             GET: "receiveToken"
55                 ]
56         }
57 }
58
59 def authPage()
60 {
61     log.debug "authPage()"
62     
63     def description = "Life360 Credentials Already Entered."
64     
65     def uninstallOption = false
66     if (app.installationState == "COMPLETE")
67        uninstallOption = true
68
69         if(!state.life360AccessToken)
70     {
71             log.debug "about to create access token"
72                 createAccessToken()
73         description = "Click to enter Life360 Credentials."
74
75                 def redirectUrl = oauthInitUrl()
76     
77                 return dynamicPage(name: "Credentials", title: "Life360", nextPage:"listCirclesPage", uninstall: uninstallOption, install:false) {
78                     section {
79                         href url:redirectUrl, style:"embedded", required:false, title:"Life360", description:description
80                     }
81                 }
82     }
83     else
84     {
85         listCircles()
86     }
87 }
88
89 def receiveToken() {
90
91         state.life360AccessToken = params.access_token
92     
93     def html = """
94 <!DOCTYPE html>
95 <html>
96 <head>
97 <meta name="viewport" content="width=640">
98 <title>Withings Connection</title>
99 <style type="text/css">
100         @font-face {
101                 font-family: 'Swiss 721 W01 Thin';
102                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
103                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
104                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
105                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
106                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
107                 font-weight: normal;
108                 font-style: normal;
109         }
110         @font-face {
111                 font-family: 'Swiss 721 W01 Light';
112                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
113                 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
114                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
115                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
116                          url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
117                 font-weight: normal;
118                 font-style: normal;
119         }
120         .container {
121                 width: 560px;
122                 padding: 40px;
123                 /*background: #eee;*/
124                 text-align: center;
125         }
126         img {
127                 vertical-align: middle;
128         }
129         img:nth-child(2) {
130                 margin: 0 30px;
131         }
132         p {
133                 font-size: 2.2em;
134                 font-family: 'Swiss 721 W01 Thin';
135                 text-align: center;
136                 color: #666666;
137                 padding: 0 40px;
138                 margin-bottom: 0;
139         }
140 /*
141         p:last-child {
142                 margin-top: 0px;
143         }
144 */
145         span {
146                 font-family: 'Swiss 721 W01 Light';
147         }
148 </style>
149 </head>
150 <body>
151         <div class="container">
152                 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/life360@2x.png" alt="Life360 icon" />
153                 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
154                 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
155                 <p>Your Life360 Account is now connected to SmartThings!</p>
156                 <p>Click 'Done' to finish setup.</p>
157         </div>
158 </body>
159 </html>
160 """
161
162         render contentType: 'text/html', data: html
163
164 }
165
166 def oauthInitUrl()
167 {
168     log.debug "oauthInitUrl"
169     def stcid = getSmartThingsClientId();
170     
171         // def oauth_url = "https://api.life360.com/v3/oauth2/authorize?client_id=pREqugabRetre4EstetherufrePumamExucrEHuc&response_type=token&redirect_uri=http%3A%2F%2Fwww.smartthings.com"
172
173         state.oauthInitState = UUID.randomUUID().toString()
174     
175         def oauthParams = [
176         response_type: "token", 
177         client_id: stcid,  
178         redirect_uri: buildRedirectUrl() 
179     ]
180
181         return "https://api.life360.com/v3/oauth2/authorize?" + toQueryString(oauthParams)
182 }
183
184 String toQueryString(Map m) {
185         return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
186 }
187
188 def getSmartThingsClientId() {
189    return "pREqugabRetre4EstetherufrePumamExucrEHuc"
190 }
191
192 def getServerUrl() { getApiServerUrl() }
193
194 def buildRedirectUrl()
195 {
196     log.debug "buildRedirectUrl"
197     // /api/token/:st_token/smartapps/installations/:id/something
198     
199         return serverUrl + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/receiveToken"
200 }
201
202 //
203 // This method is no longer used - was part of the initial username/password based authentication that has now been replaced
204 // by the full OAUTH web flow
205 //
206
207 def getCredentialsPage() {
208
209         dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false)
210     {
211                 section("Life 360 Credentials ...") {
212                         input "username", "text", title: "Life360 Username?", multiple: false, required: true
213                         input "password", "password", title: "Life360 Password?", multiple: false, required: true, autoCorrect: false
214         }
215
216     }
217
218 }
219
220 //
221 // This method is no longer used - was part of the initial username/password based authentication that has now been replaced
222 // by the full OAUTH web flow
223 //
224
225 def getCredentialsErrorPage(String message) {
226
227         dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false)
228     {
229                 section("Life 360 Credentials ...") {
230                         input "username", "text", title: "Life360 Username?", multiple: false, required: true
231                         input "password", "password", title: "Life360 Password?", multiple: false, required: true, autoCorrect: false
232             paragraph "${message}"
233         }
234
235     }
236
237 }
238
239 def testLife360Connection() {
240
241         if (state.life360AccessToken)
242                 true
243     else
244         false
245         
246 }
247
248 //
249 // This method is no longer used - was part of the initial username/password based authentication that has now been replaced
250 // by the full OAUTH web flow
251 //
252
253 def initializeLife360Connection() {
254
255         def oauthClientId = appSettings.clientId
256         def oauthClientSecret = appSettings.clientSecret
257
258         initialize()
259     
260     def username = settings.username
261     def password = settings.password
262     
263     // Base 64 encode the credentials
264
265         def basicCredentials = "${oauthClientId}:${oauthClientSecret}"
266     def encodedCredentials = basicCredentials.encodeAsBase64().toString()
267     
268     
269     // call life360, get OAUTH token using password flow, save
270     // curl -X POST -H "Authorization: Basic cFJFcXVnYWJSZXRyZTRFc3RldGhlcnVmcmVQdW1hbUV4dWNyRUh1YzptM2ZydXBSZXRSZXN3ZXJFQ2hBUHJFOTZxYWtFZHI0Vg==" 
271     //      -F "grant_type=password" -F "username=jeff@hagins.us" -F "password=tondeleo" https://api.life360.com/v3/oauth2/token.json
272     
273
274     def url = "https://api.life360.com/v3/oauth2/token.json"
275     
276         
277     def postBody =  "grant_type=password&" +
278                                 "username=${username}&"+
279                     "password=${password}"
280
281     def result = null
282     
283     try {
284        
285                 httpPost(uri: url, body: postBody, headers: ["Authorization": "Basic ${encodedCredentials}" ]) {response -> 
286                 result = response
287                 }
288         if (result.data.access_token) {
289                 state.life360AccessToken = result.data.access_token
290             return true;
291                 }
292                 log.info "Life360 initializeLife360Connection, response=${result.data}"
293         return false;
294         
295     }
296     catch (e) {
297        log.error "Life360 initializeLife360Connection, error: $e"
298        return false;
299     }
300
301 }
302
303 def listCircles (){
304
305         // understand whether to present the Uninstall option
306     def uninstallOption = false
307     if (app.installationState == "COMPLETE")
308        uninstallOption = true
309
310         // get connected to life360 api
311
312         if (testLife360Connection()) {
313     
314         // now pull back the list of Life360 circles
315         // curl -X GET -H "Authorization: Bearer MmEzODQxYWQtMGZmMy00MDZhLWEwMGQtMTIzYmYxYzFmNGU3" https://api.life360.com/v3/circles.json
316     
317         def url = "https://api.life360.com/v3/circles.json"
318  
319         def result = null
320        
321                 httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> 
322                 result = response
323                 }
324
325                 log.debug "Circles=${result.data}"
326     
327         def circles = result.data.circles
328     
329         if (circles.size > 1) {
330             return (
331                         dynamicPage(name: "listCirclesPage", title: "Life360 Circles", nextPage: null, uninstall: uninstallOption, install:false) {
332                                 section("Select Life360 Circle:") {
333                                         input "circle", "enum", multiple: false, required:true, title:"Life360 Circle: ", options: circles.collectEntries{[it.id, it.name]}     
334                                 }
335                         }
336                 )
337         }
338         else {
339                 state.circle = circles[0].id
340                 return (listPlaces())
341         }  
342         }
343     else {
344         getCredentialsErrorPage("Invalid Usernaname or password.")
345     }
346     
347 }
348
349 def listPlaces() {
350
351         // understand whether to present the Uninstall option
352     def uninstallOption = false
353     if (app.installationState == "COMPLETE")
354        uninstallOption = true
355        
356         if (!state?.circle)
357         state.circle = settings.circle
358
359         // call life360 and get the list of places in the circle
360     
361         def url = "https://api.life360.com/v3/circles/${state.circle}/places.json"
362  
363     def result = null
364        
365         httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> 
366         result = response
367         }
368
369         log.debug "Places=${result.data}" 
370     
371     def places = result.data.places
372     state.places = places
373     
374     // If there is a place called "Home" use it as the default
375     def defaultPlace = places.find{it.name=="Home"}
376     def defaultPlaceId
377     if (defaultPlace) {
378         defaultPlaceId = defaultPlace.id
379         log.debug "Place = $defaultPlace.name, Id=$defaultPlace.id"
380     }
381        
382     dynamicPage(name: "listPlacesPage", title: "Life360 Places", nextPage: null, uninstall: uninstallOption, install:false) {
383         section("Select Life360 Place to Match Current Location:") {
384             paragraph "Please select the ONE Life360 Place that matches your SmartThings location: ${location.name}"
385                 input "place", "enum", multiple: false, required:true, title:"Life360 Places: ", options: places.collectEntries{[it.id, it.name]}, defaultValue: defaultPlaceId
386         }
387     }
388     
389 }
390
391 def listUsers () {
392
393         // understand whether to present the Uninstall option
394     def uninstallOption = false
395     if (app.installationState == "COMPLETE")
396        uninstallOption = true
397     
398         if (!state?.circle)
399         state.circle = settings.circle
400
401     // call life360 and get list of users (members)
402
403     def url = "https://api.life360.com/v3/circles/${state.circle}/members.json"
404  
405     def result = null
406        
407         httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> 
408         result = response
409         }
410
411         log.debug "Members=${result.data}"
412     
413     // save members list for later
414     
415     def members = result.data.members
416     
417     state.members = members
418     
419     // build preferences page
420         
421     dynamicPage(name: "listUsersPage", title: "Life360 Users", nextPage: null, uninstall: uninstallOption, install:true) {
422         section("Select Life360 Users to Import into SmartThings:") {
423                 input "users", "enum", multiple: true, required:true, title:"Life360 Users: ", options: members.collectEntries{[it.id, it.firstName+" "+it.lastName]}   
424         }
425     }
426 }
427
428 def installed() {
429
430         if (!state?.circle)
431         state.circle = settings.circle
432
433         log.debug "In installed() method."
434     // log.debug "Members: ${state.members}"
435     // log.debug "Users: ${settings.users}"
436     
437     settings.users.each {memberId->
438     
439         // log.debug "Find by Member Id = ${memberId}"
440     
441         def member = state.members.find{it.id==memberId}
442         
443         // log.debug "After Find Attempt."
444
445         // log.debug "Member Id = ${member.id}, Name = ${member.firstName} ${member.lastName}, Email Address = ${member.loginEmail}"
446         
447         // log.debug "External Id=${app.id}:${member.id}"
448        
449         // create the device
450         if (member) {
451         
452                 def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true])
453         
454                 // save the memberId on the device itself so we can find easily later
455                 // childDevice.setMemberId(member.id)
456         
457                 if (childDevice)
458                 {
459                         // log.debug "Child Device Successfully Created"
460                                 generateInitialEvent (member, childDevice)
461                         
462                 // build the icon name form the L360 Avatar URL
463                 // URL Format: https://www.life360.com/img/user_images/b4698717-1f2e-4b7a-b0d4-98ccfb4e9730/Maddie_Hagins_51d2eea2019c7.jpeg
464                 // SmartThings Icon format is: L360.b4698717-1f2e-4b7a-b0d4-98ccfb4e9730.Maddie_Hagins_51d2eea2019c7
465                 try {
466                 
467                         // build the icon name from the avatar URL
468                         log.debug "Avatar URL = ${member.avatar}"
469                         def urlPathElements = member.avatar.tokenize("/")
470                     def fileElements = urlPathElements[5].tokenize(".")
471                     // def icon = "st.Lighting.light1"
472                         def icon="l360.${urlPathElements[4]}.${fileElements[0]}"
473                     log.debug "Icon = ${icon}"
474
475                         // set the icon on the device
476                                         childDevice.setIcon("presence","present",icon)
477                                         childDevice.setIcon("presence","not present",icon)
478                                         childDevice.save()
479                 }
480                 catch (e) { // do nothing
481                         log.debug "Error = ${e}"
482                 } 
483                 }
484         }
485     }
486     
487     createCircleSubscription()
488     
489 }
490
491 def createCircleSubscription() {
492
493     // delete any existing webhook subscriptions for this circle
494     //
495     // curl -X DELETE https://webhook.qa.life360.com/v3/circles/:circleId/webhook.json
496     
497     log.debug "Remove any existing Life360 Webhooks for this Circle."
498     
499     def deleteUrl = "https://api.life360.com/v3/circles/${state.circle}/webhook.json"
500     
501     try { // ignore any errors - there many not be any existing webhooks
502     
503         httpDelete (uri: deleteUrl, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> 
504                 result = response}
505                 }
506     
507     catch (e) {
508     
509         log.debug (e)
510     }
511     
512     // subscribe to the life360 webhook to get push notifications on place events within this circle
513     
514     // POST /circles/:circle_id/places/webooks
515         // Params: hook_url
516     
517     log.debug "Create a new Life360 Webhooks for this Circle."
518     
519     createAccessToken() // create our own OAUTH access token to use in webhook url
520     
521     def hookUrl = "${serverUrl}/api/smartapps/installations/${app.id}/placecallback?access_token=${state.accessToken}".encodeAsURL()
522     
523     def url = "https://api.life360.com/v3/circles/${state.circle}/webhook.json"
524         
525     def postBody =  "url=${hookUrl}"
526
527     def result = null
528     
529     try {
530        
531             httpPost(uri: url, body: postBody, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> 
532             result = response}
533     
534     } catch (e) {
535         log.debug (e)
536     }
537     
538     // response from this call looks like this:
539     // {"circleId":"41094b6a-32fc-4ef5-a9cd-913f82268836","userId":"0d1db550-9163-471b-8829-80b375e0fa51","clientId":"11",
540     //    "hookUrl":"https://testurl.com"}
541     
542     log.debug "Response = ${response}"
543     
544     if (result.data?.hookUrl) {
545             log.debug "Webhook creation successful. Response = ${result.data}"
546     
547         }
548 }
549
550
551 def updated() {
552
553         if (!state?.circle)
554         state.circle = settings.circle
555
556         log.debug "In updated() method."
557     // log.debug "Members: ${state.members}"
558     // log.debug "Users: ${settings.users}"
559     
560     // loop through selected users and try to find child device for each
561     
562     settings.users.each {memberId->
563     
564         def externalId = "${app.id}.${memberId}"
565
566                 // find the appropriate child device based on my app id and the device network id
567
568                 def deviceWrapper = getChildDevice("${externalId}")
569         
570         if (!deviceWrapper) { // device isn't there - so we need to create
571     
572                 // log.debug "Find by Member Id = ${memberId}"
573     
574                 def member = state.members.find{it.id==memberId}
575         
576                 // log.debug "After Find Attempt."
577
578                 // log.debug "External Id=${app.id}:${member.id}"
579        
580                 // create the device
581                 def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true])
582             // childDevice.setMemberId(member.id)
583         
584                 if (childDevice)
585                 {
586                         // log.debug "Child Device Successfully Created"
587                                 generateInitialEvent (member, childDevice)
588                 
589                 // build the icon name form the L360 Avatar URL
590                 // URL Format: https://www.life360.com/img/user_images/b4698717-1f2e-4b7a-b0d4-98ccfb4e9730/Maddie_Hagins_51d2eea2019c7.jpeg
591                 // SmartThings Icon format is: L360.b4698717-1f2e-4b7a-b0d4-98ccfb4e9730.Maddie_Hagins_51d2eea2019c7
592                 try {
593                 
594                         // build the icon name from the avatar URL
595                         log.debug "Avatar URL = ${member.avatar}"
596                         def urlPathElements = member.avatar.tokenize("/")
597                         def icon="l360.${urlPathElements[4]}.${urlPathElements[5]}"
598
599                         // set the icon on the device
600                                         childDevice.setIcon("presence","present",icon)
601                                         childDevice.setIcon("presence","not present",icon)
602                                         childDevice.save()
603                 }
604                 catch (e) { // do nothing
605                         log.debug "Error = ${e}"
606                 } 
607                 }
608             
609         }
610         else {
611         
612                 // log.debug "Find by Member Id = ${memberId}"
613     
614                 def member = state.members.find{it.id==memberId}
615     
616                 generateInitialEvent (member, deviceWrapper)
617             
618         }
619     }
620
621         // Now remove any existing devices that represent users that are no longer selected
622     
623     def childDevices = getAllChildDevices()
624     
625     log.debug "Child Devices = ${childDevices}"
626     
627     childDevices.each {childDevice->
628     
629         log.debug "Child = ${childDevice}, DNI=${childDevice.deviceNetworkId}"
630         
631         // def childMemberId = childDevice.getMemberId()
632         
633         def splitStrings = childDevice.deviceNetworkId.split("\\.")
634         
635         log.debug "Strings = ${splitStrings}"
636         
637         def childMemberId = splitStrings[1]
638         
639         log.debug "Child Member Id = ${childMemberId}"
640         
641         log.debug "Settings.users = ${settings.users}"
642         
643         if (!settings.users.find{it==childMemberId}) {
644             deleteChildDevice(childDevice.deviceNetworkId)
645             def member = state.members.find {it.id==memberId}
646             if (member)
647                 state.members.remove(member)
648         }
649     
650     }
651 }
652
653 def generateInitialEvent (member, childDevice) {
654
655     // lets figure out if the member is currently "home" (At the place)
656     
657     try { // we are going to just ignore any errors
658     
659         log.info "Life360 generateInitialEvent($member, $childDevice)"
660         
661         def place = state.places.find{it.id==settings.place}
662         
663         if (place) {
664         
665                 def memberLatitude = new Float (member.location.latitude)
666             def memberLongitude = new Float (member.location.longitude)
667             def placeLatitude = new Float (place.latitude)
668             def placeLongitude = new Float (place.longitude)
669             def placeRadius = new Float (place.radius)
670         
671                 // log.debug "Member Location = ${memberLatitude}/${memberLongitude}"
672             // log.debug "Place Location = ${placeLatitude}/${placeLongitude}"
673             // log.debug "Place Radius = ${placeRadius}"
674         
675                 def distanceAway = haversine(memberLatitude, memberLongitude, placeLatitude, placeLongitude)*1000 // in meters
676   
677                 // log.debug "Distance Away = ${distanceAway}"
678   
679                         boolean isPresent = (distanceAway <= placeRadius)
680
681                         log.info "Life360 generateInitialEvent, member: ($memberLatitude, $memberLongitude), place: ($placeLatitude, $placeLongitude), radius: $placeRadius, dist: $distanceAway, present: $isPresent"
682                 
683                 // log.debug "External Id=${app.id}:${member.id}"
684         
685                 // def childDevice2 = getChildDevice("${app.id}.${member.id}")
686                 
687                 // log.debug "Child Device = ${childDevice2}"
688         
689                 childDevice?.generatePresenceEvent(isPresent)
690         
691                 // log.debug "After generating presence event."
692             
693         }
694         
695         }
696     catch (e) {
697         // eat it
698     }
699         
700 }
701
702 def initialize() {
703         // TODO: subscribe to attributes, devices, locations, etc.
704 }
705
706 def haversine(lat1, lon1, lat2, lon2) {
707   def R = 6372.8
708   // In kilometers
709   def dLat = Math.toRadians(lat2 - lat1)
710   def dLon = Math.toRadians(lon2 - lon1)
711   lat1 = Math.toRadians(lat1)
712   lat2 = Math.toRadians(lat2)
713  
714   def a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2)
715   def c = 2 * Math.asin(Math.sqrt(a))
716   def d = R * c
717   return(d)
718 }
719
720
721 def placeEventHandler() {
722
723         log.info "Life360 placeEventHandler: params=$params, settings.place=$settings.place"
724
725         // the POST to this end-point will look like:
726     // POST http://test.com/webhook?circleId=XXXX&placeId=XXXX&userId=XXXX&direction=arrive
727     
728     def circleId = params?.circleId
729     def placeId = params?.placeId
730     def userId = params?.userId
731     def direction = params?.direction
732     def timestamp = params?.timestamp
733     
734     if (placeId == settings.place) {
735
736                 def presenceState = (direction=="in")
737     
738                 def externalId = "${app.id}.${userId}"
739
740                 // find the appropriate child device based on my app id and the device network id
741
742                 def deviceWrapper = getChildDevice("${externalId}")
743
744                 // invoke the generatePresenceEvent method on the child device
745
746                 if (deviceWrapper) {
747                         deviceWrapper.generatePresenceEvent(presenceState)
748                 log.debug "Life360 event raised on child device: ${externalId}"
749                 }
750                 else {
751                 log.warn "Life360 couldn't find child device associated with inbound Life360 event."
752         }
753     }
754
755 }