/** * life360 * * Copyright 2014 Jeff's Account * * 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. * */ definition( name: "Life360 (Connect)", namespace: "smartthings", author: "SmartThings", description: "Life360 Service Manager", category: "SmartThings Labs", iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/life360.png", iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/life360@2x.png", oauth: [displayName: "Life360", displayLink: "Life360"], singleInstance: true ) { appSetting "clientId" appSetting "clientSecret" } preferences { page(name: "Credentials", title: "Life360 Authentication", content: "authPage", nextPage: "listCirclesPage", install: false) page(name: "listCirclesPage", title: "Select Life360 Circle", nextPage: "listPlacesPage", content: "listCircles", install: false) page(name: "listPlacesPage", title: "Select Life360 Place", nextPage: "listUsersPage", content: "listPlaces", install: false) page(name: "listUsersPage", title: "Select Life360 Users", content: "listUsers", install: true) } // page(name: "Credentials", title: "Enter Life360 Credentials", content: "getCredentialsPage", nextPage: "listCirclesPage", install: false) // page(name: "page3", title: "Select Life360 Users", content: "listUsers") mappings { path("/placecallback") { action: [ POST: "placeEventHandler", GET: "placeEventHandler" ] } path("/receiveToken") { action: [ POST: "receiveToken", GET: "receiveToken" ] } } def authPage() { log.debug "authPage()" def description = "Life360 Credentials Already Entered." def uninstallOption = false if (app.installationState == "COMPLETE") uninstallOption = true if(!state.life360AccessToken) { log.debug "about to create access token" createAccessToken() description = "Click to enter Life360 Credentials." def redirectUrl = oauthInitUrl() return dynamicPage(name: "Credentials", title: "Life360", nextPage:"listCirclesPage", uninstall: uninstallOption, install:false) { section { href url:redirectUrl, style:"embedded", required:false, title:"Life360", description:description } } } else { listCircles() } } def receiveToken() { state.life360AccessToken = params.access_token def html = """ Withings Connection
Life360 icon connected device icon SmartThings logo

Your Life360 Account is now connected to SmartThings!

Click 'Done' to finish setup.

""" render contentType: 'text/html', data: html } def oauthInitUrl() { log.debug "oauthInitUrl" def stcid = getSmartThingsClientId(); // def oauth_url = "https://api.life360.com/v3/oauth2/authorize?client_id=pREqugabRetre4EstetherufrePumamExucrEHuc&response_type=token&redirect_uri=http%3A%2F%2Fwww.smartthings.com" state.oauthInitState = UUID.randomUUID().toString() def oauthParams = [ response_type: "token", client_id: stcid, redirect_uri: buildRedirectUrl() ] return "https://api.life360.com/v3/oauth2/authorize?" + toQueryString(oauthParams) } String toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") } def getSmartThingsClientId() { return "pREqugabRetre4EstetherufrePumamExucrEHuc" } def getServerUrl() { getApiServerUrl() } def buildRedirectUrl() { log.debug "buildRedirectUrl" // /api/token/:st_token/smartapps/installations/:id/something return serverUrl + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/receiveToken" } // // This method is no longer used - was part of the initial username/password based authentication that has now been replaced // by the full OAUTH web flow // def getCredentialsPage() { dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false) { section("Life 360 Credentials ...") { input "username", "text", title: "Life360 Username?", multiple: false, required: true input "password", "password", title: "Life360 Password?", multiple: false, required: true, autoCorrect: false } } } // // This method is no longer used - was part of the initial username/password based authentication that has now been replaced // by the full OAUTH web flow // def getCredentialsErrorPage(String message) { dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false) { section("Life 360 Credentials ...") { input "username", "text", title: "Life360 Username?", multiple: false, required: true input "password", "password", title: "Life360 Password?", multiple: false, required: true, autoCorrect: false paragraph "${message}" } } } def testLife360Connection() { if (state.life360AccessToken) true else false } // // This method is no longer used - was part of the initial username/password based authentication that has now been replaced // by the full OAUTH web flow // def initializeLife360Connection() { def oauthClientId = appSettings.clientId def oauthClientSecret = appSettings.clientSecret initialize() def username = settings.username def password = settings.password // Base 64 encode the credentials def basicCredentials = "${oauthClientId}:${oauthClientSecret}" def encodedCredentials = basicCredentials.encodeAsBase64().toString() // call life360, get OAUTH token using password flow, save // curl -X POST -H "Authorization: Basic cFJFcXVnYWJSZXRyZTRFc3RldGhlcnVmcmVQdW1hbUV4dWNyRUh1YzptM2ZydXBSZXRSZXN3ZXJFQ2hBUHJFOTZxYWtFZHI0Vg==" // -F "grant_type=password" -F "username=jeff@hagins.us" -F "password=tondeleo" https://api.life360.com/v3/oauth2/token.json def url = "https://api.life360.com/v3/oauth2/token.json" def postBody = "grant_type=password&" + "username=${username}&"+ "password=${password}" def result = null try { httpPost(uri: url, body: postBody, headers: ["Authorization": "Basic ${encodedCredentials}" ]) {response -> result = response } if (result.data.access_token) { state.life360AccessToken = result.data.access_token return true; } log.info "Life360 initializeLife360Connection, response=${result.data}" return false; } catch (e) { log.error "Life360 initializeLife360Connection, error: $e" return false; } } def listCircles (){ // understand whether to present the Uninstall option def uninstallOption = false if (app.installationState == "COMPLETE") uninstallOption = true // get connected to life360 api if (testLife360Connection()) { // now pull back the list of Life360 circles // curl -X GET -H "Authorization: Bearer MmEzODQxYWQtMGZmMy00MDZhLWEwMGQtMTIzYmYxYzFmNGU3" https://api.life360.com/v3/circles.json def url = "https://api.life360.com/v3/circles.json" def result = null httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> result = response } log.debug "Circles=${result.data}" def circles = result.data.circles if (circles.size > 1) { return ( dynamicPage(name: "listCirclesPage", title: "Life360 Circles", nextPage: null, uninstall: uninstallOption, install:false) { section("Select Life360 Circle:") { input "circle", "enum", multiple: false, required:true, title:"Life360 Circle: ", options: circles.collectEntries{[it.id, it.name]} } } ) } else { state.circle = circles[0].id return (listPlaces()) } } else { getCredentialsErrorPage("Invalid Usernaname or password.") } } def listPlaces() { // understand whether to present the Uninstall option def uninstallOption = false if (app.installationState == "COMPLETE") uninstallOption = true if (!state?.circle) state.circle = settings.circle // call life360 and get the list of places in the circle def url = "https://api.life360.com/v3/circles/${state.circle}/places.json" def result = null httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> result = response } log.debug "Places=${result.data}" def places = result.data.places state.places = places // If there is a place called "Home" use it as the default def defaultPlace = places.find{it.name=="Home"} def defaultPlaceId if (defaultPlace) { defaultPlaceId = defaultPlace.id log.debug "Place = $defaultPlace.name, Id=$defaultPlace.id" } dynamicPage(name: "listPlacesPage", title: "Life360 Places", nextPage: null, uninstall: uninstallOption, install:false) { section("Select Life360 Place to Match Current Location:") { paragraph "Please select the ONE Life360 Place that matches your SmartThings location: ${location.name}" input "place", "enum", multiple: false, required:true, title:"Life360 Places: ", options: places.collectEntries{[it.id, it.name]}, defaultValue: defaultPlaceId } } } def listUsers () { // understand whether to present the Uninstall option def uninstallOption = false if (app.installationState == "COMPLETE") uninstallOption = true if (!state?.circle) state.circle = settings.circle // call life360 and get list of users (members) def url = "https://api.life360.com/v3/circles/${state.circle}/members.json" def result = null httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> result = response } log.debug "Members=${result.data}" // save members list for later def members = result.data.members state.members = members // build preferences page dynamicPage(name: "listUsersPage", title: "Life360 Users", nextPage: null, uninstall: uninstallOption, install:true) { section("Select Life360 Users to Import into SmartThings:") { input "users", "enum", multiple: true, required:true, title:"Life360 Users: ", options: members.collectEntries{[it.id, it.firstName+" "+it.lastName]} } } } def installed() { if (!state?.circle) state.circle = settings.circle log.debug "In installed() method." // log.debug "Members: ${state.members}" // log.debug "Users: ${settings.users}" settings.users.each {memberId-> // log.debug "Find by Member Id = ${memberId}" def member = state.members.find{it.id==memberId} // log.debug "After Find Attempt." // log.debug "Member Id = ${member.id}, Name = ${member.firstName} ${member.lastName}, Email Address = ${member.loginEmail}" // log.debug "External Id=${app.id}:${member.id}" // create the device if (member) { def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true]) // save the memberId on the device itself so we can find easily later // childDevice.setMemberId(member.id) if (childDevice) { // log.debug "Child Device Successfully Created" generateInitialEvent (member, childDevice) // build the icon name form the L360 Avatar URL // URL Format: https://www.life360.com/img/user_images/b4698717-1f2e-4b7a-b0d4-98ccfb4e9730/Maddie_Hagins_51d2eea2019c7.jpeg // SmartThings Icon format is: L360.b4698717-1f2e-4b7a-b0d4-98ccfb4e9730.Maddie_Hagins_51d2eea2019c7 try { // build the icon name from the avatar URL log.debug "Avatar URL = ${member.avatar}" def urlPathElements = member.avatar.tokenize("/") def fileElements = urlPathElements[5].tokenize(".") // def icon = "st.Lighting.light1" def icon="l360.${urlPathElements[4]}.${fileElements[0]}" log.debug "Icon = ${icon}" // set the icon on the device childDevice.setIcon("presence","present",icon) childDevice.setIcon("presence","not present",icon) childDevice.save() } catch (e) { // do nothing log.debug "Error = ${e}" } } } } createCircleSubscription() } def createCircleSubscription() { // delete any existing webhook subscriptions for this circle // // curl -X DELETE https://webhook.qa.life360.com/v3/circles/:circleId/webhook.json log.debug "Remove any existing Life360 Webhooks for this Circle." def deleteUrl = "https://api.life360.com/v3/circles/${state.circle}/webhook.json" try { // ignore any errors - there many not be any existing webhooks httpDelete (uri: deleteUrl, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> result = response} } catch (e) { log.debug (e) } // subscribe to the life360 webhook to get push notifications on place events within this circle // POST /circles/:circle_id/places/webooks // Params: hook_url log.debug "Create a new Life360 Webhooks for this Circle." createAccessToken() // create our own OAUTH access token to use in webhook url def hookUrl = "${serverUrl}/api/smartapps/installations/${app.id}/placecallback?access_token=${state.accessToken}".encodeAsURL() def url = "https://api.life360.com/v3/circles/${state.circle}/webhook.json" def postBody = "url=${hookUrl}" def result = null try { httpPost(uri: url, body: postBody, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> result = response} } catch (e) { log.debug (e) } // response from this call looks like this: // {"circleId":"41094b6a-32fc-4ef5-a9cd-913f82268836","userId":"0d1db550-9163-471b-8829-80b375e0fa51","clientId":"11", // "hookUrl":"https://testurl.com"} log.debug "Response = ${response}" if (result.data?.hookUrl) { log.debug "Webhook creation successful. Response = ${result.data}" } } def updated() { if (!state?.circle) state.circle = settings.circle log.debug "In updated() method." // log.debug "Members: ${state.members}" // log.debug "Users: ${settings.users}" // loop through selected users and try to find child device for each settings.users.each {memberId-> def externalId = "${app.id}.${memberId}" // find the appropriate child device based on my app id and the device network id def deviceWrapper = getChildDevice("${externalId}") if (!deviceWrapper) { // device isn't there - so we need to create // log.debug "Find by Member Id = ${memberId}" def member = state.members.find{it.id==memberId} // log.debug "After Find Attempt." // log.debug "External Id=${app.id}:${member.id}" // create the device def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true]) // childDevice.setMemberId(member.id) if (childDevice) { // log.debug "Child Device Successfully Created" generateInitialEvent (member, childDevice) // build the icon name form the L360 Avatar URL // URL Format: https://www.life360.com/img/user_images/b4698717-1f2e-4b7a-b0d4-98ccfb4e9730/Maddie_Hagins_51d2eea2019c7.jpeg // SmartThings Icon format is: L360.b4698717-1f2e-4b7a-b0d4-98ccfb4e9730.Maddie_Hagins_51d2eea2019c7 try { // build the icon name from the avatar URL log.debug "Avatar URL = ${member.avatar}" def urlPathElements = member.avatar.tokenize("/") def icon="l360.${urlPathElements[4]}.${urlPathElements[5]}" // set the icon on the device childDevice.setIcon("presence","present",icon) childDevice.setIcon("presence","not present",icon) childDevice.save() } catch (e) { // do nothing log.debug "Error = ${e}" } } } else { // log.debug "Find by Member Id = ${memberId}" def member = state.members.find{it.id==memberId} generateInitialEvent (member, deviceWrapper) } } // Now remove any existing devices that represent users that are no longer selected def childDevices = getAllChildDevices() log.debug "Child Devices = ${childDevices}" childDevices.each {childDevice-> log.debug "Child = ${childDevice}, DNI=${childDevice.deviceNetworkId}" // def childMemberId = childDevice.getMemberId() def splitStrings = childDevice.deviceNetworkId.split("\\.") log.debug "Strings = ${splitStrings}" def childMemberId = splitStrings[1] log.debug "Child Member Id = ${childMemberId}" log.debug "Settings.users = ${settings.users}" if (!settings.users.find{it==childMemberId}) { deleteChildDevice(childDevice.deviceNetworkId) def member = state.members.find {it.id==memberId} if (member) state.members.remove(member) } } } def generateInitialEvent (member, childDevice) { // lets figure out if the member is currently "home" (At the place) try { // we are going to just ignore any errors log.info "Life360 generateInitialEvent($member, $childDevice)" def place = state.places.find{it.id==settings.place} if (place) { def memberLatitude = new Float (member.location.latitude) def memberLongitude = new Float (member.location.longitude) def placeLatitude = new Float (place.latitude) def placeLongitude = new Float (place.longitude) def placeRadius = new Float (place.radius) // log.debug "Member Location = ${memberLatitude}/${memberLongitude}" // log.debug "Place Location = ${placeLatitude}/${placeLongitude}" // log.debug "Place Radius = ${placeRadius}" def distanceAway = haversine(memberLatitude, memberLongitude, placeLatitude, placeLongitude)*1000 // in meters // log.debug "Distance Away = ${distanceAway}" boolean isPresent = (distanceAway <= placeRadius) log.info "Life360 generateInitialEvent, member: ($memberLatitude, $memberLongitude), place: ($placeLatitude, $placeLongitude), radius: $placeRadius, dist: $distanceAway, present: $isPresent" // log.debug "External Id=${app.id}:${member.id}" // def childDevice2 = getChildDevice("${app.id}.${member.id}") // log.debug "Child Device = ${childDevice2}" childDevice?.generatePresenceEvent(isPresent) // log.debug "After generating presence event." } } catch (e) { // eat it } } def initialize() { // TODO: subscribe to attributes, devices, locations, etc. } def haversine(lat1, lon1, lat2, lon2) { def R = 6372.8 // In kilometers def dLat = Math.toRadians(lat2 - lat1) def dLon = Math.toRadians(lon2 - lon1) lat1 = Math.toRadians(lat1) lat2 = Math.toRadians(lat2) def a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2) def c = 2 * Math.asin(Math.sqrt(a)) def d = R * c return(d) } def placeEventHandler() { log.info "Life360 placeEventHandler: params=$params, settings.place=$settings.place" // the POST to this end-point will look like: // POST http://test.com/webhook?circleId=XXXX&placeId=XXXX&userId=XXXX&direction=arrive def circleId = params?.circleId def placeId = params?.placeId def userId = params?.userId def direction = params?.direction def timestamp = params?.timestamp if (placeId == settings.place) { def presenceState = (direction=="in") def externalId = "${app.id}.${userId}" // find the appropriate child device based on my app id and the device network id def deviceWrapper = getChildDevice("${externalId}") // invoke the generatePresenceEvent method on the child device if (deviceWrapper) { deviceWrapper.generatePresenceEvent(presenceState) log.debug "Life360 event raised on child device: ${externalId}" } else { log.warn "Life360 couldn't find child device associated with inbound Life360 event." } } }