/**
* Copyright 2024 Lyle Pakula
*
* 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.
*
* OwnTracks Application Controller
*
* Connects OwnTracks push events to virtual presence drivers. This integration is using the HTTP method from OwnTracks (not MQTT)
*
* Documentation: https://owntracks.org/booklet/
* App: https://github.com/owntracks/android/
* Recorder: https://github.com/owntracks/recorder
*
* Author: Lyle Pakula (lpakula)
*
* Changelog:
* Version Date Changes
* 1.6.4 2024-01-07 - Fixed location option defaults not being displayed. Push the hubitat location to the region list for each mobile user. Added instructions for thumbnail, card and recorder installation.
* 1.6.5 2024-01-07 - Added secondary hub link.
* 1.6.6 2024-01-07 - Fixed secondary hub link.
* 1.6.7 2024-01-08 - Fixed WiFI SSID check that was giving improper "present" when away.
* 1.6.8 2024-01-09 - Prevent extra Android diagnostic fields from being sent to mobile devices that do not support them.
* 1.6.9 2024-01-10 - Cleaned up trackerID sent to map. Removed default hubitat location due to overlap and confusion.
* 1.6.10 2024-01-11 - Removed the -Delete- name from deleted regions which was preventing iOS from deleting. Send the users own location/user card back to them so their thumbnail displays on the map. Fixed iOS crash when receiving invalid data.
* 1.6.11 2024-01-12 - Added a ability to enable each user to see their own image card on the map. NOTE: iOS users will see themselves twice. Added a delete region from Hubitat only setting. Added how-to information to the respective sections. Added member status block.
* 1.6.12 2024-01-13 - Removed the ability to enable each user to see their own image card on the map due to stability issues. Added user selectable warning time to mark stale location reports on the Members Status table.
* - Added location report to the Member status table. Added ability to check the pin location of regions on Google Maps.
* 1.6.13 2024-01-14 - Fixed exception with first time configure of the app. Disabled the 'restart mobile app' due to the OwnTracks Android 2.4.x not starting the ping service after the remote restart. Added the ability to have the mobile app send a high accuracy location on the next report. Added an auto-request high accuracy location for stale Android locations.
* 1.6.14 2024-01-15 - Refactored the layout to make it simpler to install/navigate. Added the ability to reset the app back to the recommended defaults. Added ability to request a higher accuracy location on a ping/manual location (Android Only).
* 1.6.15 2024-01-16 - Added support for driver to retrieve local file URL. Fixed issue when home place was deleted. Provided quick selection to switch locator priority on main screen.
* 1.6.16 2024-01-17 - Fixed issue where assigning home would get cleared.
* 1.6.18 2024-01-17 - Changed home to use timestamp to allow name change. NOTE: breaking change -- home must be re-selected from the list. Added an automatic +follow region for iOS transition tracking.
* 1.6.19 2024-01-18 - Ignore incoming +follow regions from users. Changed the +follow region to match the locatorInterval setting.
* 1.6.20 2024-01-19 - Fixed a fail to install crash from the +follow maintenance.
* 1.6.21 2024-01-20 - Fixed issue where it was impossible to edit a different region after selecting one. Added the ability to have private members to not receive member updates or regions. Added note to edit regions for iOS devices. Added the ability to reset location and display to default. Home location cleanup.
* 1.6.22 2024-01-21 - Updated the add/edit/delete flow. Add a banner to the member status table and delete screen for regions pending deletion.
* 1.6.23 2024-01-22 - Add a red information banner to delete old +follow regions if the locater interval changed. Fixed issue where a home region mismatch would be displayed when a user left home.
* 1.6.24 2024-01-23 - Expose the member delete button to eliminate confusion.
* 1.6.25 2024-01-24 - Removed nag warning about home region mismatch.
* 1.6.26 2024-01-26 - Added direct links to the file manager and logs in the setup screens. Added reverse geocode address support.
* 1.6.27 2024-01-28 - Fixed error when configuring the geocode provider for the first time.
* 1.6.28 2024-01-28 - Added 6-decimal place rounding to geocode lat/lon.
* 1.6.29 2024-01-29 - Store the users past address, and re-use that instead of a geocode lookup if their current coordinates are within 10m of that location.
* 1.6.30 2024-01-29 - Fixed typo.
* 1.6.31 2024-01-29 - Prevent exceptions when converting units if a null was passed.
* 1.6.32 2024-01-30 - Updated member attributes before address lookup to prevent errors. Added a warning to Member Status if no home place is defined.
* 1.7.0 2024-01-30 - Moved street address logic to app.
* 1.7.1 2024-01-31 - Fixed issue where geocode location would get stuck and never request a new address. Added enter/leave transition notification.
* 1.7.2 2024-02-01 - Moved the notification selection box to the main screen. Fix issue where Geoapify geocodes added leading spaces to fields.
* 1.7.3 2024-02-02 - Pass distance from home directly to driver for better logging.
* 1.7.4 2024-02-03 - Changed the notification message. Moved notification control to app.
* 1.7.5 2024-02-03 - Remove the place from the full address.
* 1.7.6 2024-02-04 - Allow device name prefix to be changed.
* 1.7.7 2024-02-04 - Fixed error on some hubs with the new prefix change.
* 1.7.8 2024-02-04 - Removed dynamic prefix display in the settings.
* 1.7.9 2024-02-04 - Updated OwnTracks Frontend instructions.
* 1.7.10 2024-02-05 - Updated the disabled member warning instructions in the logs. Changed the starting zoom level of the region maps to show house level.
* 1.7.11 2024-02-07 - Recorder configuration URL no longer requires the /pub, and will automatically be corrected in the setting. Added common member driver for friends location tile. Added setting to select WiFi SSID keep radius.
* 1.7.13 2024-02-08 - Fixed null exceptions on update to 1.7.11.
* 1.7.14 2024-02-08 - Addressed migration issues. Change the "high accuracy location message" to debug.
* 1.7.15 2024-02-08 - Only update the device prefix if one is defined.
* 1.7.16 2024-02-08 - Add error protection on device prefix change.
* 1.7.17 2024-02-09 - Changed the device name creation to work on all hub versions. Only create member devices once the user has been enabled.
* 1.7.18 2024-02-09 - Allow changing of the arrived/left notifications.
* 1.7.19 2024-02-10 - Updated logging. Removed the request high accuracy location selection box due to it being redundant.
* 1.7.20 2024-02-10 - Mobile app location settings failed to switch units to imperial if required.
* 1.7.21 2024-02-10 - Mobile app location settings failed to switch units to imperial when reset to defaults.
* 1.7.22 2024-02-11 - Mobile app location settings in imperial mode would pull from the wrong units.
* 1.7.23 2024-02-19 - Increased the wifi SSID distance check selector to allow larger distances.
* 1.7.24 2024-02-21 - Added direct device links to the member table.
* 1.7.25 2024-02-25 - Only add geocode locations to region list if there is no current region list.
* 1.7.26 2024-02-26 - Changed layout to collapse menu items for cleaner look. Added Family map using Google Maps API. Added Google Maps API to region creation to allow for radius' to be viewed.
* 1.7.27 2024-03-03 - Minor changes to screen layout. Created html links for direct member tile access.
* 1.7.28 2024-03-05 - Added dynamic support for cloud recorder URL. Hide recorder cloud links when not using https.
* 1.7.29 2024-03-07 - Added an info box when a member is selected in Google maps. Fixed secondary hubs not receiving region updates. Added a send regions to secondary button.
* 1.7.30 2024-03-15 - Updated Google family map to have info windows when a user is clicked, and tracking ability. Added a configuration map when the Google Maps API key is entered. Fixed exception when no recorder URL is present.
* 1.7.31 2024-03-16 - Fixed exception when configure regions was selected with no Google Maps API key.
* 1.7.32 2024-03-18 - Moved region and address selectors directly to the config map.
* 1.7.33 2024-03-19 - Added a service member to allow for secondary hub region transfers.
* 1.7.34 2024-03-21 - Fixed dashboard tiles not automatically updating.
* 1.7.35 2024-03-23 - Refactored Google Friends map to dynamically update.
* 1.7.36 2024-03-24 - Fixed Google Friends map info box.
* 1.7.37 2024-03-24 - Shuffled menu tabs and text to make the flow more intuitive.
* 1.7.38 2024-03-25 - Updated Recorder instructions to include notes about using Google maps and reverse geocode keys.
* 1.7.39 2024-03-28 - Fixed issue with secondary hub link.
* 1.7.40 2024-03-31 - Presence and member tiles get regenerated automatically on change.
* 1.7.41 2024-04-06 - Detect the incoming phone OS and prevent +follow regions from being sent to Android.
* 1.7.42 2024-04-07 - Refactored layout and section labels to group recommend vs optional configurations.
* 1.7.43 2024-04-07 - Changed cloud/local URL sourcing. Fixed Google Family map local URL not displaying members.
* 1.7.44 2024-04-09 - Changed collapsible sections to retain past state.
* 1.7.45 2024-04-15 - Added per region notification granularity. Fixed issue where notifications were only sent if they were set for leave.
* 1.7.46 2024-04-17 - Fixed notifications not working if the device prefix was not blank.
* 1.7.47 2024-04-20 - Changed name displayed in notifications.
* 1.7.48 2024-04-22 - Fixed lat/lon rounding when adding a place. Added support for Android 2.5.x. Fixed issues with region types that was preventing iOS from updating regions.
* 1.7.49 2024-04-24 - Rolled back region migration.
* 1.7.50 2024-04-24 - Fixed region migration.
* 1.7.51 2024-04-27 - Added API key validation to the setup screen. Added Member command API links.
* 1.7.52 2024-04-28 - Regions are now deleted from mobile before sending new ones to eliminate duplicate region names.
* 1.7.53 2024-05-02 - Fixed issue where transition messages was assigning null to speed.
* 1.7.54 2024-05-03 - Prevent the clear waypoints command on 2.4.x.
* 1.7.55 2024-05-04 - Removed support for 2.4.17 forked version.
* 1.7.56 2024-05-04 - Cloud links for Recorder were being displayed when they should not have.
* 1.7.57 2024-05-11 - Fixed higher accuracy reporting wasn't happening after the 2.5.x migration changes. Fixed an error if a user notification was saved, with no selected regions.
* 1.7.58 2024-05-20 - When using the dynamic region config map, creating more than one region at a time would result in duplicates. Testing the map API key with no members would result in an exception and not display the map.
* 1.7.59 2024-07-02 - Support locatorPriority in 2.5.x.
* 1.7.60 2024-07-03 - When trackerID was changed to two characters, the thumbnail image was not displayed. Fixed markers on Google Family Map.
* 1.7.61 2024-07-03 - If no thumbnails are configured, Google Family Map displays a random color on each members marker.
* 1.7.62 2024-07-06 - Added ability to change the member and region pin colors on the maps.
* 1.7.62 2024-07-06 - Added ability to change the member and region pin colors on the maps.
* 1.7.63 2024-07-11 - Google Family Map accuracy and speed was not being converted to imperial.
* 1.7.64 2024-07-13 - Configuration map was not converting displayed radius back to feet on a save.
* 1.7.65 2024-07-19 - Removed specialized support for Android 2.4.x.
* 1.7.66 2024-07-30 - Added selectable member glyph colors. Added member history to the Google Family Map.
* 1.7.67 2024-07-31 - Fixed exception when exiting the app before history was created.
* 1.7.68 2024-07-31 - Added history radius size adjustment.
* 1.7.69 2024-08-04 - Split the thumbnail and history sync to fix cloud data limitation. Changed history dot fading scheme. Prevent map from panning to selected member when history is open.
* 1.7.70 2024-08-06 - Added connecting lines to history with directional arrows. Fixed history point zoom.
* 1.7.71 2024-08-07 - Added scaling to history lines and directional arrows.
* 1.7.72 2024-08-08 - Added increased past history stored at a slower recording interval. Added slider to disable cloud web links.
* 1.7.73 2024-08-10 - Fixed exception with long history if the app was not opened after the updated.
* 1.7.74 2024-08-10 - Fixed course over ground. Fixed exception on new install without previous history.
* 1.7.75 2024-08-10 - Fixed exception on new install without previous history.
* 1.7.76 2024-08-10 - Fixed exception on new install without previous history. Calculates speed if returned speed was 0. Added directional bearing to Google Map.
* 1.7.77 2024-08-11 - Calculates bearing if returned bearing was 0. Dynamically change the speed icon on Google Map based on speed.
* 1.7.78 2024-08-11 - Bearing calculation was inverted.
* 1.7.79 2024-08-18 - Reduced saved address to street address only. Added trip markers to history. Don't save locations with repeated 0 speed or similar bearing. Fixed speed calculations if phone returned 0 speed.
* 1.7.80 2024-08-18 - Added trip odometer.
* 1.7.81 2024-08-18 - Added trip stats to history points.
* 1.7.82 2024-08-20 - Improved trip stats display. Give full trip stats when a trip line is selected. Disable auto zoom/centering when the map is panned or a history point is opened.
* 1.7.83 2024-08-21 - Disable auto zoom/centering when the map is panned or a history point is opened and member is being tracked.
* 1.7.84 2024-08-24 - Re-worked the zoom/auto zoom controls. Set the minimum history speed limit to <2KPH to reduce noisy location points. Prevent calculating speed on rapidly arriving locations.
* 1.7.85 2024-08-25 - Selecting a trip will bring it into focus.
* 1.7.86 2024-08-25 - Selecting trips when all member trips are visible will bring it into focus.
* 1.7.87 2024-08-25 - Fixed exception in trip numbering when member has no history.
* 1.7.88 2024-08-26 - Fixed exception if a member was deleted and past settings were not cleared.
* 1.7.89 2024-08-31 - Refactored zoom and history selection to Google maps. Added a user configurable distance for the auto-zoom in Google maps. Added stale member notifications.
* 1.7.90 2024-09-02 - Added member deactivation to clear the mobile URL and waypoints. Prevent location updates over 5-minutes old from triggering member presence.
* 1.7.91 2024-09-08 - Added member friend groups.
* 1.7.92 2024-09-14 - When a member info box was open on Google maps, it wouldn't automatically refresh. Add more descriptive app permission warnings to the info box.
* 1.7.93 2024-09-18 - Members were not getting sorted based on last location time. Fixed Google maps member order to display the last reported member and member in focus on top.
* 1.7.94 2024-09-19 - Recreates missing member devices should they be deleted from the Hubitat device menu and not the app.
* 1.8.0 2024-09-23 - Member status now indicates configurations that will impact location performance. Fix issue where history compression was not properly removing markers at direction transitions. Google Friends map will auto-update when the main app updates.
* 1.8.1 2024-09-24 - Member status would inaccurately indicate a permission error for iOS phones.
* 1.8.2 2024-10-12 - Return last member locations in a JSON message when the mobile app setup URL is requested. Allow high power mode to be disabled when in a region.
* 1.8.4 2024-10-13 - Added missing "members" in JSON.
* 1.8.5 2024-10-26 - Cleanup migration. Fixed issue if thumbnails were enabled, but no image files were loaded in the hub.
* 1.8.6 2024-11-03 - Added radius around the Google Friends Map member pin that scales based on their location accuracy.
* 1.8.7 2024-12-10 - Changed map and geocode limits to match upcoming Google changes.
* 1.8.8 2024-12-20 - Fixed location debug logging.
* 1.8.9 2024-12-21 - Changed app to single threaded.
* 1.8.10 2025-01-08 - Allow all new regions to be added to member notifications if enabled. Fixed issue where the last notification region/device couldn't be deselected.
* 1.8.11 2025-01-25 - Fixed issue creating a new region.
* 1.8.12 2025-04-13 - Rephrased the member group reset button.
* 1.8.13 2025-05-01 - Fixed issue where passing a member name to the Google Family Map needed to be in lowercase.
* 1.8.14 2025-05-02 - Added a Google Maps setting to use the last followed member when the Google Map reloads.
* 1.8.15 2025-05-16 - Added a member drawer to the bottom of the Google Family Map. Clicking on a thumbnail will follow that user.
* 1.8.17 2025-05-18 - Add the ability to scale the thumbnail size Google Family Map member drawer.
* 1.8.18 2025-05-19 - Changed the drawer behavior to allow a single click/tap to open/close vs dragging. Increased width up to 500 pixels.
* 1.8.19 2025-05-23 - Added scalers to the map zoom in mobile portrait mode and the drawer member details.
* 1.8.20 2025-06-01 - Fixed the zoom issues and removed the mobile portrait mode zoom for the Google Family map. Fixed race condition when the thumbnails were loading on the family map. Fixed issue where new users were not being added to the default group.
* 1.8.21 2025-06-07 - Google Family Map: Improved zooming on members and member drawer. Selecting a member row in the drawer selects that member. Address issue that could lead to thumbnails not being displayed. Added zoom option for smart displays (Nest, Amazon).
* 1.8.22 2025-07-02 - Added member battery level to each history point.
* 1.8.23 2025-08-08 - Removed past cleanup that removed drawer scaling.
* 1.8.24 2025-09-01 - Added Android setting to ignore incoming network locations if a high accuracy location was received recently.
* 1.8.25 2025-09-03 - Reformat the mobile app version sent to the driver for better readability.
* 1.8.26 2025-09-05 - Fix to address the different incoming HTTP header value from Android.
* 1.8.27 2025-11-25 - Changed to dynamic tile URLs to fix tiles not working when web links were disabled.
* 1.8.28 2025-11-26 - Fixed local Google maps links not working.
*/
import groovy.transform.Field
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovy.json.JsonBuilder
import java.text.SimpleDateFormat
def appVersion() { return "1.8.27" }
@Field static final Map BATTERY_STATUS = [ "0": "Unknown", "1": "Unplugged", "2": "Charging", "3": "Full" ]
@Field static final Map DATA_CONNECTION = [ "w": "WiFi", "m": "Mobile", "o": "Offline" ]
@Field static final Map TRIGGER_TYPE = [ "p": "Ping", "c": "Region", "r": "Report Location", "u": "Manual", "b": "Beacon", "t": "Timer", "v": "Monitoring", "l": "Region" ]
@Field static final Map TOPIC_FORMAT = [ 0: "topicSource", 1: "userName", 2: "deviceID", 3: "eventType" ]
@Field static final Map LOCATOR_PRIORITY = [ "NoPower": "NO_POWER (best accuracy with zero power consumption)", "LowPower": "LOW_POWER (city level accuracy)", "BalancedPowerAccuracy": "BALANCED_POWER (block level accuracy based on Wifi/Cell)", "HighAccuracy": "HIGH_POWER (most accurate accuracy based on GPS)" ]
@Field static final Map DYNAMIC_INTERVALS = [ "pegLocatorFastestIntervalToInterval": false, "locatorPriority": "HighAccuracy" ]
//@Field static final Map MONITORING_MODES = [ 0: "Manual (user triggered events)", 1: "Significant (standard tracking using Wifi/Cell)", 2: "Move (permanent tracking using GPS)" ]
@Field static final Map MONITORING_MODES = [ 1: "Significant (standard tracking using Wifi/Cell)", 2: "Move (permanent tracking using GPS)" ]
@Field static final Map IOS_PLUS_FOLLOW = [ "rad":50, "tst":1700000000, "_type":"waypoint", "lon":0.0, "lat":0.0, "desc":"+follow" ]
@Field static final Map GEOCODE_PROVIDERS = [ 0: "Disabled", 1: "Google", 2: "Geoapify", 3: "Opencage" ]
@Field static final Map GEOCODE_ADDRESS = [ 1: "https://maps.googleapis.com/maps/api/geocode/json", 2: "https://api.geoapify.com/v1/geocode/", 3: "https://api.opencagedata.com/geocode/v1/json" ]
@Field static final Map GEOCODE_REQUEST = [ 1: "?address=", 2: "search?text=", 3: "?q=" ]
@Field static final Map REVERSE_GEOCODE_REQUEST_LAT = [ 1: "?latlng=", 2: "reverse?lat=", 3: "?q=" ]
@Field static final Map REVERSE_GEOCODE_REQUEST_LON = [ 1: ",", 2: "&lon=", 3: "," ]
@Field static final Map GEOCODE_KEY = [ 1: "&key=", 2: "&format=json&apiKey=", 3: "&key=" ]
@Field static final Map ADDRESS_JSON = [ 1: "formatted_address", 2: "formatted", 3: "formatted" ]
@Field static final Map GEOCODE_USAGE_COUNTER = [ 1: "googleUsage", 2: "geoapifyUsage", 3: "opencageUsage" ]
@Field static final Map GEOCODE_QUOTA = [ 1: 10000, 2: 3000, 3: 2500 ]
@Field static final Map GEOCODE_QUOTA_INTERVAL_DAILY = [ 1: false, 2: true, 3: true ]
@Field static final Map GEOCODE_API_KEY_LINK = [ 1: "Sign up for a Google API Key", 2: "Sign up for a Geoapify API Key", 3: "Sign up for a Opencage API Key" ]
@Field static final List URL_SOURCE = [ "[cloud.hubitat.com]", "[local.com]" ]
@Field static final Map COLLECT_PLACES = [ "desc": 0, "desc_tst": 1, "map" : 2 ]
// Main defaults
@Field String HUBITAT_CLOUD_URL = "cloud.hubitat.com"
@Field String DEFAULT_APP_THEME_COLOR = "#191970"
@Field String DEFAULT_MEMBER_PIN_COLOR = "MidnightBlue" // "#191970" - "MidnightBlue"
@Field String DEFAULT_MEMBER_GLYPH_COLOR = "Purple" // "#800080" - "Brown"
@Field String DEFAULT_REGION_PIN_COLOR = "FireBrick" // "#b22222" - "FireBrick"
@Field String DEFAULT_REGION_NEW_PIN_COLOR = "red" // "#ff0000" - "Red"
@Field String DEFAULT_REGION_NEW_GLYPH_COLOR = "black" // "#000000" - "Black"
@Field String DEFAULT_REGION_HOME_GLYPH_COLOR = "DarkSlateGrey" // "#2f4f4f" - "DarkSlateGrey"
@Field String DEFAULT_REGION_GLYPH_COLOR = "Maroon" // "#800000" - "Maroon"
@Field Number DEFAULT_memberAccuracyRadiusOpacity = 1.0
@Field Number DEFAULT_memberHistoryLength = 60
@Field Number DEFAULT_maxMemberHistoryLength = 60
@Field Number DEFAULT_memberHistoryScale = 1.0
@Field Number DEFAULT_memberHistoryRepeat = 300
@Field Number DEFAULT_memberDrawerScale = 1.0
@Field Number DEFAULT_smartDisplayScale = 1.0
@Field Boolean DEFAULT_useZoomWhenMembersAreClose = false
@Field Boolean DEFAULT_displayAllMembersHistory = false
@Field Boolean DEFAULT_removeMemberMarkersWithSameBearing = true
@Field Number DEFAULT_memberMarkerBearingDifferenceDegrees = 10
@Field Number DEFAULT_memberTripIdleMarkerTime = 15
@Field Number DEFAULT_memberBoundsRadius = 100
@Field Number memberMaximumLocationAgeMinutes = 5
@Field Number memberHistoryMinimumSpeed = 3
@Field Number memberVelocityMinimumTimeDifference = 5
@Field String memberBeginMarker = "b"
@Field String memberMiddleMarker = "m"
@Field String memberEndMarker = "e"
@Field Number GOOGLE_MAP_API_QUOTA = 10000
@Field String GOOGLE_MAP_API_KEY_LINK = "Sign up for a Google API Key"
@Field String RECORDER_PUBLISH_FOLDER = "/pub"
@Field String MQTT_TOPIC_PREFIX = "owntracks"
@Field Number INVALID_COORDINATE = 999
@Field String COMMON_CHILDNAME = "OwnTracks"
@Field String ANDROID_USER_AGENT = "Android"
@Field String DEFAULT_CHILDPREFIX = "OwnTracks - "
@Field Number DEFAULT_RADIUS = 75
@Field Number DEFAULT_regionHighAccuracyRadius = 750
@Field Number DEFAULT_wifiPresenceKeepRadius = 750
@Field Boolean DEFAULT_imperialUnits = false
@Field Boolean DEFAULT_disableCloudLinks = false
@Field Boolean DEFAULT_regionHighAccuracyRadiusHomeOnly = true
@Field Boolean DEFAULT_warnOnDisabledMember = true
@Field Boolean DEFAULT_warnOnMemberSettings = false
@Field Number DEFAULT_warnOnNoUpdateHours = 12
@Field Number DEFAULT_staleLocationWatchdogInterval = 900
@Field Boolean DEFAULT_highAccuracyOnPing = true
@Field Boolean DEFAULT_highPowerMode = true
@Field Number DEFAULT_discardNetworkLocationThresholdSeconds = 3
@Field Boolean DEFAULT_lowPowerModeInRegion = false
@Field Number DEFAULT_googleMapsZoom = 0
@Field String DEFAULT_googleMapsMember = "null"
@Field Boolean DEFAULT_useLastGoogleFriendsMapMember = false
@Field Boolean DEFAULT_descriptionTextOutput = true
@Field Boolean DEFAULT_debugOutput = false
@Field Number DEFAULT_debugResetHours = 1
@Field Number DEFAULT_geocodeProvider = 0
@Field Boolean DEFAULT_geocodeFreeOnly = true
@Field Number DEFAULT_geocodeLookupHysteresis = 0.010
@Field Boolean DEFAULT_mapFreeOnly = true
@Field Boolean DEFAULT_useCustomNotificationMessage = false
@Field String DEFAULT_notificationMessage = "NAME EVENT REGION at TIME"
@Field Boolean DEFAULT_addNewRegionsToMemberNotificationList = false
@Field Boolean DEFAULT_manualDeleteBehavior = false
@Field Number DEFAULT_globalGroupNumber = 0
@Field String DEFAULT_globalGroupName = "Default"
@Field String DEFAULT_groupNames = "Group "
@Field Number DEFAULT_maxGroups = 5
// Mobile app location defaults
@Field Number DEFAULT_monitoring = 1
@Field String DEFAULT_locatorPriority = "BalancedPowerAccuracy"
@Field Number DEFAULT_moveModeLocatorInterval = 30
@Field Number DEFAULT_locatorDisplacement = 50
@Field Number DEFAULT_locatorInterval = 60
@Field Number DEFAULT_ignoreInaccurateLocations = 150
@Field Number DEFAULT_ignoreStaleLocations = 7
@Field Number DEFAULT_ping = 30
@Field Boolean DEFAULT_pegLocatorFastestIntervalToInterval = true
// Mobile app display defaults
@Field Boolean DEFAULT_imageCards = false
@Field Boolean DEFAULT_replaceTIDwithUsername = true
@Field Boolean DEFAULT_notificationEvents = true
@Field Boolean DEFAULT_extendedData = true
@Field Boolean DEFAULT_enableMapRotation = true
@Field Boolean DEFAULT_showRegionsOnMap = true
@Field Boolean DEFAULT_notificationLocation = false
@Field Boolean DEFAULT_notificationGeocoderErrors = false
definition(
name: "OwnTracks",
namespace: "lpakula",
author: "Lyle Pakula",
description: "OwnTracks app connects your OwnTracks mobile app to Hubitat Elevation for virtual presence triggers",
importUrl: "https://raw.githubusercontent.com/wir3z/hubitat/main/owntracks-hubitat/OwnTracks%20App.groovy",
category: "",
iconUrl: "",
iconX2Url: "",
oauth: [displayName: "OwnTracks", displayLink: "https://owntracks.org/"],
singleInstance: true,
singleThreaded: true,
)
preferences {
page(name: "mainPage")
page(name: "configureHubApp")
page(name: "installationInstructions")
page(name: "thumbnailCreation")
page(name: "configureNotifications")
page(name: "configureRecorder")
page(name: "configureSecondaryHub")
page(name: "recorderInstallationInstructions")
page(name: "advancedHub")
page(name: "advancedLocation")
page(name: "advancedDisplay")
page(name: "configureRegions")
page(name: "configureGroups")
page(name: "addRegions")
page(name: "editRegions")
page(name: "deleteRegions")
page(name: "deleteMembers")
page(name: "resetDefaults")
}
def mainPage() {
// clear the setting fields
clearSettingFields()
app.removeSetting("regionToCheck")
// if we selected a user to retrieve their regions, set the flag so the table updates
updateGetRegion()
// initialize all fields if they are undefined
initialize(false)
def oauthStatus = ""
//enable OAuth in the app settings or this call will fail
try{
if (!state.accessToken) {
createAccessToken()
}
}
catch (e) {
oauthStatus = "Edit Apps Code -> OwnTracks. Select 'oAUTH' in the top right and use defaults to enable oAUTH to continue."
logError(oauthStatus)
}
// clear the http result
dynamicPage(name: "mainPage", title: "", install: true, uninstall: true) {
section(getFormat("title", "OwnTracks Version ${appVersion()}")) {
}
// if we didn't get a token, display the error and stop
if (oauthStatus != "") {
section("
${oauthStatus}
") {}
} else if (state.installed != true) {
section("
Select 'Done' to finsh the initial app installation and then re-select the OwnTracks app to finish configuration.
') + 'Select family member(s) to remain private. Locations and regions will NOT be shared with other members or the Recorder. Their Hubitat device will only display presence information.
', options: (state.members ? state.members.name.sort() : []), submitOnChange: true
href(title: "Configure Stale Member and Region Arrived/Departed Notifications", description: "", style: "page", page: "configureNotifications")
if (enabledMembers) {
enabledMembers.each { name ->
member = state.members.find {it.name==name}
// cancel any deactivation
member?.remove("deactivate")
}
}
}
input name: "sectionLinks", type: "button", title: getSectionTitle(state.show.links, "Dashboard Web Links"), submitOnChange: true, style: getSectionStyle()
if (state.show.links) {
paragraph ("Direct dashboard links for use in a web browser.")
input name: "disableCloudLinks", type: "bool", title: "Disable cloud links", defaultValue: DEFAULT_disableCloudLinks, submitOnChange: true
URL_SOURCE.each{ source->
if ((source != URL_SOURCE[0]) || (disableCloudLinks != true)) {
paragraph ((source == URL_SOURCE[0] ? "
Cloud Links
" : "
Local Links
"))
if (googleMapsAPIKey) {
paragraph ("Google family map:${getAttributeURL(source, "googlemap")}" + "&member=" + "")
paragraph ("Region configuration map:${getAttributeURL(source, "configmap")}")
}
if (state.members) {
urlList = ""
state.members.each { member->
urlList += "${member.name}: ${getAttributeURL(source, "membermap/${member.name.toLowerCase()}")}"
}
paragraph ("Member location map:${urlList}")
}
if (state.members) {
urlList = ""
state.members.each { member->
urlList += "${member.name}: ${getAttributeURL(source, "memberpresence/${member.name.toLowerCase()}")}"
}
paragraph ("Member Presence:${urlList}")
}
if (recorderURL) {
// only display the recorder links if it's a local URL or if it's https (required for the cloud link)
if ((source != URL_SOURCE[0]) || isHTTPsURL(getRecorderURL())) {
paragraph ("OwnTracks Recorder family map:${getAttributeURL(source, "recordermap")}")
if (state.members) {
urlList = ""
state.members.each { member->
urlList += "${member.name}: ${getAttributeURL(source, "memberpastlocations/${member.name.toLowerCase()}")}"
}
paragraph ("OwnTracks Recorder member past locations:${urlList}")
}
}
}
}
}
}
input name: "sectionCommands", type: "button", title: getSectionTitle(state.show.commands, "Member Command API Links"), submitOnChange: true, style: getSectionStyle()
if (state.show.commands) {
paragraph ("Member API command links for advanced integrations and virtual switch controls.")
URL_SOURCE.each{ source->
paragraph ((source == URL_SOURCE[0] ? "
Cloud Links
" : "
Local Links
"))
if (state.members) {
urlList = ""
state.members.each { member->
urlList += "${member.name}"
urlList += " 'On': ${getAttributeURL(source, "membercmd/${member.name.toLowerCase()}/on")}"
urlList += " 'Off': ${getAttributeURL(source, "membercmd/${member.name.toLowerCase()}/off")}"
urlList += " 'Arrived': ${getAttributeURL(source, "membercmd/${member.name.toLowerCase()}/arrived")}"
urlList += " 'Departed': ${getAttributeURL(source, "membercmd/${member.name.toLowerCase()}/departed")}"
}
paragraph ("Member On/Off/Arrived/Departed:${urlList}")
}
}
}
input name: "sectionOptional", type: "button", title: getSectionTitle(state.show.optional, "Optional Features - Thumbnails, Recorder, Secondary Hub"), submitOnChange: true, style: getSectionStyle()
if (state.show.optional) {
href(title: "Enabling User Thumbnails", description: "", style: "page", page: "thumbnailCreation")
href(title: "Enable OwnTracks Recorder", description: "", style: "page", page: "configureRecorder")
href(title: "Link Secondary Hub", description: "", style: "page", page: "configureSecondaryHub")
}
input name: "sectionAdvanced", type: "button", title: getSectionTitle(state.show.advanced, "Advanced Settings - Hub and Mobile"), submitOnChange: true, style: getSectionStyle()
if (state.show.advanced) {
paragraph("The default settings provide the best balance of accuracy/power. To view or modify advanced settings, select the items below.")
href(title: "Hub App Settings", description: "", style: "page", page: "advancedHub")
href(title: "Mobile App Location Settings", description: "", style: "page", page: "advancedLocation")
href(title: "Mobile App Display Settings", description: "", style: "page", page: "advancedDisplay")
}
input name: "sectionMaintenance", type: "button", title: getSectionTitle(state.show.maintenance, "Maintenance - Sync Member Settings, Reset to Defaults, Deactivate and Delete Members"), submitOnChange: true, style: getSectionStyle()
if (state.show.maintenance) {
input "syncMobileSettings", "enum", multiple: true, title:"Select family member(s) to update location, display and region settings on the next location update. The user will be registered to receive this update once 'Done' is pressed, below, and this list will be automatically cleared.", options: (enabledMembers ? enabledMembers.sort() : enabledMembers)
href(title: "Recommended Default Settings", description: "", style: "page", page: "resetDefaults")
href(title: "Delete or Deactivate Family Members", description: "", style: "page", page: "deleteMembers")
}
input name: "sectionLogging", type: "button", title: getSectionTitle(state.show.logging, "Logging"), submitOnChange: true, style: getSectionStyle()
if (state.show.logging) {
input name: "descriptionTextOutput", type: "bool", title: "Enable Description Text logging", defaultValue: DEFAULT_descriptionTextOutput
input name: "debugOutput", type: "bool", title: "Enable Debug Logging", defaultValue: DEFAULT_debugOutput
input name: "debugResetHours", type: "number", title: "Turn off debug logging after this many hours (1..24)", range: "1..24", defaultValue: DEFAULT_debugResetHours
}
}
}
}
}
def getLocatorPriority(inRegion) {
if (highPowerMode && !inRegion) {
return(DYNAMIC_INTERVALS.locatorPriority)
} else {
return(DEFAULT_locatorPriority)
}
}
def configureHubApp() {
return dynamicPage(name: "configureHubApp", title: "", nextPage: "mainPage") {
section(getFormat("box", "Configure Hubitat App")) {
// deal with changes if the imperial/metric slider was changed
initializeHub(false)
}
section() {
input name: "sectionHubsettings", type: "button", title: getSectionTitle(state.show.hubsettings, "Hubitat Settings - WiFi Settings, Units, Device Prefix"), submitOnChange: true, style: getSectionStyle()
if (state.show.hubsettings) {
input "homeSSID", "string", title:"Enter your 'Home' WiFi SSID(s), separated by commas. Used to prevent devices from being 'non-present' if currently connected to these WiFi access point(s).", defaultValue: ""
input name: "wifiPresenceKeepRadius", type: "enum", title: "SSID will only be used for presence detection when a member is within this radius from home, Recommended=${displayMFtVal(DEFAULT_wifiPresenceKeepRadius)}", defaultValue: "${DEFAULT_wifiPresenceKeepRadius}", options: (imperialUnits ? [0:'disabled',250:'820 ft',500:'1640 ft',750:'2461 ft',2000:'1.2 mi',5000:'3.1 mi',10000:'6.2 mi'] : [0:'disabled',250:'250 m',500:'500 m',750:'750 m',2000:'2 km',5000:'5 km',10000:'10 km'])
input name: "imperialUnits", type: "bool", title: "Display imperial units instead of metric units", defaultValue: DEFAULT_imperialUnits, submitOnChange: true
input name: "deviceNamePrefix", type: "string", title: "Prefix to be added to each member's device name. For example, member 'Bob' with a prefix of '${DEFAULT_CHILDPREFIX}' will have a device name of '${DEFAULT_CHILDPREFIX}Bob'. Member device name will be updated once the Hubibit app is exited. Enter a space to have no prefix in front of the member name.", defaultValue: DEFAULT_CHILDPREFIX, submitOnChange: true, required: true
}
input name: "sectionGeocode", type: "button", title: getSectionTitle(state.show.geocode, "Geocode API Settings - Converts latitude/longitude to address"), submitOnChange: true, style: getSectionStyle()
if (state.show.geocode) {
input name: "geocodeProvider", type: "enum", title: "Select the optional geocode provider for address lookups. Allows location latitude/longitude to be displayed as physical address.", description: "Enter", defaultValue: DEFAULT_geocodeProvider, options: GEOCODE_PROVIDERS, submitOnChange: true
if (geocodeProvider != "0") {
paragraph ("Google provides the best accuracy, but offers the least amount of free locations - Google usage quota is reset MONTHLY vs DAILY for the other providers.")
String provider = GEOCODE_USAGE_COUNTER[geocodeProvider?.toInteger()]
usageCounter = state."$provider"
input name: "geocodeFreeOnly", type: "bool", title: "Prevent geocode lookups once free quota has been exhausted. Current usage: ${usageCounter}/${GEOCODE_QUOTA[geocodeProvider?.toInteger()]} per ${(GEOCODE_QUOTA_INTERVAL_DAILY[geocodeProvider?.toInteger()] ? "day" : "month")}.", defaultValue: DEFAULT_geocodeFreeOnly
paragraph (GEOCODE_API_KEY_LINK[geocodeProvider?.toInteger()] + (geocodeProvider?.toInteger() == 1 ? " -- 'Geocoding API' must be enabled under API's & Services. Use API restrictions and select Geocoding API." : ""))
input name: "geocodeAPIKey_$geocodeProvider", type: "string", title: "Geocode API key for address lookups:", submitOnChange: true
reverseGeocodeTest = reverseGeocode(37.422331,-122.0843455)
paragraph ("Geocode API key check: ${(reverseGeocodeTest ? "
PASSED - $reverseGeocodeTest
" : "
FAILED
")}")
}
}
input name: "sectionMap", type: "button", title: getSectionTitle(state.show.map, "Google Map API Settings - Creates a combined family map and adds radius bubbles on the 'Region' 'Add/Edit/Delete' page maps"), submitOnChange: true, style: getSectionStyle()
if (state.show.map) {
paragraph ("
The Google Family Map dashboard URL provides detailed location and trip history for all members. It requires signing up for a free Google Maps Javascript API key (see below).
" +
"
Interacting with the map
" +
"
Selecting a member marker
" +
" 1. The map will automatically zoom and center on all family members when opened.\r" +
" 2. Clicking a member marker will change tracking to that member. The map will auto-center based on their incoming locations to ensure they are always in frame.\r" +
" 3. The info box will display the last location information. If the Android app is configured with non-optimal settings for operation, the following warnings will be listed depending on the issue:\r" +
" a. 'App battery usage: Optimized/Restricted.' Change to 'Unrestricted' for optimal operation.\r" +
" b. 'Permissions: App can pause.' Disable the slider to prevent Android from pausing the app if it has not be used in a while.\r" +
" c. 'Location permission: Not allowed all the time.' Change to 'Allow all the time' and 'Use precise location'.\r" +
" 3. Clicking on the map will release the member and auto-zoom to fit all members.\r" +
"
Selecting member history
" +
" 1. When a member is selected, their past trip history is displayed fading from dark (newest) to light (oldest).\r" +
" 2. Selecting a history point will display information about that trip at that point in time, hide other trips and pause auto-centering.\r" +
" 3. Selecting a history line will display information for the entire trip, hide other trips and pause auto-centering.\r" +
" 4. While the info window is open, clicking anywhere on the map will display all trips for that member. Closing the info window will resume auto-centering.\r" +
" 5. Trips use numbered based on how new they are in the history. Trip #1 is the latest trip.\r" +
" 6. Trips use three different markers:\r" +
" a. Hollow circle with thick border: Start location for the trip.\r" +
" b. Solid circle: Intermediate location in the trip.\r" +
" a. Hollow circle with thin border: End location for the trip.\r" +
"
Bottom banner
" +
" 1. The bottom banner displays the last date/time that the map received an updated member location.\r" +
" 2. If a member was selected, 'Following: ' will be displayed.\r" +
" 3. If the map was dragged or history point or line was selected, the automatic pan and zoom is paused and 'Auto-Centering Paused' will be displayed.\r" +
" 4. Clicking anywhere on the map will resume auto-centering when the next incoming location is received.\r\r"
)
paragraph ("
Configuring the Map
")
paragraph ("If user thumbnails have not been added to Hubitat, follow the instructions for 'Enabling User Thumbnail Instructions' to allow images to be displayed on map pins:")
href(title: "Enabling User Thumbnails", description: "", style: "page", page: "thumbnailCreation")
input name: "mapFreeOnly", type: "bool", title: "Prevent generating maps once free quota has been exhausted. Current usage: ${state.mapApiUsage}/${GOOGLE_MAP_API_QUOTA} per month.", defaultValue: DEFAULT_mapFreeOnly
paragraph (GOOGLE_MAP_API_KEY_LINK + " -- 'Maps JavaScript API' must be enabled under API's & Services. Use API restrictions and select Maps JavaScript API.")
input name: "googleMapsAPIKey", type: "string", title: "Google Maps API key for combined family location map and region add/edit/delete pages to display with region radius bubbles:", submitOnChange: true
paragraph ("Test map API key")
input name: "memberBoundsRadius", type: "number", title: "Map will only auto-zoom to fit members within this distance from home (${getLargeUnits()}) (0..${displayKmMiVal(6400).toInteger()}) Recommended=${displayKmMiVal(DEFAULT_memberBoundsRadius).toInteger()}, Show all members=0", range: "0..${displayKmMiVal(6400).toInteger()}", defaultValue: displayKmMiVal(DEFAULT_memberBoundsRadius).toInteger(), submitOnChange: true
input name: "smartDisplayScale", type: "decimal", title: "Scale value for casting to 1024x600 or 1280x800 smart displays. (1.0..1.3), recommended: 1.2:", range: "1.0..1.3", defaultValue: DEFAULT_smartDisplayScale
input name: "useZoomWhenMembersAreClose", type: "bool", title: "Enable to allow auto-zoom to zoom in closer when all member(s) are in the same area.", defaultValue: DEFAULT_useZoomWhenMembersAreClose
paragraph ("
Member History and Pin Colors
")
input name: "memberAccuracyRadiusOpacity", type: "decimal", title: "Opacity value for the member accuracy radius, 0=disabled (0.0..3.0):", range: "0.0..3.0", defaultValue: DEFAULT_memberAccuracyRadiusOpacity
input name: "memberHistoryLength", type: "number", title: "Number of total past member locations to save (0..${DEFAULT_maxMemberHistoryLength}):", range: "0..${DEFAULT_maxMemberHistoryLength}", defaultValue: DEFAULT_memberHistoryLength
input name: "memberTripIdleMarkerTime", type: "number", title: "Time in minutes between adjacent history locations to denote an end of trip (5..60):", range: "5..60", defaultValue: DEFAULT_memberTripIdleMarkerTime
input name: "removeMemberMarkersWithSameBearing", type: "bool", title: "Remove previous history location if member is moving in the same direction.", defaultValue: DEFAULT_removeMemberMarkersWithSameBearing, submitOnChange: true
if (removeMemberMarkersWithSameBearing) {
input name: "memberMarkerBearingDifferenceDegrees", type: "number", title: "Locations with bearings within this number of degrees are removed to reduce history size (0..45):", range: "0..45", defaultValue: DEFAULT_memberMarkerBearingDifferenceDegrees
}
input name: "memberDrawerScale", type: "decimal", title: "Scale value for the member details in the info boxes and map drawer. (1.0..1.3), recommended: 1.2:", range: "1.0..1.3", defaultValue: DEFAULT_memberDrawerScale
input name: "memberHistoryScale", type: "decimal", title: "Scale value for the past member locations (1.0..3.0):", range: "1.0..3.0", defaultValue: DEFAULT_memberHistoryScale
input name: "memberHistoryRepeat", type: "number", title: "Distance between repeat arrows on the history lines. '0' will place a single arrow in the middle of the line (0..1000):", range: "0..1000", defaultValue: DEFAULT_memberHistoryRepeat
input name: "useLastGoogleFriendsMapMember", type: "bool", title: "Save the last followed member between map reloads", defaultValue: DEFAULT_useLastGoogleFriendsMapMember
input name: "displayAllMembersHistory", type: "bool", title: "Enable to display all member(s) history on map. Disable to only display history of selected member on map.", defaultValue: DEFAULT_displayAllMembersHistory
input name: "memberPinColor", type: "string", title: "Member pin color: Enter a HTML color name (MidnightBlue) or a 6-digit HTML color code (#191970):", defaultValue: DEFAULT_MEMBER_PIN_COLOR
input "selectMemberGlyph", "enum", multiple: false, title:"Select family member to change glyph and history color.", options: state.members.name.sort(), submitOnChange: true
// only clear on a change of selected member
if (state.selectMemberGlyph != selectMemberGlyph) {
app.removeSetting("memberGlyphColor")
}
if (selectMemberGlyph) {
state.selectMemberGlyph = selectMemberGlyph
selectedMember = state.members.find {it.name==selectMemberGlyph}
// if we have a defined color, then assign it to the member
if (memberGlyphColor) {
selectedMember.color = memberGlyphColor
}
input name: "memberGlyphColor", type: "string", title: "${selectMemberGlyph} glyph and history color: Enter a HTML color name (Purple) or a 6-digit HTML color code (#800080):", defaultValue: (selectedMember?.color ? selectedMember.color : DEFAULT_MEMBER_GLYPH_COLOR), submitOnChange: true
}
paragraph ("
Region Pin Colors
")
input name: "regionPinColor", type: "string", title: "Region pin color: Enter a HTML color name (DarkRed) or a 6-digit HTML color code (#b22222):", defaultValue: DEFAULT_REGION_PIN_COLOR
input name: "regionGlyphColor", type: "string", title: "Region glyph color: Enter a HTML color name (Maroon) or a 6-digit HTML color code (#800000):", defaultValue: DEFAULT_REGION_GLYPH_COLOR
input name: "regionHomeGlyphColor", type: "string", title: "Region home glyph color: Enter a HTML color name (WhiteSmoke) or a 6-digit HTML color code (#2f4f4f):", defaultValue: DEFAULT_REGION_HOME_GLYPH_COLOR
}
}
}
}
def installationInstructions() {
return dynamicPage(name: "installationInstructions", title: "", nextPage: "mainPage") {
def extUri = fullApiServerUrl().replaceAll("null","webhook?access_token=${state.accessToken}")
section(getFormat("box", "Mobile App Installation Instructions")) {
paragraph ("This integration requires the OwnTracks app to be installed on your mobile device.\r" +
"NOTE: If you reinstall the OwnTracks app on Hubitat, the host URL below will change, and the mobile devices will need to be updated. \r" +
" This integration currently only supports one device per user for presence detection. Linking more than one device will cause unreliable presence detection. \r\n\r\n" +
" Mobile App Configuration\r" +
" 1. Open the OwnTracks app on the mobile device, and configure the following fields. Only the settings below need to be changed; leave the rest as defaults.\r" +
" Android\r" +
" Preferences -> Connection\r" +
" Mode -> HTTP \r" +
" Host -> ${extUri} \r" +
" Identification ->\r" +
" Username -> Name of the user's phone (IE: 'Kevin') \r" +
" Device ID -> Optional extra descriptor (IE: 'Phone'). If using OwnTracks recorder, it would be desirable\r" +
" to keep this device ID common across device changes, since it logs 'username/deviceID'. \r" +
" Preferences -> Advanced\r" +
" Remote commands -> Selected\r" +
" Remote configuration -> Selected\r\n\r\n" +
" iOS\r" +
" Tap (i) top left, and select 'Settings'. Only the settings below need to be changed.\r" +
" Mode -> HTTP \r" +
" DeviceID -> 2-character user initials that will be displayed on your map (IE: 'KT'). If using OwnTracks recorder, it would be desirable to keep this device ID common across device changes, since it logs 'username/deviceID'. \r" +
" UserID -> Name of the user's phone (IE: 'Kevin') \r" +
" URL -> ${extUri} \r" +
" cmd -> Selected\r\n\r\n" +
" 2. Click the up arrow button in the top right of the map to trigger a 'Send Location Now' to register the device with the Hubitat App."
)
}
}
}
def thumbnailCreation() {
return dynamicPage(name: "thumbnailCreation", title: "", nextPage: "mainPage") {
section(getFormat("box", "Enabling User Thumbnails")) {
paragraph ("Creating User Thumbnails for the OwnTracks Mobile App and optional OwnTracks Recorder.\r\n\r\n" +
" 1. Create a thumbnail for the user at a maximum resolution 192x192 pixels in JPG format using your computer.\r" +
" a. 96x96 pixels at 96 DPI create a file size of ~5kB which is optimal.\r" +
" b. Large file sizes can cause location timeouts from the mobile device (HTTP error 504).\r" +
" 2. Name the thumbnail 'MyUser.jpg' where 'MyUser' is the same name as the user name (case sensitive) entered in the mobile app.\r" +
" 3. In Hubitat:\r" +
" a. Navigate to the Hubitat File Manager ('Settings->File Manager').\r" +
" b. Select '+ Choose' and select the 'MyUser.jpg' that was created above.\r" +
" c. Select 'Upload'.\r" +
" d. Repeat for any additional users.\r" +
" 4. In the OwnTracks Hubitat app:\r" +
" a. Select 'Display', and enable the 'Display user thumbnails on the map.' and then 'Done'.\r" +
" b. Select all users in the 'Select family member(s) to update location, display and region settings on the next location update.' box, and then 'Done'.\r" +
" 5. In the OwnTracks Mobile app:\r" +
" a. Select the 'Send Location Now' button, top right of the map screen. User thumbnails should now populate on the mobile app map.\r"
)
input name: "imageCards", type: "bool", title: "Display user thumbnails on the map. Needs to have a 'user.jpg' image of maximum resolution 192x192 pixels uploaded to the 'Settings->File Manager'", defaultValue: DEFAULT_imageCards
}
}
}
def recorderInstallationInstructions() {
return dynamicPage(name: "recorderInstallationInstructions", title: "", nextPage: "configureRecorder") {
section(getFormat("box", "Installing and configuring the OwnTracks Recorder using Docker")) {
paragraph ("NOTE: Instructions assume that Docker has already been installed and is operational. Replace [HOME_PATH] with your installation specific docker path. For OpenSUSE, the [HOME_PATH] is '/var/lib'. Other installations may have a different home path.\r\n\r\n" +
"For the source code and instructions, navigate to OwnTracks Recorder GitHub \r\n\r\n" +
" 1. Install OwnTracks Recorder:\r" +
" a. docker pull owntracks/recorder\r\n\r\n" +
" 2. Configure OwnTracks Recorder:\r" +
" a. docker volume create recorder_store\r" +
" b. docker volume create config\r" +
" c. Copy (or create if non-existant) the 'recorder.conf' file to '/[HOME_PATH]/docker/volumes/config/_data', which contains the following:\r\n\r\n" +
" OTR_STORAGEDIR=\"/[HOME_PATH]/docker/volumes/recorder_store/_data\"\r" +
" OTR_PORT=0\r" +
" OTR_HTTPHOST=\"0.0.0.0\"\r" +
" OTR_HTTPPORT=8083\r" +
" OTR_TOPICS=\"owntracks/#\"\r" +
" OTR_GEOKEY=\"\"\r" +
" OTR_BROWSERAPIKEY=\"\"\r" +
" OTR_SERVERLABEL=\"OwnTracks\"\r\n\r\n" +
" NOTE: Recorder defaults to OpenStreet Maps. To use Google maps, add a Google Maps API key between the quotes for OTR_BROWSERAPIKEY.\r" +
" For reverse Geocode address lookups, add a Google Maps API key between the quotes for OTR_GEOKEY.\r" +
" Select 'Configure Hubitat App' for directons to get API keys: \n"
)
href(title: "Configure Hubitat App", description: "", style: "page", page: "configureHubApp")
paragraph (" d. docker run -d --restart always --name=owntracks -p 8083:8083 -v recorder_store:/store -v config:/config owntracks/recorder\r\n\r\n" +
" 3. The above 'recorder_store' (STORAGEDIR) and 'config' is found here in Docker:\r" +
" a. /[HOME_PATH]/docker/volumes/recorder_store/_data\r" +
" b. /[HOME_PATH]/docker/volumes/config/_data\r\n\r\n" +
" 4. Access the Owntracks Recorder by opening a web broswer and navigating to 'http://[enter.your.recorder.ip]:8083'.\r"
)
}
section(getFormat("box", "Installing and configuring the OwnTracks Frontend (optional UI) using Docker")) {
paragraph ("NOTE: Instructions assume that Docker has already been installed and is operational and the Owntracks Recorder, above, has been installed an configured.\r\n\r\n" +
"For the source code and instructions, navigate to OwnTracks Frontend GitHub \r\n\r\n" +
" 1. Install OwnTracks Recorder:\r" +
" a. docker pull owntracks/frontend\r\n\r\n" +
" 2. Configure OwnTracks Recorder:\r" +
" a. docker run -d --restart always --name=owntracks_ui -p 8082:80 -e SERVER_HOST=[enter.your.recorder.ip] -e SERVER_PORT=8083 owntracks/frontend\r\n\r\n" +
" 3. Access the Owntracks Frontend by opening a web broswer and navigating to 'http://[enter.your.recorder.ip]:8082'.\r"
)
}
section(getFormat("box", "Adding user cards to OwnTracks Recorder")) {
paragraph ("1. If user thumbnails have not been added to Hubitat, follow the instructions for 'Enabling User Thumbnail Instructions' first:")
href(title: "Enabling User Thumbnails", description: "", style: "page", page: "thumbnailCreation")
paragraph ("2. Select the slider to generate the enabled user's JSON card data in the Hubitat logs:")
input name: "generateMemberCardJSON", type: "bool", title: "Create 'trace' outputs for each enabled member in the Hubitat logs. Slider will turn off once complete. ${(imageCards ? "" : "
Thumbnails are disabled. Select 'Enabling User Thumbnails' to allow thumbnail generation.
")}", defaultValue: false, submitOnChange: true
if (generateMemberCardJSON) {
logMemberCardJSON()
app.updateSetting("generateMemberCardJSON",[value: false, type: "bool"])
}
paragraph ("3. In the Hubitat log, look for the 'trace' output that looks like this:\r" +
" For recorder cards, copy the bold JSON text between | |, and save this file to 'STORAGEDIR/cards/myuser/myuser.json' (user name is in lower case): \r" +
" |{\"_type\":\"card\",\"name\":\"MyUser\",\"face\":\"....\",\"tid\":\"MyUser\"}|\r\n\r\n" +
"4. Save the {\"_type\":\"card\",\"name\":\"MyUser\",\"face\":\"....\",\"tid\":\"MyUser\"} to a text file with the name myuser.json (user name is in lower case), as listed in step 3.\r\n\r\n" +
"5. Create the cards folder if it does not exist:\r" +
" /[HOME_PATH]/docker/volumes/recorder_store/_data/cards\r\n\r\n" +
"6. To add user cards, copy card file for each user to the following docker path:\r" +
" /[HOME_PATH]/docker/volumes/recorder_store/_data/cards/myuser/myuser.json\r\n\r\n" +
"7. Alternatively, if you choose to have user/device specific cards, you would name the card 'myuser-mydevice.json' (user/device name is in lower case), and save it in the following docker path:\r" +
" /[HOME_PATH]/docker/volumes/recorder_store/_data/cards/myuser/mydevice/myuser-mydevice.json\r"
)
}
}
}
def configureRecorder() {
return dynamicPage(name: "configureRecorder", title: "", nextPage: "mainPage") {
section(getFormat("box", "Recorder Configuration")) {
paragraph("The OwnTracks Recorder (optional) can be installed for local tracking. For the Recorder dashboard tiles and links to work outside the home network, the recorder must have a secure URL (https) and be secured with a public certificate.")
input name: "recorderURL", type: "text", title: "HTTP URL of the OwnTracks Recorder. It will be in the format 'http://enter.your.recorder.ip:8083', assuming using the default port of 8083. The app will automatically add the '$RECORDER_PUBLISH_FOLDER' path.", defaultValue: ""
input name: "enableRecorder", type: "bool", title: "Enable location updates to be sent to the Recorder URL", defaultValue: false, submitOnChange: true
if (!recorderURL) {
app.updateSetting("enableRecorder",[value: false, type: "bool"])
}
}
section() {
href(title: "Installing OwnTracks Recorder and Configuring User Card Instructions", description: "", style: "page", page: "recorderInstallationInstructions")
}
}
}
def configureSecondaryHub() {
return dynamicPage(name: "configureSecondaryHub", title: "", nextPage: "mainPage") {
section(getFormat("box", "Secondary Hub Configuration")) {
paragraph ("Allows for OwnTracks to daisy chain to multiple hubs.\r" +
"1. On the secondary hub, paste the host/URL from 'Mobile App Installation Instructions -> Mobile App Configuration' below.\r" +
"2. Select the slider to enable mobile updates to be sent to the secondary hub URL as they arrive.\r" +
"3. Additional hubs can be daisy chained by repeating the above on the third hub, and adding it to the second hub.\r"
)
input name: "secondaryHubURL", type: "text", title: "Host URL of the Seconday Hub from the OwnTracks app 'Mobile App Installation Instructions' page.", defaultValue: ""
input name: "enableSecondaryHub", type: "bool", title: "Enable location updates to be sent to the secondary hub URL", defaultValue: false, submitOnChange: true
if (!secondaryHubURL) {
app.updateSetting("enableSecondaryHub",[value: false, type: "bool"])
}
}
}
}
def advancedHub() {
return dynamicPage(name: "advancedHub", title: "", nextPage: "mainPage") {
section(getFormat("box", "Hub App Configuration")) {
if (state.submit) {
appButtonHandler(state.submit)
state.submit = ""
}
input name: "resetHubDefaultsButton", type: "button", title: "Restore Defaults", state: "submit"
input name: "regionHighAccuracyRadius", type: "enum", title: "Enable high accuracy reporting when location is between region radius and this value, Recommended=${displayMFtVal(DEFAULT_regionHighAccuracyRadius)}", defaultValue: "${DEFAULT_regionHighAccuracyRadius}", options: (imperialUnits ? [0:'disabled',250:'820 ft',500:'1640 ft',750:'2461 ft',1000:'3281 ft',1250:'4101 ft',1500:'4921 ft'] : [0:'disabled',250:'250 m',500:'500 m',750:'750 m',1000:'1000 m',1250:'1250 m',1500:'1500 m'])
input name: "regionHighAccuracyRadiusHomeOnly", type: "bool", title: "High accuracy reporting is used for home region only when selected, all regions if not selected", defaultValue: DEFAULT_regionHighAccuracyRadiusHomeOnly
input name: "warnOnNoUpdateHours", type: "number", title: "Highlight members on the 'Member Status' that have not reported a location for this many hours (1..168)", range: "1..168", defaultValue: DEFAULT_warnOnNoUpdateHours
input name: "warnOnDisabledMember", type: "bool", title: "Display a warning in the logs if a family member reports a location but is not enabled", defaultValue: DEFAULT_warnOnDisabledMember
input name: "warnOnMemberSettings", type: "bool", title: "Display a warning in the logs if a family member app settings are not configured for optimal operation", defaultValue: DEFAULT_warnOnMemberSettings
}
}
}
def advancedLocation() {
return dynamicPage(name: "advancedLocation", title: "", nextPage: "mainPage") {
section(getFormat("box", "Mobile App Location Configuration")) {
if (state.submit) {
appButtonHandler(state.submit)
state.submit = ""
}
input name: "resetLocationDefaultsButton", type: "button", title: "Restore Defaults", state: "submit"
input name: "monitoring", type: "enum", title: "Location reporting mode, Recommended=${MONITORING_MODES[DEFAULT_monitoring]}", required: true, options: MONITORING_MODES, defaultValue: DEFAULT_monitoring, submitOnChange: true
if (getAndroidMembers()) {
input name: "ping", type: "number", title: "Device will send a location interval at this heart beat interval (minutes) (15..360), Recommended=${DEFAULT_ping} (Android ONLY)", required: true, range: "15..60", defaultValue: DEFAULT_ping
input name: "highPowerMode", type: "bool", title: "Use GPS for high accuracy locations. NOTE: This will consume slightly more battery but will offer better performance in areas with poor WiFi/Cell coverage. (Android ONLY)", defaultValue: DEFAULT_highPowerMode, submitOnChange: true
// if in high power mode, default to high accuracy pings
if (highPowerMode) {
app.updateSetting("highAccuracyOnPing", [value: true, type: "bool"])
input name: "lowPowerModeInRegion", type: "bool", title: "Turn off high accuracy locations when the member is inside a region to reduce location jitter and power consumption. (Android ONLY)", defaultValue: DEFAULT_lowPowerModeInRegion
} else {
input name: "highAccuracyOnPing", type: "bool", title: "Request a high accuracy location from members on their next location report after a ping update to keep location fresh (Android ONLY)", defaultValue: DEFAULT_highAccuracyOnPing
}
}
input name: "ignoreInaccurateLocations", type: "number", title: "Do not send a location if the accuracy is greater than the given (${getSmallUnits()}) (0..${displayMFtVal(2000)}) Recommended=${displayMFtVal(DEFAULT_ignoreInaccurateLocations)}", required: true, range: "0..${displayMFtVal(2000)}", defaultValue: displayMFtVal(DEFAULT_ignoreInaccurateLocations)
input name: "ignoreStaleLocations", type: "number", title: "Number of days after which location updates from friends are assumed stale and removed (0..7), Recommended=${DEFAULT_ignoreStaleLocations}", required: true, range: "0..7", defaultValue: DEFAULT_ignoreStaleLocations
input name: "pegLocatorFastestIntervalToInterval", type: "bool", title: "Request that the location provider deliver updates no faster than the requested locater interval, Recommended '${DEFAULT_pegLocatorFastestIntervalToInterval}'", defaultValue: DEFAULT_pegLocatorFastestIntervalToInterval
paragraph("
Settings for Significant Monitoring Mode
")
if (getAndroidMembers()) {
input name: "locatorDisplacement", type: "number", title: "How far the device travels (${getSmallUnits()}) before receiving another location update, Recommended=${displayMFtVal(DEFAULT_locatorDisplacement)} This value needs to be less than the minimum configured region radius for automations to trigger. (Android ONLY)", required: true, range: "0..${displayMFtVal(1000)}", defaultValue: displayMFtVal(DEFAULT_locatorDisplacement)
input name: "discardNetworkLocationThresholdSeconds", type: "number", title: "Ignore incoming network locations if the last high accuracy location was received this many seconds ago, Range 0-10 seconds, Recommended=${DEFAULT_discardNetworkLocationThresholdSeconds}. (Android ONLY)", required: true, range: "0..10", defaultValue: DEFAULT_discardNetworkLocationThresholdSeconds
}
input name: "locatorInterval", type: "number", title: "Device will not report location updates faster than this interval (seconds) unless moving. When moving, Android uses this 'locaterInterval/6' or '5-seconds' (whichever is greater, unless 'locaterInterval' is less than 5-seconds, then 'locaterInterval' is used), Recommended=60 Requires the device to move the above distance, otherwise no update is sent.", required: true, range: "0..3600", defaultValue: DEFAULT_locatorInterval, submitOnChange: true
// IE: locatorInterval=0-seconds, then locations every 0-seconds if moved locatorDisplacement meters
// locatorInterval=5-seconds, then locations every 5-seconds if moved locatorDisplacement meters
// locatorInterval=10-seconds, then locations every 5-seconds if moved locatorDisplacement meters
// locatorInterval=15-seconds, then locations every 5-seconds if moved locatorDisplacement meters
// locatorInterval=30-seconds, then locations every 5-seconds if moved locatorDisplacement meters
// locatorInterval=60-seconds, then locations every 10-seconds if moved locatorDisplacement meters
// locatorInterval=120-seconds, then locations every 20-seconds if moved locatorDisplacement meters
// locatorInterval=240-seconds, then locations every 40-seconds if moved locatorDisplacement meters
// assign the app defaults for move monitoring modes - we will use a larger interval in case the user accidentally switches to 'move mode'
paragraph("
Settings for Move Monitoring Mode
")
input name: "moveModeLocatorInterval", type: "number", title: "How often should locations be continuously sent from the device while in 'Move' mode (seconds) (2..3600), Recommended=${DEFAULT_moveModeLocatorInterval}. 'Move' mode will result in higher battery consumption.", required: true, range: "2..3600", defaultValue: DEFAULT_moveModeLocatorInterval
}
setUpdateFlag([ "name":"" ], "updateLocation", true, false)
}
}
def advancedDisplay() {
return dynamicPage(name: "advancedDisplay", title: "", nextPage: "mainPage") {
section(getFormat("box", "Mobile App Display Configuration")) {
if (state.submit) {
appButtonHandler(state.submit)
state.submit = ""
}
input name: "resetDisplayDefaultsButton", type: "button", title: "Restore Defaults", state: "submit"
input name: "replaceTIDwithUsername", type: "bool", title: "Replace the 'TID' (tracker ID) with 'username' for displaying a name on the map and recorder", defaultValue: DEFAULT_replaceTIDwithUsername
input name: "notificationEvents", type: "bool", title: "Notify about received events", defaultValue: DEFAULT_notificationEvents
input name: "extendedData", type: "bool", title: "Include extended data in location reports", defaultValue: DEFAULT_extendedData
input name: "enableMapRotation", type: "bool", title: "Allow the map to be rotated", defaultValue: DEFAULT_enableMapRotation
input name: "showRegionsOnMap", type: "bool", title: "Display the region pins/bubbles on the map", defaultValue: DEFAULT_showRegionsOnMap
input name: "notificationLocation", type: "bool", title: "Show last reported location in ongoing notification banner", defaultValue: DEFAULT_notificationLocation
input name: "notificationGeocoderErrors", type: "bool", title: "Display Geocoder errors in the notification banner", defaultValue: DEFAULT_notificationGeocoderErrors
}
setUpdateFlag([ "name":"" ], "updateDisplay", true, false)
}
}
def configureRegions() {
return dynamicPage(name: "configureRegions", title: "", nextPage: "mainPage") {
section(getFormat("box", "Configure Regions")) {
}
if (isMapAllowed(false)) {
def deviceWrapper = getChildDevice(getCommonChildDNI())
if (deviceWrapper) {
deviceWrapper.generateConfigMapTile()
}
section() {
input name: "sectionRegion", type: "button", title: getSectionTitle(state.show.region, "Region Map Instructions and Delete Behavior"), submitOnChange: true, style: getSectionStyle()
if (state.show.region) {
paragraph ("
Add a Region
" +
"1. Click the map to drop a pin at a desired location.\r" +
"2. Add the region name and radius information.\r" +
"3. Once 'Save' is selected, all enabled members will automatically receive the changes on their next location report.\r" +
"4. Clicking on the map or another region without saving will remove this pin.\r" +
"5. The pin will remain red until it is saved.\r" +
"NOTE: If a Google geocode API has been entered, an input box to allow direct address lookup will be displayed."
)
paragraph ("
Edit a Region
" +
"1. Select the pin to be edited or a region from the selection box.\r" +
"2. Once 'Save' is selected, all enabled members will automatically receive the changes on their next location report.\r"
)
paragraph ("
Assign a Home Region
" +
"1. Select a pin to be 'Home'.\r" +
"2. Select the 'Set Home' button to assign the region.\r" +
"3. New pins must be saved before the 'Set Home' button is visible.\r" +
"4. The 'Home' pin will be larger with a green glyph.\r"
)
paragraph ("
Delete a Region
" +
"1. Select the pin to be deleted.\r" +
"2. Select the 'Delete' button to remove the region from the map.\r" +
"3. NOTE: The actual delete behavior will be based on the operation described below.\r"
)
input name: "manualDeleteBehavior", type: "bool", title: "Manual Delete", defaultValue: DEFAULT_manualDeleteBehavior, submitOnChange: true
paragraph("
Manual Delete: Region Deleted from Hub Only. Requires Region to be Manually Deleted from Mobile
" +
"1. Deleted regions will be deleted from the Hubitat ONLY.\r" +
"2. On each mobile phone, find and remove the region that was deleted.\r"
)
paragraph ("
Automatic Delete: Region Deleted from Hub and Mobile after Location Update
" +
"1. The deleted region will be assigned an invalid lat/lon, but will not be immediately removed.\r" +
"2. The region will remain in the Hubitat until ALL enabled users have sent a location report.\r" +
"3. Once the last user has sent a location report, the region will be deleted from Hubitat.\r"
)
}
}
}
section() {
if (isMapAllowed(false)) {
configMapURL = "${getAttributeURL(URL_SOURCE[0],'configmap')}"
paragraph("")
} else {
clearSettingFields()
paragraph ("Configure a Google Maps API key in 'Additional Hubitat App Settings' -> 'Google Maps Settings' to allow radius bubbles to be displayed around the regions.")
href(title: "Add Regions", description: "", style: "page", page: "addRegions")
href(title: "Edit Regions", description: "", style: "page", page: "editRegions")
href(title: "Delete Regions", description: "", style: "page", page: "deleteRegions")
}
}
// only display if we don't have a Google maps API key, or our quota is expired
if (!isMapAllowed(false)) {
section(getFormat("line", "")) {
input "homePlace", "enum", multiple: false, title:(homePlace ? '
' : '
') + "Select your 'Home' place. ${(homePlace ? "" : "Use 'Configure Regions'->'Add Regions' to create a home location.")}" + '
', options: getNonFollowRegions(COLLECT_PLACES["desc_tst"]), submitOnChange: true
paragraph("")
}
}
checkForHome()
section(getFormat("line", "")) {
input "getMobileRegions", "enum", multiple: true, title:"Hubitat can retrieve regions from a member's OwnTracks mobile device and merge them into the Hubitat region list. Select family member(s) to retrieve their region list on next location update.", options: getEnabledAndNotHiddenMembers()
if (secondaryHubURL && enableSecondaryHub) {
if (state.submit) {
paragraph "${appButtonHandler(state.submit)}"
state.submit = ""
}
input name: "sendRegionsToSecondaryButton", type: "button", title: "Send Region List to Secondary Hub", state: "submit"
input name: "getRegionsFromSecondaryButton", type: "button", title: "Retrieve Region List from Secondary Hub", state: "submit"
}
}
}
}
def configureGroups() {
return dynamicPage(name: "configureGroups", title: "", nextPage: "mainPage") {
section(getFormat("box", "Configure Groups")) {
paragraph ("
Groups are used to create isolated friend 'bubbles' to prevent locations being shared with all members.
" +
"For example,\r" +
" There are two defined groups with 5 members:\r" +
" - Default: A, B, C, D\r" +
" - Group 1: B, E\r" +
" 1. Members A, B, C, D will see each other, but not member E\r" +
" 2. Member B will see members A, B, C, D and E\r" +
" 3. Member E will only see members B and E\r\r" +
"How the app interacts with members in isolated friend groups:\r" +
" 1. Presence detection only occurs for members in the 'Default' group.\r" +
" 2. Regions are only shared with members in the 'Default' group.\r" +
" 3. Only members in the 'Default' group are sent to the OwnTracks Recorder.\r" +
" 4. Member locations are shared with other members in intersecting groups.\r\r" +
"How the Google Friend Map interacts with members in isolated friend groups:\r\n" +
" 1. By default, only members in the 'Default' group are displayed.\r" +
" 2. If a member name is passed in the '&member=' suffix of the URL, they will see all members in the groups that the member is assigned to.\r" +
" 3. In the above example, using the URL suffix of '&member=E' would only show members B and E on the Google map."
)
}
section() {
if (state.submit) {
paragraph "${appButtonHandler(state.submit)}"
state.submit = ""
}
input name: "resetGroupsButton", type: "button", title: "Reset Member Groupings and Custom Names to Defaults", state: "submit"
input "selectGroup", "enum", multiple: false, title: "Select group to edit name or assign members.", options: state.groups.collectEntries{[it.id, it.name]}, submitOnChange: true
if (selectGroup) {
app.updateSetting("selectFamilyMembers",[value:state.members.findAll{it.groups.find{it == selectGroup}}.name.sort(),type:"enum"])
app.updateSetting("groupName",[value:state.groups.find{it.id == selectGroup}.name,type:"text"])
input "selectFamilyMembers", "enum", multiple: true, title:"Select family member(s) to assign to this group.", options: state.members.name.sort()
// prevent editing the default group name
if (selectGroup != "${DEFAULT_globalGroupNumber}") {
input name: "groupName", type: "text", title: "Group Name", required: true
}
input name: "saveGroupButton", type: "button", title: "Save Settings", state: "submit"
}
}
}
}
def configureNotifications() {
return dynamicPage(name: "configureNotifications", title: "", nextPage: "mainPage") {
section(getFormat("box", "Configure Stale Member Notifications")) {
input "notificationStaleList", "capability.notification", title: "Select device(s) to get notifications when members stop reporting locations and go stale.", multiple: true, required: false, offerAll: true, submitOnChange: true
}
section(getFormat("box", "Configure Region Arrived/Departed Notifications")) {
input "notificationList", "capability.notification", title: "Global enable/disable of notification devices. Select per member enter/leave notifications below for these devices.", multiple: true, required: false, offerAll: true, submitOnChange: true
}
section(getFormat("line", "")) {
if (state.submit) {
paragraph "${appButtonHandler(state.submit)}"
state.submit = ""
}
input "selectFamilyMembers", "enum", multiple: false, title:"Select family member to change arrived/departed notifications.", options: state.members.name.sort(), submitOnChange: true
// only clear on a change of selected member
if (state.selectFamilyMembers != selectFamilyMembers) {
app.removeSetting("notificationEnter")
app.removeSetting("notificationEnterRegions")
app.removeSetting("notificationLeave")
app.removeSetting("notificationLeaveRegions")
}
if (selectFamilyMembers) {
input name: "clearNotificationsButton", type: "button", title: "Clear Settings", state: "submit"
state.selectFamilyMembers = selectFamilyMembers
input "notificationEnter", "enum", title: "Select device(s) to get notifications when this member enters selected region(s).", multiple: true, offerAll: true, required: false, options: notificationList.collect{entry -> entry.displayName}, defaultValue: state.members.find {it.name==selectFamilyMembers}?.enterDevices
input "notificationEnterRegions", "enum", multiple: true, offerAll: true, title: "Trigger notifications when this member enters these region(s).", options: getNonFollowRegions(COLLECT_PLACES["desc_tst"]), defaultValue: state.members.find {it.name==selectFamilyMembers}?.enterRegions
input "notificationLeave", "enum", title: "Select device(s) to get notifications when this member leaves selected region(s).", multiple: true, offerAll: true, required: false, options: notificationList.collect{entry -> entry.displayName}, defaultValue: state.members.find {it.name==selectFamilyMembers}?.leaveDevices
input "notificationLeaveRegions", "enum", multiple: true, offerAll: true, title: "Trigger notifications when this member leaves these region(s).", options: getNonFollowRegions(COLLECT_PLACES["desc_tst"]), defaultValue: state.members.find {it.name==selectFamilyMembers}?.leaveRegions
input name: "saveNotificationsButton", type: "button", title: "Save Settings", state: "submit"
}
}
section(getFormat("line", "")) {
input name: "addNewRegionsToMemberNotificationList", type: "bool", title: "Add each new created region to all member notifications", defaultValue: DEFAULT_addNewRegionsToMemberNotificationList
input name: "useCustomNotificationMessage", type: "bool", title: "Use a custom notification message. The default message format is '$DEFAULT_notificationMessage'", defaultValue: DEFAULT_useCustomNotificationMessage, submitOnChange: true
if (useCustomNotificationMessage) {
input name: "notificationMessage", type: "textarea", title: "Enter notification message. Variables are case sensitive.", defaultValue: DEFAULT_notificationMessage, submitOnChange: true
}
}
}
}
def memberInGlobalMemberGroup(member) {
// check if the member is part of the global member group (undefined or zero), or is a secondary hub update
if ((member.name == COMMON_CHILDNAME) || (member?.groups == null) || (member?.groups?.find {it=="${DEFAULT_globalGroupNumber}"})) {
return(true)
} else {
return(false)
}
}
def getMatchingGroupMembersForMember(member) {
groupMembers = []
// find all enabled members that contain the passed in member's group
settings?.enabledMembers.each { enabled->
enabledMember = state.members.find {it.name==enabled}
// if the member is part of the enabled members group, add them to the map
if (!(settings?.privateMembers.find {it==enabled}) && ((member?.groups == null) || (enabledMember?.groups?.intersect(member?.groups)))) {
groupMembers << enabledMember
}
}
return(groupMembers)
}
def getEnabledAndNotHiddenMembers() {
allowedMembers = []
// build a list of enabled and not hidden members, and deal with global membership
settings?.enabledMembers.each { enabled->
enabledMember = state.members.find {it.name==enabled}
if (!(settings?.privateMembers.find {it==enabled}) && memberInGlobalMemberGroup(enabledMember)) {
allowedMembers << enabled
}
}
if (allowedMembers) {
return (allowedMembers.sort())
} else {
return (allowedMembers)
}
}
def getEnabledAndNotHiddenMemberData(memberName) {
member = state.members.find {it.name.toLowerCase()==memberName?.toLowerCase()}
// in case no member name is passed in, only allow global members
if (member == null) {
member = []
member << [ groups:"${DEFAULT_globalGroupNumber}" ]
}
return((getMatchingGroupMembersForMember(member)?.sort { it.lastReportTime }))
}
def getNonFollowRegions(collectRegions) {
allowedRegions = []
// build a list of regions that don't start with '+' to screen off the '+follow' iOS regions
state.places.each { place->
if (place.desc[0] != "+") {
allowedRegions << place
}
}
if (allowedRegions) {
switch (collectRegions) {
case 0:
// collecton of names
return (allowedRegions.desc.sort())
break
case 1:
// collecton of names and timestamp
return (allowedRegions.collectEntries{[it.tst, it.desc]})
break
}
}
// entire map or blank map
return (allowedRegions)
}
def getHomeRegion() {
return ((homePlace ? state.places.find {it.tst==homePlace.toInteger()} : []))
}
def getAttributeURL(urlSource, path) {
// remove the []
urlSource = urlSource.substring(1, (urlSource.length()-1))
// get the cloud or local URL
if (fullApiServerUrl().indexOf(urlSource, 0) >= 0) {
return(fullApiServerUrl().replaceAll("null","${path}?access_token=${state.accessToken}"))
} else {
return(getFullLocalApiServerUrl() + "/${path}" + "?access_token=${state.accessToken}")
}
}
def displayRegionsPendingDelete() {
// get the names of any regions that are pending deletion
pendingDelete = state?.places.findAll{it.lat == INVALID_COORDINATE}.collect{place -> place.desc}
if (pendingDelete) {
paragraph "
${pendingDelete} pending deletion once all members report a location update.
"
}
}
def displayMissingHomePlace() {
// display an error if the home place is missing
if (!homePlace) {
paragraph "
'Home' place not set. Click 'Installation and Configuration' -> 'Configure Regions' to select or add a home location.
"
}
}
def addRegions() {
return dynamicPage(name: "addRegions", title: "", nextPage: "configureRegions") {
section(getFormat("box", "Add a Region")) {
if (state.submit) {
paragraph "${appButtonHandler(state.submit)}"
state.submit = ""
}
paragraph ("1. Add the region to be information.\r" +
"2. Once 'Save' is selected, all enabled members will automatically receive the changes on their next location report.\r"
)
if (geocodeProvider == "0") {
paragraph ("Configure a geocode provider in 'Additional Hubitat App Settings' -> 'Geocode Settings' to enable address to latitude/longitude lookup.")
} else {
input "regionAddress", "text", title: "Enter address to populate the latitude/longitude. Confirm the location is correct using the map below.", submitOnChange: true
if (regionAddress) {
(addressLat, addressLon) = geocode(regionAddress)
app.updateSetting("regionLat",[value:addressLat,type:"double"])
app.updateSetting("regionLon",[value:addressLon,type:"double"])
}
}
}
section(getFormat("line", "")) {
// assign defaults so the map populates properly
if (settings["regionLat"] == null) settings["regionLat"] = location.getLatitude()
if (settings["regionLon"] == null) settings["regionLon"] = location.getLongitude()
//createRegionMap(lat,lon,rad)
paragraph("")
input "regionName", "text", title: "Name", submitOnChange: true
input name: "regionRadius", type: "number", title: "Detection radius (${getSmallUnits()}) (${displayMFtVal(50)}..${displayMFtVal(1000)})", range: "${displayMFtVal(50)}..${displayMFtVal(1000)}", defaultValue: displayMFtVal(DEFAULT_RADIUS), submitOnChange: true
input name: "regionLat", type: "double", title: "Latitude (-90.0..90.0)", range: "-90.0..90.0", defaultValue: location.getLatitude(), submitOnChange: true
input name: "regionLon", type: "double", title: "Longitude (-180.0..180.0)", range: "-180.0..180.0", defaultValue: location.getLongitude(), submitOnChange: true
input name: "addRegionButton", type: "button", title: "Save", state: "submit"
}
}
}
def addRegionToEdit() {
input "regionToEdit", "enum", multiple: false, title:"Select region to edit", options: getNonFollowRegions(COLLECT_PLACES["desc"]), submitOnChange: true
}
def editRegions() {
return dynamicPage(name: "editRegions", title: "", nextPage: "configureRegions") {
section(getFormat("box", "Edit a Region")) {
if (state.submit) {
paragraph "${appButtonHandler(state.submit)}"
state.submit = ""
}
paragraph ("1. Select the region to be edited.\r" +
"2. Once 'Save' is selected, all enabled members will automatically receive the changes on their next location report.\r"
)
addRegionToEdit()
if (regionToEdit) {
// get the place map and assign the current values
def foundPlace = state.places.find {it.desc==regionToEdit}
app.updateSetting("regionName",[value:foundPlace.desc,type:"text"])
if (state.previousRegionName != regionName) {
app.updateSetting("regionRadius",[value:displayMFtVal(foundPlace.rad.toInteger()),type:"number"])
app.updateSetting("regionLat",[value:foundPlace.lat,type:"double"])
app.updateSetting("regionLon",[value:foundPlace.lon,type:"double"])
}
// save the name in so we can retrieve the values should it get changed below
state.previousRegionName = regionName
paragraph("")
input name: "regionName", type: "text", title: "Name", required: true
input name: "regionRadius", type: "number", title: "Detection radius (${getSmallUnits()})", required: true, range: "${displayMFtVal(50)}..${displayMFtVal(1000)}", submitOnChange: true
input name: "regionLat", type: "double", title: "Latitude (-90.0..90.0)", required: true, range: "-90.0..90.0", submitOnChange: true
input name: "regionLon", type: "double", title: "Longitude (-180.0..180.0)", required: true, range: "-180.0..180.0", submitOnChange: true
input name: "editRegionButton", type: "button", title: "Save", state: "submit"
}
}
}
}
def deleteRegions() {
return dynamicPage(name: "deleteRegions", title: "", nextPage: "configureRegions") {
section(getFormat("box", "Delete Region")) {
if (state.submit) {
paragraph "${getFormat("redText", appButtonHandler(state.submit))}"
state.submit = ""
}
displayRegionsPendingDelete()
input "regionName", "enum", multiple: false, title:"Select region to delete", options: getNonFollowRegions(COLLECT_PLACES["desc"]), submitOnChange: true
if (regionName) {
deleteRegion = state.places.find {it.desc==regionName}
paragraph("")
paragraph("
Delete Region from Hub Only - Manually Delete Region from Mobile
" +
"1. Click the 'Delete Region from Hubitat ONLY' button.\r" +
"2. On each mobile phone, find and delete the region selected above.\r"
)
input name: "deleteRegionFromHubButton", type: "button", title: "Delete Region from Hubitat ONLY", state: "submit"
paragraph ("
Automatically Delete Region from Hub and Mobile after Location Update
" +
"1. Selected region will be assigned an invalid lat/lon.\r" +
"2. The region will remain in the list until ALL enabled users have sent a location report.\r" +
"3. Once the last user has sent a location report, the region will be deleted from Hubitat.\r"
)
input name: "deleteRegionFromAllButton", type: "button", title: "Delete Region from Hubitat and Mobile(s)", state: "submit"
}
}
}
}
def sendRegionsToSecondaryHub() {
data = [ "_type":"waypoints", "waypoints":state.places ]
def postParams = [ uri: secondaryHubURL?.trim(), requestContentType: 'application/json', contentType: 'application/json', headers: ["X-limit-u" : COMMON_CHILDNAME, "X-limit-d" : COMMON_CHILDNAME], body : (new JsonBuilder(data)).toPrettyString() ]
asynchttpPost("httpCallbackMethod", postParams)
}
def retrieveRegionsFromSecondaryHub() {
data = sendReportWaypointsRequest([ name:COMMON_CHILDNAME ])
def postParams = [ uri: secondaryHubURL?.trim(), requestContentType: 'application/json', contentType: 'application/json', headers: ["X-limit-u" : COMMON_CHILDNAME, "X-limit-d" : COMMON_CHILDNAME], body : (new JsonBuilder(data)).toPrettyString() ]
asynchttpPost("httpCallbackMethod", postParams)
}
def getDisabledMembers() {
disabledMembers = []
state.members.each { member->
if (!settings?.enabledMembers.find {it==member.name}) {
disabledMembers << member
}
}
return(disabledMembers)
}
def deleteMembers() {
return dynamicPage(name: "deleteMembers", title: "", nextPage: "mainPage") {
section(getFormat("box", "Deactivate Family Member(s)")) {
if (state.submit) {
paragraph "${getFormat("redText", appButtonHandler(state.submit))}"
state.submit = ""
}
input "selectDeactivateFamilyMembers", "enum", multiple: true, title:"Select family member(s) to delete their phone URL and waypoints. Only members that are not enabled are listed.", options: getDisabledMembers()?.name?.sort(), submitOnChange: true
paragraph("NOTE: Selected user(s) will be deactivated on their next location update. They will no longer be able to reach the OwnTracks app without configuration!")
input name: "deactivateMembersButton", type: "button", title: "Deactivate", state: "submit"
}
section(getFormat("box", "Delete Family Member(s)")) {
if (state.submit) {
paragraph "${getFormat("redText", appButtonHandler(state.submit))}"
state.submit = ""
}
input "selectFamilyMembers", "enum", multiple: true, title:"Select family member(s) to delete.", options: state.members.name.sort(), submitOnChange: true
paragraph("NOTE: Selected user(s) will be deleted from the app and their corresponding child device will be removed. Ensure no automations are dependent on their device before proceeding!")
input name: "deleteMembersButton", type: "button", title: "Delete", state: "submit"
}
}
}
def resetDefaults() {
return dynamicPage(name: "resetDefaults", title: "", nextPage: "mainPage") {
section(getFormat("box", "Reset to Recommended Default Settings")) {
if (state.submit) {
paragraph "${getFormat("redText", appButtonHandler(state.submit))}"
state.submit = ""
}
paragraph("Reset Hubitat and Mobile Settings to Recommended Defaults. NOTE: Members, Regions, Recorder and Secondary Hub settings will not be deleted.")
input name: "resetAllDefaultsButton", type: "button", title: "Restore Defaults for All Settings", state: "submit"
}
section(getFormat("line", "")) {
input name: "resetHubDefaultsButton", type: "button", title: "Restore Defaults for 'Additional Hub App Settings'", state: "submit"
input name: "resetLocationDefaultsButton", type: "button", title: "Restore Defaults for 'Mobile App Location Settings'", state: "submit"
input name: "resetDisplayDefaultsButton", type: "button", title: "Restore Defaults for 'Mobile App Display Settings'", state: "submit"
}
}
}
String appButtonHandler(btn) {
def success = false
def updateMember = false
def result = ""
switch (btn) {
case "addRegionButton":
// check if there are any regions selected
if (regionName) {
// check if we are duplicating a region, and delete the name if so
if (state.places.find {it.desc==regionName}) {
result = "Region '${regionName}' already exists."
logWarn(result)
// clear the region name
app.removeSetting("regionName")
} else {
if (!regionName || !regionLat || !regionLon || !regionRadius) {
result = "All fields need to be populated."
logWarn(result)
} else {
// create the waypoint map - NOTE: the app keys off the "tst" field as a unique identifier
currentTst = (now()/1000).toInteger()
def newPlace = [ "_type": "waypoint", "desc": "${regionName}", "lat": regionLat.toDouble().round(6), "lon": regionLon.toDouble().round(6), "rad": convertToMeters(regionRadius), "tst": currentTst ]
// add the new place
state.places << newPlace
result = "Region '${regionName}' has been added."
logDescriptionText(result)
success = true
updateMember = true
// update the member notifications if enabled
addPlaceToMemberNotifications(currentTst)
}
}
}
break
case "editRegionButton":
// find the existing place to update.
def foundPlace = state.places.find {it.desc==state.previousRegionName}
// create the updated waypoint map - NOTE: the app keys off the "tst" field as a unique identifier
def newPlace = [ "_type": "waypoint", "desc": "${regionName}", "lat": regionLat.toDouble().round(6), "lon": regionLon.toDouble().round(6), "rad": convertToMeters(regionRadius), "tst": foundPlace.tst ]
// overwrite the existing place
foundPlace << newPlace
result = "Updating region '${newPlace.desc}'"
logDescriptionText(result)
success = true
updateMember = true
break
case "deleteRegionFromAllButton":
case "deleteRegionFromHubButton":
// check if there are any regions selected
if (regionName) {
// unvalidate all the places that need to be removed
place = state.places.find {it.desc==regionName}
// check if we are deleting our home location
if (homePlace?.toInteger() == place.tst) {
app.removeSetting("homePlace")
}
if (btn == "deleteRegionFromHubButton") {
// remove the place from our current list but don't trigger a member update
deleteIndex = state.places.findIndexOf {it.desc==regionName}
if (deleteIndex >= 0) {
state.places.remove(deleteIndex)
}
updateMember = false
result = "Deleting region '${regionName}' from Hubitat ONLY. Manually remove '${regionName}' from each mobile."
} else {
// invalidate the coordinates to flag it for deletion
place.lat = INVALID_COORDINATE
place.lon = INVALID_COORDINATE
updateMember = true
result = "Deleting region '${regionName}' from Hubitat and each mobile once they ALL report a location."
}
logWarn(result)
success = true
}
break
case "sendRegionsToSecondaryButton":
sendRegionsToSecondaryHub()
result = "Regions sent to secondary hub."
break
case "getRegionsFromSecondaryButton":
retrieveRegionsFromSecondaryHub()
result = "Regions requested from secondary hub."
case "deactivateMembersButton":
if (selectDeactivateFamilyMembers) {
selectDeactivateFamilyMembers.each { name ->
member = state.members.find {it.name==name}
// flag the member for deactivation
member["deactivate"] = 1
}
result = "Deactivating family members '${selectDeactivateFamilyMembers}'"
logWarn(result)
app.removeSetting("selectDeactivateFamilyMembers")
}
break
case "deleteMembersButton":
if (selectFamilyMembers) {
selectFamilyMembers.each { name ->
deleteIndex = state.members.findIndexOf {it.name==name}
def deviceWrapper = getChildDevice(state.members[deleteIndex].id)
try {
deleteChildDevice(deviceWrapper.deviceNetworkId)
} catch(e) {
logDebug("Device for ${name} does not exist.")
}
state.members.remove(deleteIndex)
}
result = "Deleting family members '${selectFamilyMembers}'"
logWarn(result)
app.removeSetting("selectFamilyMembers")
}
break
case "resetGroupsButton":
initializeGroupList(true)
result = "Default grouping and names assigned to groups"
break
case "saveGroupButton":
if (selectGroup) {
group = state.groups.find{it.id == selectGroup}
// update the name
if (groupName?.trim()?.length()) {
if (group.name != groupName?.trim()) {
group.name = groupName?.trim()
}
}
// add/remove the group id from the member groups
state.members.each { member ->
// first remove it
member.groups?.remove(group.id)
// if we have a match add it back
if (selectFamilyMembers.find {it==member.name}) {
member.groups << group.id
}
}
result = "Updated group name and members"
}
break
case "clearNotificationsButton":
if (selectFamilyMembers) {
member = state.members.find {it.name==selectFamilyMembers}
member.enterDevices = null
member.enterRegions = null
member.leaveDevices = null
member.leaveRegions = null
state.selectFamilyMembers = null
result = "Cleared notification settings for family member '${selectFamilyMembers}'"
}
break
case "saveNotificationsButton":
if (selectFamilyMembers) {
member = state.members.find {it.name==selectFamilyMembers}
if (notificationEnter) (member.enterDevices = notificationEnter - "Toggle All On/Off") else member.enterDevices = null
if (notificationEnterRegions) (member.enterRegions = notificationEnterRegions - "Toggle All On/Off") else member.enterRegions = null
if (notificationLeave) (member.leaveDevices = notificationLeave - "Toggle All On/Off") else member.leaveDevices = null
if (notificationLeaveRegions) (member.leaveRegions = notificationLeaveRegions - "Toggle All On/Off") else member.leaveRegions = null
result = "Updated notification settings for family member '${selectFamilyMembers}'"
}
break
case "resetAllDefaultsButton":
initialize(true)
result = "All settings reset to recommended defaults."
logWarn(result)
break
case "resetHubDefaultsButton":
initializeHub(true)
result = "Hub settings reset to recommended defaults."
logWarn(result)
break
case "resetLocationDefaultsButton":
initializeMobileLocation(true)
result = "Mobile location settings reset to recommended defaults."
logWarn(result)
break
case "resetDisplayDefaultsButton":
initializeMobileDisplay(true)
result = "Mobile display settings reset to recommended defaults."
logWarn(result)
break
case "sectionInstall":
state.show.install = state.show.install ? false : true
break
case "sectionLinks":
state.show.links = state.show.links ? false : true
break
case "sectionCommands":
state.show.commands = state.show.commands ? false : true
break
case "sectionOptional":
state.show.optional = state.show.optional ? false : true
break
case "sectionAdvanced":
state.show.advanced = state.show.advanced ? false : true
break
case "sectionMaintenance":
state.show.maintenance = state.show.maintenance ? false : true
break
case "sectionLogging":
state.show.logging = state.show.logging ? false : true
break
case "sectionHubsettings":
state.show.hubsettings = state.show.hubsettings ? false : true
break
case "sectionGeocode":
state.show.geocode = state.show.geocode ? false : true
break
case "sectionMap":
state.show.map = state.show.map ? false : true
break
case "sectionRegion":
state.show.region = state.show.region ? false : true
break
default:
result = ""
logWarn ("Unhandled button: $btn")
break
}
if (success) {
// clear the setting fields
clearSettingFields()
// force an update of all users
setUpdateFlag([ "name":"" ], "updateWaypoints", updateMember, true)
}
return (result)
}
def clearSettingFields() {
// clear the setting fields
app.removeSetting("regionToEdit")
app.removeSetting("regionAddress")
app.removeSetting("regionName")
app.removeSetting("regionRadius")
app.removeSetting("regionLat")
app.removeSetting("regionLon")
app.removeSetting("selectDeactivateFamilyMembers")
app.removeSetting("selectFamilyMembers")
app.removeSetting("notificationEnter")
app.removeSetting("notificationEnterRegions")
app.removeSetting("notificationLeave")
app.removeSetting("notificationLeaveRegions")
app.removeSetting("selectMemberGlyph")
app.removeSetting("memberGlyphColor")
app.removeSetting("selectGroup")
app.removeSetting("groupName")
state.previousRegionName = null
state.selectFamilyMembers = null
state.selectMemberGlyph = null
}
def installed() {
log.info("Installed")
initialize(true)
// clear the flag to indicate we finish installing the app -- we need this to have the map install fully in order to get a fixed URL for the mobile app
state.installed = false
updated()
}
def uninstalled() {
removeChildDevices(getChildDevices())
}
def initialize(forceDefaults) {
// initialize the system states if undefined
if (state.show == null) state.show = [ install: true, links: false, commands: false, optional: false, advanced: false, maintenance: false, logging: false, hubsettings: false, geocode: false, map: false, region: false ]
if (state.accessToken == null) state.accessToken = ""
if (state.members == null) state.members = []
if (state.places == null) state.places = []
if (state.mapApiUsage == null) state.mapApiUsage = 0
if (state.lastGoogleFriendsLocationTime == null) state.lastGoogleFriendsLocationTime = 0
if (state.lastReportTime == null) state.lastReportTime = new SimpleDateFormat("E h:mm a yyyy-MM-dd").format(new Date())
if (state.googleMapsZoom == null) state.googleMapsZoom = DEFAULT_googleMapsZoom
if (state.googleMapsMember == null) state.googleMapsMember = DEFAULT_googleMapsMember
GEOCODE_USAGE_COUNTER.eachWithIndex { entry, index ->
String provider = GEOCODE_USAGE_COUNTER[index+1]
if (state."$provider" == null) {
state."$provider" = 0
}
}
// assign hubitat defaults
if (useLastGoogleFriendsMapMember == null) app.updateSetting("useLastGoogleFriendsMapMember", [value: DEFAULT_useLastGoogleFriendsMapMember, type: "bool"])
if (homeSSID == null) app.updateSetting("homeSSID", [value: "", type: "string"])
if (imperialUnits == null) app.updateSetting("imperialUnits", [value: DEFAULT_imperialUnits, type: "bool"])
if (disableCloudLinks == null) app.updateSetting("disableCloudLinks", [value: DEFAULT_disableCloudLinks, type: "bool"])
if (deviceNamePrefix == null) app.updateSetting("deviceNamePrefix", [value: DEFAULT_CHILDPREFIX, type: "string"])
if (forceDefaults || (imageCards == null)) app.updateSetting("imageCards", [value: DEFAULT_imageCards, type: "bool"])
if (forceDefaults || (highPowerMode == null)) app.updateSetting("highPowerMode", [value: DEFAULT_highPowerMode, type: "bool"])
if (forceDefaults || (lowPowerModeInRegion == null)) app.updateSetting("lowPowerModeInRegion", [value: DEFAULT_lowPowerModeInRegion, type: "bool"])
if (forceDefaults) app.updateSetting("descriptionTextOutput", [value: DEFAULT_descriptionTextOutput, type: "bool"])
if (forceDefaults) app.updateSetting("debugOutput", [value: DEFAULT_debugOutput, type: "bool"])
if (forceDefaults || (debugResetHours == null)) app.updateSetting("debugResetHours", [value: DEFAULT_debugResetHours, type: "number"])
// assign the defaults to the hub settings
initializeHub(forceDefaults)
// assign the defaults to the mobile app location settings
initializeMobileLocation(forceDefaults)
// assign the defaults to the mobile app display settings
initializeMobileDisplay(forceDefaults)
// add the iOS +follow location to allow for transition updates
updatePlusFollow()
}
def initializeHub(forceDefaults) {
if (forceDefaults) {
app.removeSetting("regionHighAccuracyRadius")
app.removeSetting("wifiPresenceKeepRadius")
app.removeSetting("geocodeProvider")
app.removeSetting("selectMemberGlyph")
app.removeSetting("memberGlyphColor")
}
if (forceDefaults || (regionHighAccuracyRadius == null)) app.updateSetting("regionHighAccuracyRadius", [value: DEFAULT_regionHighAccuracyRadius, type: "number"])
if (forceDefaults || (wifiPresenceKeepRadius == null)) app.updateSetting("wifiPresenceKeepRadius", [value: DEFAULT_wifiPresenceKeepRadius, type: "number"])
if (forceDefaults || (regionHighAccuracyRadiusHomeOnly == null)) app.updateSetting("regionHighAccuracyRadiusHomeOnly", [value: DEFAULT_regionHighAccuracyRadiusHomeOnly, type: "bool"])
if (forceDefaults || (warnOnNoUpdateHours == null)) app.updateSetting("warnOnNoUpdateHours", [value: DEFAULT_warnOnNoUpdateHours, type: "number"])
if (forceDefaults || (warnOnDisabledMember == null)) app.updateSetting("warnOnDisabledMember", [value: DEFAULT_warnOnDisabledMember, type: "bool"])
if (forceDefaults || (warnOnMemberSettings == null)) app.updateSetting("warnOnMemberSettings", [value: DEFAULT_warnOnMemberSettings, type: "bool"])
if (forceDefaults || (highAccuracyOnPing == null)) app.updateSetting("highAccuracyOnPing", [value: DEFAULT_highAccuracyOnPing, type: "bool"])
if (forceDefaults || (geocodeProvider == null)) app.updateSetting("geocodeProvider", [value: DEFAULT_geocodeProvider, type: "number"])
if (forceDefaults || (geocodeFreeOnly == null)) app.updateSetting("geocodeFreeOnly", [value: DEFAULT_geocodeFreeOnly, type: "bool"])
if (forceDefaults || (useCustomNotificationMessage == null)) app.updateSetting("useCustomNotificationMessage", [value: DEFAULT_useCustomNotificationMessage, type: "bool"])
if (forceDefaults || (addNewRegionsToMemberNotificationList == null)) app.updateSetting("addNewRegionsToMemberNotificationList", [value: DEFAULT_addNewRegionsToMemberNotificationList, type: "bool"])
if (forceDefaults || (notificationMessage == null)) app.updateSetting("notificationMessage", [value: DEFAULT_notificationMessage, type: "string"])
if (forceDefaults || (mapFreeOnly == null)) app.updateSetting("mapFreeOnly", [value: DEFAULT_mapFreeOnly, type: "bool"])
if (forceDefaults || (manualDeleteBehavior == null)) app.updateSetting("manualDeleteBehavior", [value: DEFAULT_manualDeleteBehavior, type: "bool"])
if (forceDefaults || (memberPinColor == null)) app.updateSetting("memberPinColor", [value: DEFAULT_MEMBER_PIN_COLOR, type: "string"])
if (forceDefaults || (regionPinColor == null)) app.updateSetting("regionPinColor", [value: DEFAULT_REGION_PIN_COLOR, type: "string"])
if (forceDefaults || (regionGlyphColor == null)) app.updateSetting("regionGlyphColor", [value: DEFAULT_REGION_GLYPH_COLOR, type: "string"])
if (forceDefaults || (regionHomeGlyphColor == null)) app.updateSetting("regionHomeGlyphColor", [value: DEFAULT_REGION_HOME_GLYPH_COLOR, type: "string"])
if (forceDefaults || (memberAccuracyRadiusOpacity == null)) app.updateSetting("memberAccuracyRadiusOpacity", [value: DEFAULT_memberAccuracyRadiusOpacity, type: "decimal"])
if (forceDefaults || (memberHistoryLength == null)) app.updateSetting("memberHistoryLength", [value: DEFAULT_memberHistoryLength, type: "number"])
if (forceDefaults || (memberHistoryScale == null)) app.updateSetting("memberHistoryScale", [value: DEFAULT_memberHistoryScale, type: "decimal"])
if (forceDefaults || (memberHistoryRepeat == null)) app.updateSetting("memberHistoryRepeat", [value: DEFAULT_memberHistoryRepeat, type: "number"])
if (forceDefaults || (memberDrawerScale == null)) app.updateSetting("memberDrawerScale", [value: DEFAULT_memberDrawerScale, type: "decimal"])
if (forceDefaults || (smartDisplayScale == null)) app.updateSetting("smartDisplayScale", [value: DEFAULT_smartDisplayScale, type: "decimal"])
if (forceDefaults || (displayAllMembersHistory == null)) app.updateSetting("displayAllMembersHistory", [value: DEFAULT_displayAllMembersHistory, type: "bool"])
if (forceDefaults || (useZoomWhenMembersAreClose == null)) app.updateSetting("useZoomWhenMembersAreClose", [value: DEFAULT_useZoomWhenMembersAreClose, type: "bool"])
if (forceDefaults || (memberTripIdleMarkerTime == null)) app.updateSetting("memberTripIdleMarkerTime", [value: DEFAULT_memberTripIdleMarkerTime, type: "number"])
if (forceDefaults || (memberMarkerBearingDifferenceDegrees == null)) app.updateSetting("memberMarkerBearingDifferenceDegrees", [value: DEFAULT_memberMarkerBearingDifferenceDegrees, type: "number"])
if (forceDefaults || (removeMemberMarkersWithSameBearing == null)) app.updateSetting("removeMemberMarkersWithSameBearing", [value: DEFAULT_removeMemberMarkersWithSameBearing, type: "bool"])
if (forceDefaults || (memberBoundsRadius == null)) app.updateSetting("memberBoundsRadius", [value: DEFAULT_memberBoundsRadius, type: "number"])
if (forceDefaults || (state.memberBoundsRadius == null)) state.memberBoundsRadius = DEFAULT_memberBoundsRadius
// if we are in imperial, convert the distances for displaying
if (forceDefaults || (state.imperialUnitsHub != imperialUnits)) {
state.imperialUnitsHub = imperialUnits
// preload the settings field with the proper units
app.updateSetting("memberBoundsRadius", [value: displayKmMiVal(state.memberBoundsRadius).toInteger(), type: "number"])
}
// convert back to metric if in imperial to send out to the map
state.memberBoundsRadius = convertToKilometers(memberBoundsRadius)
// assign the default groups
initializeGroupList(forceDefaults)
}
def initializeMobileLocation(forceDefaults) {
if (forceDefaults) {
app.removeSetting("monitoring")
}
if (forceDefaults || (monitoring == null)) app.updateSetting("monitoring", [value: DEFAULT_monitoring, type: "number"])
if (forceDefaults || (ignoreInaccurateLocations == null)) app.updateSetting("ignoreInaccurateLocations", [value: DEFAULT_ignoreInaccurateLocations, type: "number"])
if (forceDefaults || (ignoreStaleLocations == null)) app.updateSetting("ignoreStaleLocations", [value: DEFAULT_ignoreStaleLocations, type: "number"])
if (forceDefaults || (ping == null)) app.updateSetting("ping", [value: DEFAULT_ping, type: "number"])
if (forceDefaults || (pegLocatorFastestIntervalToInterval == null)) app.updateSetting("pegLocatorFastestIntervalToInterval", [value: DEFAULT_pegLocatorFastestIntervalToInterval, type: "bool"])
if (forceDefaults || (locatorDisplacement == null)) app.updateSetting("locatorDisplacement", [value: DEFAULT_locatorDisplacement, type: "number"])
if (forceDefaults || (locatorInterval == null)) app.updateSetting("locatorInterval", [value: DEFAULT_locatorInterval, type: "number"])
if (forceDefaults || (moveModeLocatorInterval == null)) app.updateSetting("moveModeLocatorInterval", [value: DEFAULT_moveModeLocatorInterval, type: "number"])
if (forceDefaults || (state.locatorDisplacement == null)) state.locatorDisplacement = DEFAULT_locatorDisplacement
if (forceDefaults || (state.ignoreInaccurateLocations == null)) state.ignoreInaccurateLocations = DEFAULT_ignoreInaccurateLocations
if (forceDefaults || (state.imperialUnits == null)) state.imperialUnits = DEFAULT_imperialUnits
// if we are in imperial, convert the distances for displaying
if (forceDefaults || (state.imperialUnits != imperialUnits)) {
state.imperialUnits = imperialUnits
// preload the settings field with the proper units
app.updateSetting("locatorDisplacement", [value: displayMFtVal(state.locatorDisplacement), type: "number"])
app.updateSetting("ignoreInaccurateLocations", [value: displayMFtVal(state.ignoreInaccurateLocations), type: "number"])
}
// convert back to metric if in imperial to send out to the phone
state.locatorDisplacement = convertToMeters(locatorDisplacement)
state.ignoreInaccurateLocations = convertToMeters(ignoreInaccurateLocations)
}
def initializeMobileDisplay(forceDefaults) {
if (forceDefaults || (replaceTIDwithUsername == null)) app.updateSetting("replaceTIDwithUsername", [value: DEFAULT_replaceTIDwithUsername, type: "bool"])
if (forceDefaults || (notificationEvents == null)) app.updateSetting("notificationEvents", [value: DEFAULT_notificationEvents, type: "bool"])
if (forceDefaults || (extendedData == null)) app.updateSetting("extendedData", [value: DEFAULT_extendedData, type: "bool"])
if (forceDefaults || (enableMapRotation == null)) app.updateSetting("enableMapRotation", [value: DEFAULT_enableMapRotation, type: "bool"])
if (forceDefaults || (showRegionsOnMap == null)) app.updateSetting("showRegionsOnMap", [value: DEFAULT_showRegionsOnMap, type: "bool"])
if (forceDefaults || (notificationLocation == null)) app.updateSetting("notificationLocation", [value: DEFAULT_notificationLocation, type: "bool"])
if (forceDefaults || (notificationGeocoderErrors == null)) app.updateSetting("notificationGeocoderErrors", [value: DEFAULT_notificationGeocoderErrors, type: "bool"])
}
def initializeGroupList(forceDefaults) {
// recreate the default groups
if (forceDefaults || (state?.groups == null)) {
state.groups = []
for (id=0; id
// assign the default group number to all members
member.groups = []
member.groups << "${DEFAULT_globalGroupNumber}"
}
}
}
}
def updatePlusFollow() {
// create the +follow with the time interval prefix
plusFollow = IOS_PLUS_FOLLOW
plusFollow.desc = "+${locatorInterval}follow"
// if the +follow location changed
deletePlace = state.places.find {it.desc[0] == plusFollow.desc[0]}
if (deletePlace?.desc != plusFollow.desc) {
logDescriptionText("Deleting place: ${deletePlace}")
state.places.remove(deletePlace)
// add the new one
addPlace([ "name":"" ], plusFollow, false)
}
}
def updateGetRegion() {
state.members.each { member->
// if we selected member(s) to retrieve their regions
if (settings?.getMobileRegions.find {it==member.name}) {
member.getRegions = true
}
}
app.updateSetting("getMobileRegions",[value:"",type:"enum"])
}
def updated() {
unschedule()
unsubscribe()
logDescriptionText("Updated")
// cleanup up the recorder URL if necessary by removing the trailing /pub or /
formatRecorderURL()
// create the common child if it doesn't exist
createCommonChild()
// create a presence child device for each enabled member - we will need to manually removed children unless the app is uninstalled
settings?.enabledMembers.each { enabledMember->
member = state.members.find {it.name==enabledMember}
// default to false
syncSettings = false
// create the child if it doesn't exist
if ((member.id == null) || (getChildDevice(member.id) == null)) {
createChild(member.name)
// force the update to the new device
syncSettings = true
} else {
// update the child name if the prefix changed
updateChildName(member)
}
// recreate the tiles
getChildDevice(member.id)?.generateTiles()
// if we selected member(s) to update settings
if (settings?.syncMobileSettings.find {it==member.name}) {
syncSettings = true
}
// if the configuration has changed, trigger the member update
if (syncSettings) {
member.updateLocation = true
member.updateDisplay = true
// only global members get waypoint updates
if (memberInGlobalMemberGroup(member)) {
member.updateWaypoints = true
}
}
// remove excessive history events
pruneMemberHistory(member)
}
// clear the settings flags to prevent the configurations from being forced to the display on each entry
app.updateSetting("syncMobileSettings",[value:"",type:"enum"])
// check to see if home was assigned
checkForHome()
// schedule the watchdog to automatically request a high accuracy location fix for stale locations
locationFixWatchdog()
// set the flag to indicate we installed the app
state.installed = true
// clear the debug logging if set
if (debugOutput) {
runIn(debugResetHours*3600, resetLogging)
}
/*
0/2 0 0 * * * *
XXX Every 2 seconds
X during minute zero
X during hour zero
X any day of the month
X every month
X every day of the week
X every year
*/
// get the time zone offset so we can schedule at midnight GMT
def timeZoneOffset = (location.timeZone.rawOffset) / (3600 * 1000)
if (timeZoneOffset < 0) {
timeZoneOffset = 24 + timeZoneOffset
}
schedule("0 0 $timeZoneOffset * * ? *", dailyScheduler)
// refresh the maps nightly
schedule("0 0 0 * * ? *", nightlyMaintenance)
nightlyMaintenance()
removePlaces()
}
def refresh() {
}
def pruneMemberHistory(member) {
if (memberHistoryLength == 0) {
member.history = []
} else {
// first remove the oldest of the history buffer is full
while (member?.history?.size() > (memberHistoryLength != null ? memberHistoryLength : DEFAULT_memberHistoryLength)) {
member.history.remove(0)
}
try {
// calculate the trip numbers and populate the history
tripNumber = 1;
for (i=(member.history.size-1); i>=0; i--) {
// if we have no trip started yet
if ((member.history.mkr[i] == memberBeginMarker) && (i == (member.history.size-1))) {
tripNumber = 0;
}
// increment the trip marker
if (member.history.mkr[i] == memberEndMarker) {
tripNumber++;
}
memberHistory = member.history[i]
memberHistory["tp"] = tripNumber;
}
} catch (e) {
// do nothing -- once we have configured and received enough history points, this will succeed
}
}
}
def childGetWarnOnNonOptimalSettings() {
// return with the log setting
return (warnOnMemberSettings)
}
def getImageURL(memberName) {
if (imageCards) {
return ("http://${location.hubs[0].getDataValue("localIP")}/local/${memberName}.jpg")
} else {
return ("")
}
}
def getEmbeddedImage(memberName) {
def thumbnail = ""
if (imageCards) {
try {
thumbnail = "data:image/png;base64," + downloadHubFile("${memberName}.jpg").encodeBase64().toString()
} catch (e) {
// use the default blank if no thumbnail was found
}
}
return (thumbnail)
}
def getRecorderURL() {
// return with recorder URL
return (settings?.recorderURL)
}
def formatRecorderURL() {
// cleanup up the recorder URL if necessary by removing the trailing /pub or /
properURL = recorderURL?.trim()?.minus(RECORDER_PUBLISH_FOLDER)
if (recorderURL != properURL) {
app.updateSetting("recorderURL",[value: properURL, type: "string"])
}
}
def generateStaleNotification() {
// collect the names of the members that are stale
staleMembers = state?.members.findAll{it.staleReport == true}.collect{member -> member.name}
// send notification to mobile if selected
if (staleMembers && notificationStaleList) {
def date = new Date()
def dateFormat = "hh:mm a yyyy-MM-dd"
SimpleDateFormat newDate = new SimpleDateFormat(dateFormat)
messageToSend = "Members ${staleMembers} have stale locations ${newDate.format(date)}"
notificationStaleList.each { val ->
val.deviceNotification(messageToSend)
}
}
}
def generateTransitionNotification(memberName, transitionEvent, transitionRegion, transitionTime) {
member = state.members.find {it.name==memberName}
place = state.places.find {it.desc==transitionRegion}
if (transitionEvent == "arrived at") {
notificationDevices = member?.enterDevices
notificationDeviceRegion = member?.enterRegions.find {it.toInteger()==place.tst}
} else {
notificationDevices = member?.leaveDevices
notificationDeviceRegion = member?.leaveRegions.find {it.toInteger()==place.tst}
}
if (useCustomNotificationMessage) {
// parse the notification message
messageToSend = notificationMessage
} else {
messageToSend = DEFAULT_notificationMessage
}
// parse the notification message
messageToSend = messageToSend.replace("NAME", "${memberName}")
messageToSend = messageToSend.replace("EVENT", "${transitionEvent}")
messageToSend = messageToSend.replace("REGION", "${transitionRegion}")
messageToSend = messageToSend.replace("TIME", "${transitionTime}")
// send notification to mobile if selected
if (notificationDevices && notificationDeviceRegion) {
notificationList.each { val ->
if (notificationDevices.find {it==val.displayName}) {
val.deviceNotification(messageToSend)
}
}
}
}
def checkForHome() {
if (!homePlace) {
logError("No 'Home' location has been defined. Create a 'Home' region to enable presence detection.")
}
}
def locationFixWatchdog() {
logDebug("Check members for stale locations.")
// update each member with their last report times
checkStaleMembers()
// reschedule the watchdog
runIn(DEFAULT_staleLocationWatchdogInterval, locationFixWatchdog)
}
def checkStaleMembers() {
// loop through all the members
state.members.each { member->
// generate the stale report times
if (member.lastReportTime) {
long lastReportTime = member.lastReportTime.toLong()
member.lastReportDate = new SimpleDateFormat("E h:mm a yyyy-MM-dd").format(new Date(lastReportTime))
// true if no update the selected number of hours
member.staleReport = ((now() - lastReportTime) > (warnOnNoUpdateHours * 3600000))
// number of hours since last report
member.numberHoursReport = ((now() - lastReportTime) / 3600000).toInteger()
} else {
// force a stale report if no time was reported
member.staleReport = true
member.lastReportDate = "None"
member.numberHoursReport = "?"
}
// generate the stale location times
if (member.timeStamp) {
long lastFixTime = member.timeStamp.toLong() * 1000
member.lastFixDate = new SimpleDateFormat("E h:mm a yyyy-MM-dd").format(new Date(lastFixTime))
// true if no update the selected number of hours
member.staleFix = ((now() - lastFixTime) > (warnOnNoUpdateHours * 3600000))
// number of hours since last report
member.numberHoursFix = ((now() - lastFixTime) / 3600000).toInteger()
} else {
// force a stale fix if no time was reported
member.staleFix = true
member.lastFixDate = "None"
member.numberHoursFix = "?"
}
// if auto request location is enabled and the position fix is stale, flag the user
if (member.staleFix) {
member.requestLocation = true
logDebug("${member.name}'s position is stale. Requesting a high accuracy location update.")
}
}
// sort by last report time
state.members?.sort { it.lastReportTime }
}
def displayMemberStatus() {
String tableData = "";
if (state.members) {
tableData += '
'
tableData += '
'
tableData += '
'
tableData += '
'
tableData += '
Member
'
tableData += '
Last Location Report
'
tableData += '
Last Location Fix
'
tableData += '
Update Region
'
tableData += '
Update Configuration
'
tableData += '
Get Regions
'
tableData += '
Request Location
'
tableData += '
'
// update each member with their last report times
checkStaleMembers()
// loop through all the members
state.members.each { member->
// check if member is enabled
memberEnabled = settings?.enabledMembers.find {it==member.name}
deviceWrapper = getChildDevice(member.id)
memberName = "" + member.name + ""
tableData += '
'
// display members in non-optimal configurations
tableData += '
'
tableData += '
'
tableData += '
'
tableData += ((member?.cmd == 0) ? '* OwnTracks app "remote configuration" disabled ' : '')
tableData += ((member?.bo == 1) ? '* Battery usage is set to "optimized" or "restricted" ' : '')
tableData += ((member?.hib == 1) ? '* "Pause app activity if unused" is enabled ' : '')
tableData += (((member?.loc != null) && (member?.loc < 0)) ? '* Location permission is not set to "Allow all the time" and "Use precise location" ' : '')
tableData += ((member?.ps == 1) ? '* Phone in battery saver mode' : '')
tableData += '
'
tableData += '
'
}
tableData += '
'
tableData += '
'
} else {
tableData = "
'Select family member(s) to monitor' to add members.
"
}
paragraph( tableData )
}
def splitTopic(topic) {
// split the topic into source, name, deviceID, eventType (if present)
// TOPIC_FORMAT = [ 0: "topicSource", 1: "userName", 2: "deviceID", 3: "eventType" ]
// [0] = "owntracks"
// [1] = "username"
// [2] = "deviceID"
// [3] = event type: "waypoint", "waypoints", "event". Does not get populated for "location".
return(topic.split("/"))
}
def nightlyMaintenance() {
// runs at midnight to refresh the map contents
createRecorderFriendsLocationTile()
createGoogleFriendsLocationTile()
// loop through all the enabled members
settings?.enabledMembers.each { enabledMember->
member = state.members.find {it.name==enabledMember}
deviceWrapper = getChildDevice(member.id)
deviceWrapper?.generatePastLocationsTile()
}
// check if we need to notify on stale locations
generateStaleNotification()
}
def webhookEventHandler() {
// Get the user/device from the message header
String sourceName = request.headers.'X-limit-u'
String sourceDeviceID = request.headers.'X-limit-d'
// default to an empty payload
result = []
// catch the exception if no message was sent
if (!request.body) {
logError("Username: '${sourceName}' / Device ID: '${sourceDeviceID}' reported no data from the OwnTracks app, aborting.")
} else if (!sourceName || !sourceDeviceID) {
// catch the exception if a webhook comes in without being configured properly
logError("Username: '${sourceName}' / Device ID: '${sourceDeviceID}' not configured in the OwnTracks app - Deactivating unknown user. Ensure the 'Username' and 'Device ID' are set on the OwnTracks mobile app.")
result = sendDeactivateUpdate([ "name":"${sourceDeviceID}" ])
} else {
// strip the [] around these values
sourceName = sourceName.substring(1, (sourceName.length()-1))
sourceDeviceID = sourceDeviceID.substring(1, (sourceDeviceID.length()-1))
// check if this a message from the service device. If not, check for a matching member
if (sourceName == COMMON_CHILDNAME) {
findMember = [ name:COMMON_CHILDNAME, deviceID:COMMON_CHILDNAME, id:getCommonChildDNI() ]
} else {
findMember = state.members.find {it.name==sourceName}
}
data = parseJson(request.body)
logDebug("Received update' from user: '$sourceName', deviceID: '$sourceDeviceID', data: $data")
if (!findMember?.id) {
// add the new user to the list if they don't exist yet. We will use the current time since not all incoming packets have a timestamp
if (findMember == null) {
state.members << [ name:sourceName, deviceID:sourceDeviceID, id:null, groups:["0"], timeStamp:(now()/1000).toInteger(), updateWaypoints:false, updateLocation:false, updateDisplay:false, dynamicLocaterAccuracy:false, getRegions:false, requestLocation:false ]
}
logWarn("User: '${sourceName}' not configured. To enable this member, open the Hubitat OwnTracks app, select '${sourceName}' in 'Select family member(s) to monitor' box and then click 'Done'.")
} else {
// only process events from enabled members, or the service member
if ((settings?.enabledMembers.find {it==sourceName}) || (sourceName == COMMON_CHILDNAME)) {
// Pass the location to a secondary hub if configured
if (secondaryHubURL && enableSecondaryHub) {
def postParams = [ uri: secondaryHubURL?.trim(), requestContentType: 'application/json', contentType: 'application/json', headers: parsePostHeaders(request.headers), body : (new JsonBuilder(data)).toPrettyString() ]
asynchttpPost("httpCallbackMethod", postParams)
}
// update the device ID should it have changed
findMember.deviceID = sourceDeviceID
result = parseMessage(request.headers, data, findMember);
} else {
if (warnOnDisabledMember) {
logWarn("User: '${sourceName}' not enabled. To enable this member, open the Hubitat OwnTracks app, select '${sourceName}' in 'Select family member(s) to monitor' box and then click 'Done'.")
// if the member is flagged for deactivation
if (findMember?.deactivate == 1) {
result = sendDeactivateUpdate(findMember)
}
} else {
logDebug("User: '${sourceName}' not enabled. To enable this member, open the Hubitat OwnTracks app, select '${sourceName}' in 'Select family member(s) to monitor' box and then click 'Done'.")
}
}
}
}
// app requires a non-empty JSON response, or it will display HTTP 500
payload = new JsonBuilder(result).toPrettyString()
return render(contentType: "text/html", data: payload, status: 200)
}
def parseMessage(headers, data, member) {
// default to an empty payload
payload = []
switch (data._type) {
case "location":
case "transition":
// store the last report time for the Google friends map
state.lastReportTime = new SimpleDateFormat("E h:mm a yyyy-MM-dd").format(new Date())
// log the elapsed distance and time
logDistanceTraveledAndElapsedTime(member, data)
updateMemberAttributes(headers, data, member)
// flag the data as private if necessary, but let the raw message pass to the secondary hub to be filtered
data.private = ((settings?.privateMembers.find {it==member.name}) ? true : false)
// calculate how many minutes the incoming location message is from current time
deltaTst = (((member.lastReportTime / 1000) - member?.timeStamp) / 60).toInteger()
// send push event to driver if the incoming location isn't stale
if (deltaTst < memberMaximumLocationAgeMinutes) {
updateDevicePresence(member, data)
} else {
logDescriptionText("Location update for user ${member.name} is ${deltaTst} minutes old. Skipping presence update.")
}
// return with the rest of the users positions and waypoints if pending
payload = sendUpdate(member, data)
// if the country code was not defined, replace with with hub timezone country
if (!data.cc) { data.cc = location.getTimeZone().getID().substring(0, 2).toUpperCase() }
// if we have the OwnTracks recorder configured, and the timestamp is valid, and the user is not marked as private, pass the location data to it
if (recorderURL && enableRecorder && !data.private && memberInGlobalMemberGroup(member)) {
def postParams = [ uri: recorderURL + RECORDER_PUBLISH_FOLDER, requestContentType: 'application/json', contentType: 'application/json', headers: parsePostHeaders(headers), body : (new JsonBuilder(data)).toPrettyString() ]
asynchttpPost("httpCallbackMethod", postParams)
}
break
case "waypoint":
// append/update to the places list for global members only
if (memberInGlobalMemberGroup(member)) {
addPlace(member, data, true)
} else {
logDescriptionText("User ${member.name} is not in the default group. Skipping waypoint update.")
}
break
case "waypoints":
// update the places list for global members only
if (memberInGlobalMemberGroup(member)) {
updatePlaces(member, data)
} else {
logDescriptionText("User ${member.name} is not in the default group. Skipping waypoint update.")
}
break
case "cmd":
// parse the action
switch (data.action) {
case "setWaypoints":
// update the waypoint list from the payload
updatePlaces(member, data.waypoints)
break
case "waypoints":
// send waypoints
payload = sendWaypoints(member)
break
case "reportLocation":
// returns data.topic of the member to request from, and data._id for the unique id
// http version cannot request from members, so this is not implemented
case "setConfiguration":
case "dump": // iOS
case "status": // iOS
case "reportSteps": // iOS
case "clearWaypoints": // iOS
default:
// do nothing
break
}
break
case "status":
// update the member status from the payload
updateStatus(member, data)
break
case "card":
break
default:
logWarn("Unhandled message type: ${data._type}")
break
}
return (payload)
}
def parsePostHeaders(postHeaders) {
def newHeaders = [:]
// loop through each header and remove the surrounding [], and recreate a new header map
postHeaders.each { entry->
String parsedValue = entry.getValue()
newHeaders.put("${entry.getKey()}", "${parsedValue.substring(1, (parsedValue.length()-1))}")
}
return (newHeaders)
}
def httpCallbackMethod(response, data) {
if (response.status == 200) {
logDebug "Posted successfully to OwnTracks URL."
responseData = response?.getJson()
responseHeaders = response?.getHeaders()
if (responseData) {
// parse the response
try {
// for map of maps
for (i=0; i
if (SSID.trim() == deviceID.currentValue("SSID")) {
result = true
}
}
}
return (result)
}
def calcMemberVelocity(member, data) {
try {
travelDistance = haversine(member.latitude.toDouble(), member.longitude.toDouble(),data.lat.toDouble(), data.lon.toDouble())
// calcuate the member odometer
member.odo = (member.odo ? (member.odo + travelDistance).round(1) : travelDistance.round(1))
// if we received a speed -- wifi location updates will return with 0 speed, so ignore those and calculated based on the distance moved
if (data?.vel > 0) {
member.speed = data.vel
} else {
// TODO - if we get a noisy location that jumps, we can get an artifically inflated calculated speed
timeDifference = data.tst - member.timeStamp
// only calculate speed on specific location types, and if the time difference between locations is large enough
if (validLocationType(data.t) && (timeDifference >= memberVelocityMinimumTimeDifference)) {
// calculate the speed between the new and previous location point
member.speed = (travelDistance / ((timeDifference) / 3600)).toInteger()
} else {
// prevent high calculated speed between multiple manual points returned in succession
member.speed = 0
}
data.vel = member.speed
}
// if we received a bearing
if (data?.cog) {
member.bearing = data.cog
} else {
// calculate the bearing between the new and previous location point
member.bearing = angleFromCoordinate(member.latitude.toDouble(), member.longitude.toDouble(),data.lat.toDouble(), data.lon.toDouble()).toInteger()
data.cog = member.bearing
}
} catch (e) {
member.speed = 0
member.bearing = 0
}
}
def getCompassDifference(bearing1, bearing2) {
difference = Math.abs(bearing1 - bearing2)
if (difference > 180) {
return (360 - difference)
} else {
return (difference)
}
}
def removeHistoryPoint(member) {
// calculate the angle between the three points
d12 = haversine(member.latitude.toDouble(), member.longitude.toDouble(),member.history[member.history.size-1].lat.toDouble(), member.history[member.history.size-1].lng.toDouble())
d13 = haversine(member.latitude.toDouble(), member.longitude.toDouble(),member.history[member.history.size-2].lat.toDouble(), member.history[member.history.size-2].lng.toDouble())
d23 = haversine(member.history[member.history.size-1].lat.toDouble(), member.history[member.history.size-1].lng.toDouble(),member.history[member.history.size-2].lat.toDouble(), member.history[member.history.size-2].lng.toDouble())
// check if the angle deviation is within range
if ((180 - Math.toDegrees(Math.acos(((d12*d12) + (d23*d23) - (d13*d13)) / (2*d12*d23)))) <= memberMarkerBearingDifferenceDegrees) {
return (true)
} else {
return (false)
}
}
def getHistoryMarker(member, data) {
// default to the "middle" marker
marker = memberMiddleMarker
try {
historyLength = member.history.size - 1
removeMarker = false
// check if enough time has elapsed between points to denote a trip end
if ((member.timeStamp - member.history.tst[historyLength]) > (60 * memberTripIdleMarkerTime.toInteger())) {
// check if the last marker was a begin marker, if so remove it so it can be replaced with a new begin marker
if (member.history.mkr[historyLength] == memberBeginMarker) {
removeMarker = true
} else {
// change the last marker to "end"
historyPoint = member.history[historyLength]
historyPoint.mkr = memberEndMarker
}
// return "begin" for the next marker
marker = memberBeginMarker
// reset the odometer
member.odo = 0
} else {
// TODO - may need to add a duration check between these samples in case stop and go traffic erases them
// if the last two history points are below minimum velocity
if ((member.speed <= memberHistoryMinimumSpeed) && (member.history.spd[historyLength] <= memberHistoryMinimumSpeed)) {
removeMarker = true
}
// if we are travelling in roughly the same direction - check the last two points so that we do not remove a marker during a direction transition
if (removeMemberMarkersWithSameBearing && removeHistoryPoint(member)) {
removeMarker = true
}
}
// remove the last history point so it can be replaced with the new one
if (removeMarker) {
// replace the marker with the same type as deleted
marker = member.history.mkr[historyLength]
member.history.remove(historyLength)
}
} catch (e) {
}
return(marker)
}
def parseAppVersion(appVersion) {
parsedVersion = splitTopic(appVersion)
if (isAndroidMember(appVersion)) {
def tempString = parsedVersion[2].substring(1)
def parts = [
tempString.substring(0, 1),
tempString.substring(1, 3),
tempString.substring(3, 5),
tempString.substring(5, 8)
]
versionString = "Android: v" + parts.collect { it.toInteger() }.join('.')
} else {
versionString = "iOS: v" + parsedVersion[1].split(' ')[0]
}
return (versionString)
}
def updateMemberAttributes(headers, data, member) {
// round to 6-decimal places
data.lat = data?.lat?.toDouble()?.round(6)
data.lon = data?.lon?.toDouble()?.round(6)
// replace the tracker ID with the member name. NOTE: if the TID is undefined, it will be the last 2-characters of the Device ID
if (replaceTIDwithUsername) {
data.tid = member.name
}
// pre-seed the member lat/lon for the first update address lookup
if (member.latitude == null) member.latitude = data?.lat
if (member.longitude == null) member.longitude = data?.lon
// do a reverse lookup for the address if it doesn't exist, and we have an API enabled
updateAddress(member, data)
// add the street address and regions, if they exist
addStreetAddressAndRegions(data)
// calculate the speed and odometer
calcMemberVelocity(member, data)
// save the position and timestamp so we can push to other users
member.appVersion = parseAppVersion(headers.'User-agent'.toString())
member.lastReportTime = now()
member.latitude = data?.lat
member.longitude = data?.lon
member.timeStamp = data?.tst
member.accuracy = data?.acc
// these are not present in transition messages
if (data?.tid != null) member.trackerID = data.tid
if (data?.batt != null) member.battery = data.batt
if (data?.alt != null) member.altitude = data.alt
if (data?.bs != null) member.bs = data.bs
if (data?.conn != null) member.conn = data.conn
// save the history
if (member?.history == null) {
member.history = []
}
// only save history on valid location types (ignore region and manual types)
if (validLocationType(data.t)) {
// first create the new member location so that the getHistoryMarker can clean up repeating events as necessary
def memberLocation = [ "lat": member.latitude, "lng": member.longitude, "acc": member.accuracy, "cog": member.bearing, "spd": member.speed, "odo": member.odo, "tst": member.timeStamp, "bat": member.battery, "bs": member.bs, "loc": data.streetAddress, "mkr": getHistoryMarker(member, data) ]
try {
// if the history buffer is full
if (member.history.size == memberHistoryLength.toInteger()) {
// first check if the second location is not a middle marker, if so then locations 1 and 3 are begin/end of the oldest trip
if (member.history.mkr[1] == memberMiddleMarker) {
member.history.remove(1)
} else {
// remove that last trip
member.history.remove(0)
member.history.remove(0)
}
}
} catch(e) {
// do nothing -- once we have configured and received enough history points, this will succeed
}
// add to the end of the list
member.history << memberLocation
// remove the oldest of the history buffer if over full
pruneMemberHistory(member)
} else {
logDebug("Skipping history point save for trigger source '${(data.t ? TRIGGER_TYPE[data.t] : "Location")}', member: ${member.name}")
}
}
def updateDevicePresence(member, data) {
// update the presence information for the member
try {
// find the appropriate child device based on app id and the device network id
def deviceWrapper = getChildDevice(member.id)
logDebug("Updating '${(data.event ? "Event $data.event" : (data.t ? TRIGGER_TYPE[data.t] : "Location"))}' presence for member $deviceWrapper")
// check if the user defined a home place
if (homePlace) {
// append the distance from home to the data packet
data.currentDistanceFromHome = getDistanceFromHome(data)
// check if the member is within our home geofence
memberHubHome = (data.currentDistanceFromHome <= ((getHomeRegion().rad.toDouble()) / 1000))
// or the mobile is reporting the member is home
memberMobileHome = (data?.inregions.find {it==getHomeRegion().desc} || ((data?.desc == getHomeRegion().desc) && (data?.event == 'enter')))
// needed for safe migration from 1.7.11
if (wifiPresenceKeepRadius == null) app.updateSetting("wifiPresenceKeepRadius", [value: DEFAULT_wifiPresenceKeepRadius, type: "number"])
// or connected to a listed SSID and within the next geofence
data.memberWiFiHome = (data.currentDistanceFromHome < (wifiPresenceKeepRadius?.toDouble() / 1000)) && isSSIDMatch(homeSSID, deviceWrapper)
// if either the hub or the mobile reports it is home, then make the member present
if (memberHubHome || memberMobileHome || data.memberWiFiHome) {
data.memberAtHome = true
// if the home name isn't present, at it to the regions
addRegionToInregions(getHomeRegion().desc, data)
} else {
data.memberAtHome = false
}
} else {
data.currentDistanceFromHome = 0.0
logWarn("No 'Home' location has been defined. Create a 'Home' region to enable presence detection.")
}
// update the child information
deviceWrapper.generateLocationEvent(member, getHomeRegion().desc, data)
} catch(e) {
logError("updateDevicePresence: Exception for member: ${member.name} $e")
}
}
def addRegionToInregions(place, data) {
// if there was no defined regions, create a blank list
if (!data.inregions) {
data.inregions = []
}
// if the region isn't present, then add it
if (!data.inregions.find {it==place}) {
data.inregions << place
}
}
def addStreetAddressAndRegions(data) {
try {
addressList = data.address?.split(',')
// trim whitespace to allow the parser to work
addressList[0] = addressList[0]?.trim()
addressList[1] = addressList[1]?.trim()
// The address will be:
// place, street address
// street address, city
// lat, lon
// if the first digit of the first entry is not a number, but the second is, then we were returned a place, street adress
if ( !((addressList[0])[0])?.isNumber() && (((addressList[1])[0])?.isNumber() || (addressList.size() > 4)) ) {
// save the place to the region list if we don't already have a region defined
if (!data.inregions) addRegionToInregions(addressList[0], data)
data.streetAddress = addressList[1]
// remove the place from the address
data.address = data.address.substring(data.address.indexOf(",") + 1)?.trim()
} else {
// if the first entry is not a number, then we have a street address
if (!addressList[0]?.isNumber()) {
data.streetAddress = addressList[0]
} else {
// pass through since it is a lat,lon or a format we don't know how to parse
data.streetAddress = data.address
}
}
} catch (e) {
// pass the address through
data.streetAddress = data.address
}
}
def checkRegionConfiguration(member, data) {
// if we configured the high accuracy region and a home has been defined
if (regionHighAccuracyRadius && homePlace) {
def closestWaypointRadius = 0
def closestWaypointDistance = -1
// loop through all the waypoints, and find the one that is closet to the location
state.places.each { waypoint->
// check our distance from the waypoint
distanceToWaypoint = ((haversine(data.lat.toDouble(), data.lon.toDouble(), waypoint.lat.toDouble(), waypoint.lon.toDouble()))*1000).round(0)
// if we are closest, then save that waypoint
if ((closestWaypointDistance == -1) || (distanceToWaypoint < closestWaypointDistance)) {
closestWaypointDistance = distanceToWaypoint.toDouble()
closestWaypointRadius = waypoint.rad.toDouble()
}
}
// check if the member is inside a region and we are in high power mode, and we want to reduce power inside regions
if (highPowerMode && lowPowerModeInRegion) {
currentlyInRegion = (closestWaypointDistance <= closestWaypointRadius)
} else {
currentlyInRegion = false
}
// check if we need to apply this to the home region only then we will overwrite the all regions
if (regionHighAccuracyRadiusHomeOnly) {
// only switch to faster reporting when near home
closestWaypointDistance = (getDistanceFromHome(data)*1000).toDouble()
closestWaypointRadius = getHomeRegion()?.rad.toDouble()
}
// catch the exception if no regions have been defined
if ((closestWaypointDistance == null) || (closestWaypointRadius == null)) {
closestWaypointRadius = 0
closestWaypointDistance = -1
logWarn("Home region is undefined. Run setup to configure the 'Home' location")
}
// check if we are outside our region radius, and within our greater than the radius + regionHighAccuracyRadius
return (createConfiguration(member, currentlyInRegion, ((closestWaypointDistance > closestWaypointRadius) && (closestWaypointDistance < (closestWaypointRadius + regionHighAccuracyRadius.toDouble())))))
}
}
def createConfiguration(member, currentlyInRegion, useDynamicLocaterAccuracy) {
def updateConfiguration = false
// check if we need to force a high accuracy update
if (useDynamicLocaterAccuracy) {
// switch to locatorPriority=high power and pegLocatorFastestIntervalToInterval=false (dynamic interval)
configurationList = [ "_type": "configuration",
"pegLocatorFastestIntervalToInterval": DYNAMIC_INTERVALS.pegLocatorFastestIntervalToInterval,
"locatorPriority": DYNAMIC_INTERVALS.locatorPriority,
]
} else {
// switch to settings. Recommended locatorPriority=balanced power and pegLocatorFastestIntervalToInterval=true (fixed interval)
// if in a region, use balanced power to prevent location jitter
configurationList = [ "_type": "configuration",
"pegLocatorFastestIntervalToInterval": pegLocatorFastestIntervalToInterval,
"locatorPriority": getLocatorPriority(currentlyInRegion),
]
}
if (member?.dynamicLocaterAccuracy != useDynamicLocaterAccuracy) {
// assign the new state
member.dynamicLocaterAccuracy = useDynamicLocaterAccuracy
updateConfiguration = true
}
if (member?.currentlyInRegion != currentlyInRegion) {
// assign the new state
member.currentlyInRegion = currentlyInRegion
updateConfiguration = true
}
if (updateConfiguration) {
// return with the dynamic configuration
return( [ "_type":"cmd","action":"setConfiguration", "configuration": configurationList ] )
} else {
// return nothing
return
}
}
def sendClearURLConfiguration(currentMember) {
logDescriptionText("Clear URL for user ${currentMember.name}")
// remove the URL to prevent the device from calling home
configurationList = [ "_type": "configuration", "url": "" ]
return( [ "_type":"cmd","action":"setConfiguration", "configuration": configurationList ] )
}
private def getDistanceFromHome(data) {
// return distance in kilometers, rounded to 3 decimal places (meters)
distance = 0.0
try {
distance = haversine(data.lat.toDouble(), data.lon.toDouble(), getHomeRegion()?.lat.toDouble(), getHomeRegion()?.lon.toDouble()).round(3)
} catch (e) {
logError("Unable to get distance from home. Confirm a 'Home' region is assigned. Error reported: $e")
}
return (distance)
}
private def logDistanceTraveledAndElapsedTime(member, data) {
// log the elapsed distance and time between location events
try {
logDebug ("${app.label}: Delta between location events, member: ${member.name}, Distance: ${displayMFtVal(haversine(data.lat.toDouble(), data.lon.toDouble(), member.latitude.toDouble(), member.longitude.toDouble()).round(3)*1000)} ${getSmallUnits()}, Time: ${data.tst-member.timeStamp} s")
} catch(e) {
// only gets here on a first time user
}
}
def removePlaces() {
// check if all users have their waypoints updated, and then remove any place with invalidated coordinates
// default to true
def deleteEntries = true
// loop through all the enabled members to see if any have outstanding waypoint updates
settings?.enabledMembers.each { enabledMember->
if (state.members.find {it.name==enabledMember}?.updateWaypoints) {
deleteEntries = false;
}
}
// remove all regions flagged for deletion
if (deleteEntries) {
deleteIndex = state.places.findIndexOf {it.lat == INVALID_COORDINATE}
// remove the place from our current list
if (deleteIndex >= 0) {
state.places.remove(deleteIndex)
}
}
// remove regions from each member's notification list if they no longer exist
state?.members.each { member->
if (member.enterRegions) {
member.enterRegions -= removeDeletedPlaces(member.enterRegions)
}
if (member.leaveRegions) {
member.leaveRegions -= removeDeletedPlaces(member.leaveRegions)
}
}
}
def removeDeletedPlaces(regionList) {
removeList = []
regionList.each { entry ->
if (state.places.find {it.tst==entry.toInteger()} == null) {
// add to the list to be removed
removeList += entry
}
}
return(removeList)
}
def updatePlaces(findMember, data) {
// only add places from non-private members
if (!(settings?.privateMembers.find {it==findMember.name})) {
logDescriptionText("Updating places")
// loop through all the waypoints
data.waypoints.each { waypoint->
addPlace(findMember, waypoint, true)
}
logDebug("Updating places: ${state.places}")
} else {
logDebug("Ignoring waypoints due to private member.")
}
}
def addPlace(findMember, data, verboseAdd) {
// only add places from non-private members
if (!(settings?.privateMembers.find {it==findMember.name})) {
// create a new map removing the MQTT topic
def newPlace = [ "_type": "${data._type}", "desc": "${data.desc}", "lat": data.lat.toDouble().round(6), "lon": data.lon.toDouble().round(6), "rad": data.rad, "tst": data.tst ]
// check if we have an existing place with the same timestamp
place = state.places.find {it.tst==newPlace.tst}
// no changes to existing place, or a member is returing the +follow region
if ((place == newPlace) || (findMember.name && (data?.desc[0] == "+"))) {
if (verboseAdd) {
logDescriptionText("Skipping, no change to place: ${newPlace}")
}
} else {
// change logging depending if the place previously existed
if (place) {
logDescriptionText("${findMember.name} updated place: ${newPlace}")
// overwrite the existing place
place << newPlace
} else {
logDescriptionText("${findMember.name} added place: ${newPlace}")
// add the new place
state.places << newPlace
}
// force the users to get the update place list
setUpdateFlag(findMember, "updateWaypoints", true, true)
// update the member notifications if enabled
addPlaceToMemberNotifications(data.tst)
}
} else {
logDebug("Ignoring waypoint due to private member.")
}
}
def addPlaceToMemberNotifications(tst) {
if (addNewRegionsToMemberNotificationList) {
// add regions from each member's notification list if they allowed
state?.members.each { member->
if (member.enterRegions) (member.enterRegions << tst) else (member.enterRegions = [ tst ])
if (member.leaveRegions) (member.leaveRegions << tst) else (member.leaveRegions = [ tst ])
logDescriptionText("Adding enter/leave notifications for region '${state.places.find {it.tst==tst}.desc}' to member ${member.name}")
}
}
}
def updateStatus(findMember, data) {
findMember.wifi = data?.android?.wifi
findMember.hib = data?.android?.hib
findMember.ps = data?.android?.ps
findMember.bo = data?.android?.bo
findMember.loc = data?.android?.loc
findMember.cmd = (data?.android?.cmd != null ? data?.android?.cmd : 1)
if (findMember.cmd == 0) {
logWarn("'Remote Configuration' not enabled. Open the Owntracks app for ${findMember.name} and select 'Preferences -> Advanced-> Remote configuration'")
}
/*
log.debug (data?.iOS?.deviceSystemName)
log.debug (data?.iOS?.deviceUserInterfaceIdiom)
log.debug (data?.iOS?.localeUsesMetricSystem)
log.debug (data?.iOS?.backgroundRefreshStatus)
log.debug (data?.iOS?.deviceSystemVersion)
log.debug (data?.iOS?.altimeterAuthorizationStatus)
log.debug (data?.iOS?.deviceModel)
log.debug (data?.iOS?.locale)
log.debug (data?.iOS?.version)
log.debug (data?.iOS?.altimeterIsRelativeAltitudeAvailable)
log.debug (data?.iOS?.locationManagerAuthorizationStatus)
log.debug (data?.iOS?.deviceIdentifierForVendor)
*/
logDebug("Updating status: ${findMember.name}")
}
private def setUpdateFlag(currentMember, newSetting, newValue, globalOnly) {
// loop through all the enabled members
settings?.enabledMembers.each { enabledMember->
member = state.members.find {it.name==enabledMember}
// filter out group members if the request is for global members only
if ((globalOnly && memberInGlobalMemberGroup(member)) || !globalOnly) {
// don't set the flag for the member that triggered the update
if (currentMember.name != member.name) {
member."$newSetting" = newValue
logDebug("${newSetting} for user ${member.name}: ${newValue}")
}
}
}
}
private def sendReportLocationRequest(currentMember) {
logDescriptionText("Request location for user ${currentMember.name}")
// Forces the device to get a GPS fix for higher accuracy
return ([ "_type":"cmd","action":"reportLocation" ])
}
private def sendReportWaypointsRequest(currentMember) {
logDescriptionText("Request waypoints for user ${currentMember.name}")
// Requests the waypoints list from the device
return ([ "_type":"cmd","action":"waypoints" ])
}
private def sendClearWaypointsRequest(currentMember) {
logDescriptionText("Clear waypoints for user ${currentMember.name}")
// Clears the waypoints list from the device
return ([ "_type":"cmd","action":"clearWaypoints" ])
}
private def sendReportStatusRequest(currentMember) {
logDescriptionText("Request status for user ${currentMember.name}")
// Requests the status from the device
return ([ "_type":"cmd","action":"status" ])
}
private def sendGetConfigurationRequest(currentMember) {
logDescriptionText("Request configuration for user ${currentMember.name}")
// Requests the configuration from the device
return ([ "_type":"cmd","action":"dump" ])
}
private def sendMemberPositions(currentMember, data) {
def positions = []
// check if a member has been configured to not see other member locations
if (!settings?.privateMembers.find {it==currentMember.name}) {
// loop through all the enabled group members
groupMembers = getMatchingGroupMembersForMember(currentMember)
groupMembers?.each { member->
// Don't send the member's location back to them. NOTE: iOS users will make a duplicate of themselves, and Android has a lag between the app displayed thumbnail and the current phone location
// Don't send locations if a member has been configured to not see other member location
if ((currentMember != member) && !(settings?.privateMembers.find {it==member.name}) ) {
// populating the tracker ID field with a name allows the name to be displayed in the Friends list and map bubbles and load the OwnTrack support parameters
def memberLocation = [ "_type": "location", "t": "u", "lat": member.latitude, "lon": member.longitude, "tst": member.timeStamp ]
// check if fields are valid before adding
if (member.trackerID != null) memberLocation["tid"] = member.trackerID
if (member.battery != null) memberLocation["batt"] = member.battery
if (member.accuracy != null) memberLocation["acc"] = member.accuracy
if (member.altitude != null) memberLocation["alt"] = member.altitude
if (member.speed != null) memberLocation["vel"] = member.speed
if (member.bs != null) memberLocation["bs"] = member.bs
positions << memberLocation
// send the image cards for the user if there is one, and we aren't sending commands, -- only send on the ping or the manual update to minimize data traffic
if (validPositionType(data.t)) {
card = getMemberCard(member)
if (!sendCmdToMember(currentMember) && card) {
positions << card
}
}
}
}
} else {
logDebug("${currentMember.name} is configured to not receive member updates.")
}
return (positions)
}
private def getRandomID() {
return((Math.random() * 0xFFFFFFFF).toInteger())
}
private def getMemberCard(member) {
def card = []
// send the image cards for the user if enabled
if (imageCards) {
try{
// append each enabled user's card with encoded image
card = [ "_type": "card", "name": "${member.name}", "face": "${downloadHubFile("${member.name}.jpg").encodeBase64().toString()}", "tid": "${member.trackerID}" ]
}
catch (e) {
logError("No ${member.name}.jpg image stored in 'Settings->File Manager'")
}
}
return (card)
}
private def logMemberCardJSON() {
// creates "trace" outputs in the Hubitat logs for each user card to be saved into a .JSON file for OwnTracks Recorder
settings?.enabledMembers.each { enabledMember->
member = state.members.find {it.name==enabledMember}
card = getMemberCard(member)
if (card) {
// for recorder, this debug must be captured and saved to: /cards//.json
// or use: https://avanc.github.io/owntracks-cards/ to create and save the JSON
log.trace("For recorder cards, copy the bold JSON text between | |, and save this file to 'STORAGEDIR/cards/${member.name}/${member.name}.json': |${(new JsonBuilder(card)).toPrettyString()}|")
}
}
}
private def sendWaypoints(currentMember) {
logDescriptionText("Updating waypoints for user ${currentMember.name}")
// If the member isn't public, then only send the home place
if (settings?.privateMembers.find {it==currentMember.name}) {
return ([ "_type":"cmd","action":"setWaypoints", "waypoints": [ "_type":"waypoints", "waypoints":getHomeRegion() ] ])
} else {
// if the member is an android user, then do not send the +follow region
return ([ "_type":"cmd","action":"setWaypoints", "waypoints": [ "_type":"waypoints", "waypoints":(isAndroidMember(currentMember?.appVersion) ? getNonFollowRegions(COLLECT_PLACES["map"]) : state.places) ] ])
}
}
private def sendConfiguration(currentMember) {
logDescriptionText("Updating configuration for user ${currentMember.name}")
// create the configuration response. Note: Configuration below are only the HTTP from the exported config.otrc file values based on the build version below
def configurationList = [
"_type" : "configuration",
// static configuration
"mode" : 3, // Endpoint protocol mode: 0=MQTT, 3=HTTP
"autostartOnBoot" : true, // Autostart the app on device boot
"cmd" : true, // Respond to cmd messages
"remoteConfiguration" : true, // Allow remote configuration
"allowRemoteLocation" : true, // Allow remote location command
"reverseGeocodeProvider" : "Device", // Reverse Geocode provider -- use device (Google for Android)
"allowRemoteLocation" : true, // required for 'reportLocation' to be processed
"connectionTimeoutSeconds" : 30,
"debugLog" : false,
"dontReuseHttpClient" : false,
"experimentalFeatures" : [],
"fusedRegionDetection" : true,
"notificationHigherPriority" : false,
"opencageApiKey" : "",
]
def deviceLocatorList = [
// dynamic configurations
"pegLocatorFastestIntervalToInterval" : pegLocatorFastestIntervalToInterval, // Request that the location provider deliver updates no faster than the requested locator interval
"monitoring" : monitoring.toInteger(), // Monitoring mode (quiet, manual, significant, move)
"locatorPriority" : getLocatorPriority(false), // source/power setting for location updates (no power, low power, balanced power, high power)
"locatorDisplacement" : state.locatorDisplacement, // How far should the device travel (in metres) before receiving another location
"locatorInterval" : locatorInterval, // How often should locations be requested from the device (seconds)
"moveModeLocatorInterval" : moveModeLocatorInterval, // How often should locations be requested from the device whilst in Move mode (seconds)
"ignoreInaccurateLocations" : state.ignoreInaccurateLocations, // Ignore location, if the accuracy is greater than the given meters. NOTE: Build 420412000 occasionally reports events with acc=1799.999
"ignoreStaleLocations" : ignoreStaleLocations, // Number of days after which location updates are assumed stale
"ping" : ping, // Device will send a location interval at this heart beat interval (minutes). Minimum 15, seems to be fixed at 30 minutes.
"discardNetworkLocationThresholdSeconds" : (discardNetworkLocationThresholdSeconds ?: DEFAULT_discardNetworkLocationThresholdSeconds), // Ignore network locations if a high accuracy location occured this many seconds ago.
]
def deviceDisplayList = [
"notificationLocation" : notificationLocation, // Display last reported location and time in ongoing notification
"extendedData" : extendedData, // Include extended data in location reports
"notificationEvents" : notificationEvents, // Notify about received events
"enableMapRotation" : enableMapRotation, // Allow the map to be rotated
"showRegionsOnMap" : showRegionsOnMap, // Display the region pins/bubbles on the map
"notificationGeocoderErrors" : notificationGeocoderErrors, // Display Geocoder errors in the notification banner
]
// if we enabled a high accuracy location fix, then mark the user
if (highAccuracyOnPing) {
// configurationList.experimentalFeatures = "showExperimentalPreferenceUI,locationPingUsesHighAccuracyLocationRequest"
configurationList.experimentalFeatures = "locationPingUsesHighAccuracyLocationRequest"
} else {
configurationList.experimentalFeatures = ""
}
// append the extra app configurations if enabled
if (currentMember.updateLocation) {
currentMember.updateLocation = false
configurationList << deviceLocatorList
}
if (currentMember.updateDisplay) {
currentMember.updateDisplay = false
configurationList << deviceDisplayList
}
def configuration = [ "_type":"cmd","action":"setConfiguration", "configuration": configurationList ]
logDebug("Updating configuration: ${configuration}")
return (configuration)
}
private def sendUpdate(currentMember, data) {
def update = []
// only send the position updates on a ping or manual update
if (validPositionType(data.t)) {
update += sendMemberPositions(currentMember, data)
}
if (currentMember?.updateWaypoints) {
currentMember.updateWaypoints = false
update += sendClearWaypointsRequest(currentMember)
update += sendWaypoints(currentMember)
}
// check if we have any places marked for removal, and clean up the list
removePlaces()
if ((currentMember?.updateLocation) || (currentMember?.updateDisplay)) {
update += sendConfiguration(currentMember)
} else {
// dynamically change the configuration as necessary
updateConfig = checkRegionConfiguration(currentMember, data)
if (updateConfig) {
update += updateConfig
}
// request a high accuracy report for one location request
if (currentMember?.requestLocation) {
currentMember.requestLocation = false
logDescriptionText("Requesting a high accuracy location update for ${currentMember.name}")
update += sendReportLocationRequest(currentMember)
}
}
// request the member's regions
if (currentMember?.getRegions) {
currentMember.getRegions = false
update += sendReportWaypointsRequest(currentMember)
}
// report status on ping message type -- user generated location already generates the status message
if (data.t == "p") {
update += sendReportStatusRequest(currentMember)
}
logDebug("Updating user: ${currentMember.name} with data: ${update}")
return (update)
}
private def sendDeactivateUpdate(currentMember) {
def update = []
// indidate we have sent the commands
currentMember["deactivate"] = 2
// clear the waypoints list and URL
update += sendClearWaypointsRequest(currentMember)
update += sendClearURLConfiguration(currentMember)
logWarn("Deactivating user: ${currentMember.name} with data: ${update}")
return (update)
}
private def sendCmdToMember(currentMember) {
// check if there are commands to send to the member
if ((currentMember?.updateWaypoints) || (currentMember?.updateLocation) || (currentMember?.updateDisplay) || (currentMember?.getRegions)) {
return (true)
} else {
return (false)
}
}
def getAndroidMembers() {
// returns a list of Android members
members = []
settings?.enabledMembers.each { enabledMember->
member = state.members.find {it.name==enabledMember}
if (isAndroidMember(member?.appVersion)) {
members << member.name
}
}
return(members)
}
def getiOSMembers() {
// returns a list of iOS members
members = []
settings?.enabledMembers.each { enabledMember->
member = state.members.find {it.name==enabledMember}
if (member?.appVersion?.toString()?.indexOf(ANDROID_USER_AGENT,0) < 0) {
members << member.name
}
}
return(members)
}
def isAndroidMember(appVersion) {
if (appVersion?.toString()?.indexOf(ANDROID_USER_AGENT,0) >= 0) {
return (true)
} else {
return (false)
}
}
def isAllAndroidMembers() {
// check if all users are Android
return(settings?.enabledMembers?.size() == getAndroidMembers()?.size() ? true : false)
}
def validPositionType(locationType) {
// allow update if ping or manual location
return ((locationType == "p") || (locationType == "u"))
}
def validLocationType(locationType) {
// allow if not a region or manual update
return ((locationType != "l") && (locationType != "u"))
}
private def createRecorderFriendsLocationTile() {
def deviceWrapper = getChildDevice(getCommonChildDNI())
if (deviceWrapper) {
deviceWrapper.generateRecorderFriendsLocationTile()
}
}
def createGoogleFriendsLocationTile() {
def deviceWrapper = getChildDevice(getCommonChildDNI())
if (deviceWrapper) {
deviceWrapper.generateGoogleFriendsLocationTile()
}
}
private def getCommonChildDNI() {
return("${app.id}.${COMMON_CHILDNAME}")
}
private def createCommonChild() {
// common device to allow the app to do across family member tasks
def deviceWrapper = getChildDevice(getCommonChildDNI())
if (!deviceWrapper) {
logDescriptionText("Creating OwnTracks Common Device: $COMMON_CHILDNAME:${getCommonChildDNI()}")
try{
addChildDevice("lpakula", "OwnTracks Common Driver", getCommonChildDNI(), ["name": COMMON_CHILDNAME, isComponent: false])
logDescriptionText("Common Child Device Successfully Created")
}
catch (e) {
logError("Common Child device creation failed with error ${e}")
}
}
// recreate the tiles
deviceWrapper?.push()
}
private def createChild(name) {
// the unique ID will be the EPOCH timestamp
id = now()
def DNI = "${app.id}.${id}"
logDescriptionText("Creating OwnTracks Device: $name:$DNI")
try{
def deviceName = createDeviceName(name)
addChildDevice("lpakula", "OwnTracks Driver", DNI, ["name": "${deviceName}", isComponent: false])
state.members.find {it.name==name}.id = DNI
logDescriptionText("Child Device Successfully Created")
}
catch (e) {
logError("Child device creation failed with error ${e}")
}
}
private def updateChildName(member) {
try {
def deviceWrapper = getChildDevice(member.id)
def deviceName = createDeviceName(member.name)
if (deviceWrapper.getName() != deviceName) {
deviceWrapper.setName(deviceName)
logWarn("Changing ${member.name} device name to '${deviceName}'")
} else {
logDebug("Leaving ${member.name} device name as '${deviceName}'")
}
} catch(e) {
logWarn("Leaving ${member.name} device name as '${deviceName}'")
}
}
private def createDeviceName(name) {
if (deviceNamePrefix) {
deviceName = "${deviceNamePrefix}${name}"
} else {
deviceName = name
}
return (deviceName?.trim())
}
private removeChildDevices(delete) {
delete.each {
try {
deleteChildDevice(it.deviceNetworkId)
} catch(e) {
logDebug("Device ${it} does not exist.")
}
}
}
def haversine(lat1, lon1, lat2, lon2) {
def Double R = 6372.8
// In kilometers
def Double dLat = Math.toRadians(lat2 - lat1)
def Double dLon = Math.toRadians(lon2 - lon1)
lat1 = Math.toRadians(lat1)
lat2 = Math.toRadians(lat2)
def Double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2)
def Double c = 2 * Math.asin(Math.sqrt(a))
def Double d = R * c
return(d)
}
private angleFromCoordinate(lat1, lon1, lat2, lon2) {
double deltaLon = Math.toRadians(lon2 - lon1);
double y = Math.sin(deltaLon) * Math.cos(Math.toRadians(lat2));
double x = Math.cos(Math.toRadians(lat1)) * Math.sin(Math.toRadians(lat2)) - Math.sin(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.cos(deltaLon);
double angle = Math.toDegrees(Math.atan2(y, x));
return (angle + 360) % 360; // Normalize to 0-360 degrees
}
def displayKmMiVal(val) {
return (imperialUnits ? (val?.toFloat()*0.621371)?.round(1) : val?.toFloat()?.round(1))
}
def displayMFtVal(val) {
// round up and convert to an integer
return (imperialUnits ? (val?.toFloat()*3.28084)?.round(0)?.toInteger() : val?.toInteger())
}
def convertToKilometers(val) {
// round up and convert to an integer
return (imperialUnits ? (val?.toFloat()*1.60934)?.round(0)?.toInteger() : val?.toInteger())
}
def convertToMeters(val) {
// round up and convert to an integer
return (imperialUnits ? (val?.toFloat()*0.3048)?.round(0)?.toInteger() : val?.toInteger())
}
def getLargeUnits() {
return (imperialUnits ? "mi" : "km")
}
def getSmallUnits() {
return (imperialUnits ? "ft" : "m")
}
def getVelocityUnits() {
return (imperialUnits ? "mph" : "kph")
}
def isimperialUnits() {
return (imperialUnits)
}
private isAddress(address) {
if (address) {
addressList = address?.split(',')
// check if the first two entries in the address are not numbers (lat,lon), then it's an address
if (!addressList[0]?.isNumber() || !addressList[1]?.isNumber()) {
return (true)
}
}
// default to not address
return (false)
}
private def updateAddress(currentMember, data) {
// check if the incoming coordinates within the hystersis of past coordinates, and we have a previously stored address
if ((haversine(data.lat, data.lon, currentMember.latitude, currentMember.longitude) < DEFAULT_geocodeLookupHysteresis) && isAddress(currentMember.address) && !isAddress(data.address)) {
data.address = currentMember.address
} else {
// do the address lookup
data.address = getReverseGeocodeAddress(data)
currentMember.address = data.address
}
}
private def getReverseGeocodeAddress(data) {
try {
// if we have received an address field from the phone
if (isAddress(data?.address)) {
// we already have an address, so pass it back out
return(data.address)
}
} catch (e) {
// ignore the error and continue
}
// do a reverse geocode lookup to get the address
return(reverseGeocode(data.lat, data.lon))
}
private def reverseGeocode(lat,lon) {
if ((geocodeProvider != "0") && (geocodeProvider != null) && isGeocodeAllowed()) {
// generate the reverse loopup URL based on the provider
lookupUrl = GEOCODE_ADDRESS[geocodeProvider.toInteger()] + REVERSE_GEOCODE_REQUEST_LAT[geocodeProvider.toInteger()] + lat.toDouble().round(6) + REVERSE_GEOCODE_REQUEST_LON[geocodeProvider.toInteger()] + lon.toDouble().round(6) + GEOCODE_KEY[geocodeProvider.toInteger()] + settings["geocodeAPIKey_$geocodeProvider"]?.trim()
String address = ADDRESS_JSON[geocodeProvider.toInteger()]
// replace the spaces with %20 to make it URL friendly
response = syncHttpGet(lookupUrl.replaceAll(" ","%20"))
if (response != "") {
logDebug ("Coodindate lookup results in addess: ${response.results."$address"[0]}")
return(response.results."$address"[0])
}
}
return("$lat,$lon")
}
private def geocode(address) {
Double lat = 0.0
Double lon = 0.0
if ((geocodeProvider != "0") && (geocodeProvider != null) && isGeocodeAllowed()) {
// generate the forward loopup URL based on the provider
lookupUrl = GEOCODE_ADDRESS[geocodeProvider.toInteger()] + GEOCODE_REQUEST[geocodeProvider.toInteger()] + address + GEOCODE_KEY[geocodeProvider.toInteger()] + settings["geocodeAPIKey_$geocodeProvider"]?.trim()
// replace the spaces with %20 to make it URL friendly
response = syncHttpGet(lookupUrl.replaceAll(" ","%20"))
if (response != "") {
switch (geocodeProvider.toInteger()) {
case 1:
// Google
lat = response.results.geometry.location.lat[0]
lon = response.results.geometry.location.lng[0]
break
case 2:
// Geoapify
lat = response.results.lat[0]
lon = response.results.lon[0]
break
case 3:
// Opencage
lat = response.results.geometry.lat[0]
lon = response.results.geometry.lng[0]
break
default:
// do nothing
break
}
lat = lat?.toDouble()?.round(6)
lon = lon?.toDouble()?.round(6)
logDescriptionText("Address: '$address' resolves to $lat,$lon")
}
} else {
logWarn("Geocode not configured or quota has been exceeded. Select 'Additional Hub App Settings' to configure/verify geocode provider.")
}
return[lat,lon]
}
def getGoogleMapsAPIKey() {
return(settings["googleMapsAPIKey"]?.trim())
}
private def isGeocodeAllowed() {
String provider = GEOCODE_USAGE_COUNTER[geocodeProvider.toInteger()]
// check if we are allowing paid lookups or we are under our quota and we have a key defined
if (settings["geocodeAPIKey_$geocodeProvider"]?.trim() && (!geocodeFreeOnly || (state."$provider" < GEOCODE_QUOTA[geocodeProvider.toInteger()]))) {
// increment the usage counter
state."$provider"++
return(true)
} else {
return(false)
}
}
private def isMapAllowed(incrementUsage) {
String provider = GEOCODE_USAGE_COUNTER[geocodeProvider.toInteger()]
// check if we are allowing paid lookups or we are under our quota and we have a key defined
if (getGoogleMapsAPIKey() && (!mapFreeOnly || (state.mapApiUsage < GOOGLE_MAP_API_QUOTA))) {
if (incrementUsage) {
// increment the usage counter
state.mapApiUsage++
}
return(true)
} else {
return(false)
}
}
def dailyScheduler() {
logDescriptionText("Running daily geocode quota maintenance.")
dayOfMonth = new SimpleDateFormat("d").format(new Date())
// check if it's the first of the month
if (dayOfMonth.toInteger() == 1) {
state.mapApiUsage = 0
}
// runs midnight GMT - reset the quota's based on if the provider resets daily or monthly
GEOCODE_USAGE_COUNTER.eachWithIndex { entry, index ->
String provider = GEOCODE_USAGE_COUNTER[index+1]
if (GEOCODE_QUOTA_INTERVAL_DAILY[index+1]) {
state."$provider" = 0
} else {
// check if it's the first of the month
if (dayOfMonth.toInteger() == 1) {
state."$provider" = 0
}
}
}
}
def createRegionMap(lat,lon,rad) {
return(["lat":lat,"lon":lon,"rad":rad])
}
def getRegionMapLink(region) {
// if we have an API key that is still has quota left
APIKey = googleMapsAPIKey?.trim()
if (APIKey && isMapAllowed(true)) {
return(getFullLocalApiServerUrl() + "/regionmap/${createRegionMap(region?.lat,region?.lon,region?.rad)}?access_token=${state.accessToken}")
} else {
return("https://maps.google.com/?q=${region?.lat},${region?.lon}&z=17&output=embed&")
}
}
def getMemberMapLink(memberName) {
return(getFullLocalApiServerUrl() + "/membermap/${memberName.toLowerCase()}?access_token=${state.accessToken}")
}
def generateRegionMap() {
// convert the string back to a map
def region = evaluate((params.region).replaceAll("%20",""))
String htmlData = "Google Maps API Not Configured or Quota Exceeded or Cloud Web Links are Disabled"
APIKey = getGoogleMapsAPIKey()
if (APIKey && isMapAllowed(true) && isCloudLinkEnabled(request.HOST)) {
htmlData = """
"""
}
return render(contentType: "text/html", data: (insertOwnTracksFavicon() + htmlData))
}
def generateConfigMap() {
String htmlData = "Google Maps API Not Configured or Quota Exceeded or Cloud Web Links are Disabled"
APIKey = getGoogleMapsAPIKey()
if (APIKey && isMapAllowed(true) && isCloudLinkEnabled(request.HOST)) {
htmlData = """
"""
}
return render(contentType: "text/html", data: (insertOwnTracksFavicon() + htmlData))
}
def displayMemberMap() {
// find the member from the member name in the parameters - this is returned in lowercase
member = state.members.find {it.name.toLowerCase()==params.member}
String htmlData = "Private Member"
if (isCloudLinkEnabled(request.HOST)) {
def deviceWrapper = getChildDevice(member.id)
if (deviceWrapper) {
displayData = deviceWrapper.generateMember(request.headers.Host.toString())
// only display if we could retrieve the data
if (displayData) {
htmlData = displayData
}
}
} else {
htmlData = "Cloud web links are disabled."
}
return render(contentType: "text/html", data: (insertOwnTracksFavicon() + htmlData))
}
def displayMemberPresence() {
// find the member from the member name in the parameters - this is returned in lowercase
member = state.members.find {it.name.toLowerCase()==params.member}
String htmlData = "Member Not Configured"
if (isCloudLinkEnabled(request.HOST)) {
def deviceWrapper = getChildDevice(member.id)
if (deviceWrapper) {
displayData = deviceWrapper.generatePresence(request.headers.Host.toString())
// only display if we could retrieve the data
if (displayData) {
htmlData = displayData
}
}
} else {
htmlData = "Cloud web links are disabled."
}
return render(contentType: "text/html", data: (insertOwnTracksFavicon() + htmlData))
}
def displayMemberPastLocations() {
// find the member from the member name in the parameters - this is returned in lowercase
member = state.members.find {it.name.toLowerCase()==params.member}
String htmlData = "OwnTracks Recorder Not Configured or Private Member"
if (isCloudLinkEnabled(request.HOST)) {
def deviceWrapper = getChildDevice(member.id)
if (deviceWrapper) {
displayData = deviceWrapper.generatePastLocations()
// only display if we could retrieve the data
if (displayData) {
htmlData = displayData
}
}
} else {
htmlData = "Cloud web links are disabled."
}
return render(contentType: "text/html", data: (insertOwnTracksFavicon() + htmlData))
}
def processAPIData() {
// process incoming data
response = []
data = parseJson(request.body)
if (data.zoom) {
state.googleMapsZoom = data.zoom
}
if (data.member) {
state.googleMapsMember = data.member
}
if (data.action) {
switch (data.action) {
case "save":
// trigger and add/update of the region
addPlace([ "name":"" ], data.payload, false)
break;
case "home":
// set home to the place matching the timestamp
app.removeSetting("homePlace")
app.updateSetting("homePlace", [value: data.payload.tst, type: "number"])
break;
case "delete":
// delete region from hub/mobile or just hub depending on setting
app.updateSetting("regionName",[value:data.payload.desc,type:"text"])
if (manualDeleteBehavior) {
appButtonHandler("deleteRegionFromHubButton")
} else {
appButtonHandler("deleteRegionFromAllButton")
}
break;
case "geocode":
(addressLat, addressLon) = geocode(data.payload.address)
response = ["lat" : addressLat, "lon" : addressLon, "action" : data.action]
break;
case "reversegeocode":
response = ["address" : reverseGeocode(data.payload.lat,data.payload.lon), "index" : data.payload.index, "action" : data.action]
break;
case "members":
// return with the consolidated list of member information
def memberLocations = []
publicMembers = getEnabledAndNotHiddenMemberData(data.payload?.member)
publicMembers.eachWithIndex { member, index ->
def deviceWrapper = getChildDevice(member.id)
def memberLocation = [
"name" : "${member.name}",
"id" : member.id,
"lat" : member.latitude,
"lng" : member.longitude,
"cog" : member.bearing,
"spd" : deviceWrapper?.currentValue("lastSpeed"),
"bat" : member?.battery,
"acc" : deviceWrapper?.currentValue("accuracy"),
"wifi" : member?.wifi,
"ps" : member?.ps,
"hib" : (member?.hib ? member?.hib : "0"),
"bo" : (member?.bo ? member?.bo : "0"),
"per" : (member?.loc ? member?.loc : "0"),
"cmd" : member?.cmd,
"stale" : member.staleReport,
"bs" : "${member?.bs}",
"last" : "${deviceWrapper?.currentValue("lastLocationtime")}",
"since" : "${deviceWrapper?.currentValue("since")}",
"data" : "${member?.conn}",
"loc" : "${deviceWrapper?.currentValue("location")}",
"dfh" : deviceWrapper?.currentValue("distanceFromHome"),
"color": (member?.color ? member?.color : DEFAULT_MEMBER_GLYPH_COLOR),
"history": (member?.history ? member?.history : []),
"zIndex" : index+1,
]
// split out the img since that is a large data payload that we only need once during the page init
if (data.payload.request == "img") {
memberLocation["img"] = "${getEmbeddedImage(member.name)}"
// don't send the history on the initial page load
memberLocation["history"] = []
// clear the flag to allow the member to sync to the map
member.lastMapTime = 0
}
if (data.payload.request == "sync") {
// clear the flag to allow the member to sync to the map
member.lastMapTime = 0
}
// only send new data to the map to minimize data traffic
if (member.lastMapTime != member.lastReportTime) {
memberLocations << memberLocation
}
// store the last location report sent to the map
member.lastMapTime = member.lastReportTime
}
response = [ "members" : memberLocations, "appVersion" : appVersion() ]
break;
case "update":
member = state.members.find {it.name==data.payload}
response = ["lastReportTime" : member?.lastReportTime]
break;
default:
logWarn("Unhandled API action: ${data.action}")
break;
}
}
// send back a success response
return render(contentType: "text/html", data: (new JsonBuilder(response)).toPrettyString(), status: 200)
}
def processMemberAPICommand() {
try {
member = state.members.find {it.name.toLowerCase()==params.member.toLowerCase()}
def deviceWrapper = getChildDevice(member?.id)
deviceWrapper."${params.cmd}"()
response = "Command '${params.cmd}' successfully issued for member '${params.member}'."
status = 200
} catch (e) {
logError(e.message)
response = "Command '${params.cmd}' failed for member '${params.member}'. Member, member device or command does not exist."
status = 404
}
return render(contentType: "text/html", data: (new JsonBuilder(response)).toPrettyString(), status: status)
}
def retrieveGoogleFriendsMapZoom() {
return(state.googleMapsZoom)
}
def retrieveGoogleFriendsMapMember() {
return(state.googleMapsMember)
}
def insertOwnTracksFavicon() {
return('')
}
def generateGoogleFriendsMap() {
String htmlData = "Google Maps API Not Configured or Quota Exceeded or Cloud Web Links are Disabled"
APIKey = getGoogleMapsAPIKey()
if (APIKey && isMapAllowed(true) && isCloudLinkEnabled(request.HOST)) {
htmlData = """
"""
}
return render(contentType: "text/html", data: (insertOwnTracksFavicon() + htmlData))
}
def isHTTPsURL(url) {
parsedURL = url?.split(":")
return(parsedURL[0]?.toLowerCase() == "https")
}
def recorderURLType() {
// check the recorder URL and switch as necessary
recorderURL = getRecorderURL()
// default to local source
source = URL_SOURCE[1]
if (recorderURL) {
if (isHTTPsURL(recorderURL)) {
// cloud source
source = URL_SOURCE[0]
}
}
return(source)
}
def generateRecorderFriendsLocation() {
String htmlData = "OwnTracks Recorder Not Configured"
if (getRecorderURL()) {
publicMembers = getEnabledAndNotHiddenMembers()
urlPath = getRecorderURL() + '/last/index.html'
htmlData = """