4 * Copyright 2014 Jeff's Account
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:
9 * http://www.apache.org/licenses/LICENSE-2.0
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.
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"],
29 appSetting "clientSecret"
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)
39 // page(name: "Credentials", title: "Enter Life360 Credentials", content: "getCredentialsPage", nextPage: "listCirclesPage", install: false)
40 // page(name: "page3", title: "Select Life360 Users", content: "listUsers")
44 path("/placecallback") {
46 POST: "placeEventHandler",
47 GET: "placeEventHandler"
51 path("/receiveToken") {
61 log.debug "authPage()"
63 def description = "Life360 Credentials Already Entered."
65 def uninstallOption = false
66 if (app.installationState == "COMPLETE")
67 uninstallOption = true
69 if(!state.life360AccessToken)
71 log.debug "about to create access token"
73 description = "Click to enter Life360 Credentials."
75 def redirectUrl = oauthInitUrl()
77 return dynamicPage(name: "Credentials", title: "Life360", nextPage:"listCirclesPage", uninstall: uninstallOption, install:false) {
79 href url:redirectUrl, style:"embedded", required:false, title:"Life360", description:description
91 state.life360AccessToken = params.access_token
97 <meta name="viewport" content="width=640">
98 <title>Withings Connection</title>
99 <style type="text/css">
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');
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');
123 /*background: #eee;*/
127 vertical-align: middle;
134 font-family: 'Swiss 721 W01 Thin';
146 font-family: 'Swiss 721 W01 Light';
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>
162 render contentType: 'text/html', data: html
168 log.debug "oauthInitUrl"
169 def stcid = getSmartThingsClientId();
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"
173 state.oauthInitState = UUID.randomUUID().toString()
176 response_type: "token",
178 redirect_uri: buildRedirectUrl()
181 return "https://api.life360.com/v3/oauth2/authorize?" + toQueryString(oauthParams)
184 String toQueryString(Map m) {
185 return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
188 def getSmartThingsClientId() {
189 return "pREqugabRetre4EstetherufrePumamExucrEHuc"
192 def getServerUrl() { getApiServerUrl() }
194 def buildRedirectUrl()
196 log.debug "buildRedirectUrl"
197 // /api/token/:st_token/smartapps/installations/:id/something
199 return serverUrl + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/receiveToken"
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
207 def getCredentialsPage() {
209 dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false)
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
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
225 def getCredentialsErrorPage(String message) {
227 dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false)
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}"
239 def testLife360Connection() {
241 if (state.life360AccessToken)
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
253 def initializeLife360Connection() {
255 def oauthClientId = appSettings.clientId
256 def oauthClientSecret = appSettings.clientSecret
260 def username = settings.username
261 def password = settings.password
263 // Base 64 encode the credentials
265 def basicCredentials = "${oauthClientId}:${oauthClientSecret}"
266 def encodedCredentials = basicCredentials.encodeAsBase64().toString()
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
274 def url = "https://api.life360.com/v3/oauth2/token.json"
277 def postBody = "grant_type=password&" +
278 "username=${username}&"+
279 "password=${password}"
285 httpPost(uri: url, body: postBody, headers: ["Authorization": "Basic ${encodedCredentials}" ]) {response ->
288 if (result.data.access_token) {
289 state.life360AccessToken = result.data.access_token
292 log.info "Life360 initializeLife360Connection, response=${result.data}"
297 log.error "Life360 initializeLife360Connection, error: $e"
305 // understand whether to present the Uninstall option
306 def uninstallOption = false
307 if (app.installationState == "COMPLETE")
308 uninstallOption = true
310 // get connected to life360 api
312 if (testLife360Connection()) {
314 // now pull back the list of Life360 circles
315 // curl -X GET -H "Authorization: Bearer MmEzODQxYWQtMGZmMy00MDZhLWEwMGQtMTIzYmYxYzFmNGU3" https://api.life360.com/v3/circles.json
317 def url = "https://api.life360.com/v3/circles.json"
321 httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response ->
325 log.debug "Circles=${result.data}"
327 def circles = result.data.circles
329 if (circles.size > 1) {
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]}
339 state.circle = circles[0].id
340 return (listPlaces())
344 getCredentialsErrorPage("Invalid Usernaname or password.")
351 // understand whether to present the Uninstall option
352 def uninstallOption = false
353 if (app.installationState == "COMPLETE")
354 uninstallOption = true
357 state.circle = settings.circle
359 // call life360 and get the list of places in the circle
361 def url = "https://api.life360.com/v3/circles/${state.circle}/places.json"
365 httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response ->
369 log.debug "Places=${result.data}"
371 def places = result.data.places
372 state.places = places
374 // If there is a place called "Home" use it as the default
375 def defaultPlace = places.find{it.name=="Home"}
378 defaultPlaceId = defaultPlace.id
379 log.debug "Place = $defaultPlace.name, Id=$defaultPlace.id"
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
393 // understand whether to present the Uninstall option
394 def uninstallOption = false
395 if (app.installationState == "COMPLETE")
396 uninstallOption = true
399 state.circle = settings.circle
401 // call life360 and get list of users (members)
403 def url = "https://api.life360.com/v3/circles/${state.circle}/members.json"
407 httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response ->
411 log.debug "Members=${result.data}"
413 // save members list for later
415 def members = result.data.members
417 state.members = members
419 // build preferences page
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]}
431 state.circle = settings.circle
433 log.debug "In installed() method."
434 // log.debug "Members: ${state.members}"
435 // log.debug "Users: ${settings.users}"
437 settings.users.each {memberId->
439 // log.debug "Find by Member Id = ${memberId}"
441 def member = state.members.find{it.id==memberId}
443 // log.debug "After Find Attempt."
445 // log.debug "Member Id = ${member.id}, Name = ${member.firstName} ${member.lastName}, Email Address = ${member.loginEmail}"
447 // log.debug "External Id=${app.id}:${member.id}"
452 def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true])
454 // save the memberId on the device itself so we can find easily later
455 // childDevice.setMemberId(member.id)
459 // log.debug "Child Device Successfully Created"
460 generateInitialEvent (member, childDevice)
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
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}"
475 // set the icon on the device
476 childDevice.setIcon("presence","present",icon)
477 childDevice.setIcon("presence","not present",icon)
480 catch (e) { // do nothing
481 log.debug "Error = ${e}"
487 createCircleSubscription()
491 def createCircleSubscription() {
493 // delete any existing webhook subscriptions for this circle
495 // curl -X DELETE https://webhook.qa.life360.com/v3/circles/:circleId/webhook.json
497 log.debug "Remove any existing Life360 Webhooks for this Circle."
499 def deleteUrl = "https://api.life360.com/v3/circles/${state.circle}/webhook.json"
501 try { // ignore any errors - there many not be any existing webhooks
503 httpDelete (uri: deleteUrl, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response ->
512 // subscribe to the life360 webhook to get push notifications on place events within this circle
514 // POST /circles/:circle_id/places/webooks
517 log.debug "Create a new Life360 Webhooks for this Circle."
519 createAccessToken() // create our own OAUTH access token to use in webhook url
521 def hookUrl = "${serverUrl}/api/smartapps/installations/${app.id}/placecallback?access_token=${state.accessToken}".encodeAsURL()
523 def url = "https://api.life360.com/v3/circles/${state.circle}/webhook.json"
525 def postBody = "url=${hookUrl}"
531 httpPost(uri: url, body: postBody, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response ->
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"}
542 log.debug "Response = ${response}"
544 if (result.data?.hookUrl) {
545 log.debug "Webhook creation successful. Response = ${result.data}"
554 state.circle = settings.circle
556 log.debug "In updated() method."
557 // log.debug "Members: ${state.members}"
558 // log.debug "Users: ${settings.users}"
560 // loop through selected users and try to find child device for each
562 settings.users.each {memberId->
564 def externalId = "${app.id}.${memberId}"
566 // find the appropriate child device based on my app id and the device network id
568 def deviceWrapper = getChildDevice("${externalId}")
570 if (!deviceWrapper) { // device isn't there - so we need to create
572 // log.debug "Find by Member Id = ${memberId}"
574 def member = state.members.find{it.id==memberId}
576 // log.debug "After Find Attempt."
578 // log.debug "External Id=${app.id}:${member.id}"
581 def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true])
582 // childDevice.setMemberId(member.id)
586 // log.debug "Child Device Successfully Created"
587 generateInitialEvent (member, childDevice)
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
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]}"
599 // set the icon on the device
600 childDevice.setIcon("presence","present",icon)
601 childDevice.setIcon("presence","not present",icon)
604 catch (e) { // do nothing
605 log.debug "Error = ${e}"
612 // log.debug "Find by Member Id = ${memberId}"
614 def member = state.members.find{it.id==memberId}
616 generateInitialEvent (member, deviceWrapper)
621 // Now remove any existing devices that represent users that are no longer selected
623 def childDevices = getAllChildDevices()
625 log.debug "Child Devices = ${childDevices}"
627 childDevices.each {childDevice->
629 log.debug "Child = ${childDevice}, DNI=${childDevice.deviceNetworkId}"
631 // def childMemberId = childDevice.getMemberId()
633 def splitStrings = childDevice.deviceNetworkId.split("\\.")
635 log.debug "Strings = ${splitStrings}"
637 def childMemberId = splitStrings[1]
639 log.debug "Child Member Id = ${childMemberId}"
641 log.debug "Settings.users = ${settings.users}"
643 if (!settings.users.find{it==childMemberId}) {
644 deleteChildDevice(childDevice.deviceNetworkId)
645 def member = state.members.find {it.id==memberId}
647 state.members.remove(member)
653 def generateInitialEvent (member, childDevice) {
655 // lets figure out if the member is currently "home" (At the place)
657 try { // we are going to just ignore any errors
659 log.info "Life360 generateInitialEvent($member, $childDevice)"
661 def place = state.places.find{it.id==settings.place}
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)
671 // log.debug "Member Location = ${memberLatitude}/${memberLongitude}"
672 // log.debug "Place Location = ${placeLatitude}/${placeLongitude}"
673 // log.debug "Place Radius = ${placeRadius}"
675 def distanceAway = haversine(memberLatitude, memberLongitude, placeLatitude, placeLongitude)*1000 // in meters
677 // log.debug "Distance Away = ${distanceAway}"
679 boolean isPresent = (distanceAway <= placeRadius)
681 log.info "Life360 generateInitialEvent, member: ($memberLatitude, $memberLongitude), place: ($placeLatitude, $placeLongitude), radius: $placeRadius, dist: $distanceAway, present: $isPresent"
683 // log.debug "External Id=${app.id}:${member.id}"
685 // def childDevice2 = getChildDevice("${app.id}.${member.id}")
687 // log.debug "Child Device = ${childDevice2}"
689 childDevice?.generatePresenceEvent(isPresent)
691 // log.debug "After generating presence event."
703 // TODO: subscribe to attributes, devices, locations, etc.
706 def haversine(lat1, lon1, lat2, lon2) {
709 def dLat = Math.toRadians(lat2 - lat1)
710 def dLon = Math.toRadians(lon2 - lon1)
711 lat1 = Math.toRadians(lat1)
712 lat2 = Math.toRadians(lat2)
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))
721 def placeEventHandler() {
723 log.info "Life360 placeEventHandler: params=$params, settings.place=$settings.place"
725 // the POST to this end-point will look like:
726 // POST http://test.com/webhook?circleId=XXXX&placeId=XXXX&userId=XXXX&direction=arrive
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
734 if (placeId == settings.place) {
736 def presenceState = (direction=="in")
738 def externalId = "${app.id}.${userId}"
740 // find the appropriate child device based on my app id and the device network id
742 def deviceWrapper = getChildDevice("${externalId}")
744 // invoke the generatePresenceEvent method on the child device
747 deviceWrapper.generatePresenceEvent(presenceState)
748 log.debug "Life360 event raised on child device: ${externalId}"
751 log.warn "Life360 couldn't find child device associated with inbound Life360 event."