433 lines
14 KiB
Python
433 lines
14 KiB
Python
"""Support for Life360 device tracking."""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from datetime import timedelta
|
|
import logging
|
|
|
|
from life360 import Life360Error
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.device_tracker import (
|
|
CONF_SCAN_INTERVAL,
|
|
DOMAIN as DEVICE_TRACKER_DOMAIN,
|
|
)
|
|
from homeassistant.components.zone import async_active_zone
|
|
from homeassistant.const import (
|
|
ATTR_BATTERY_CHARGING,
|
|
ATTR_ENTITY_ID,
|
|
CONF_PREFIX,
|
|
LENGTH_FEET,
|
|
LENGTH_KILOMETERS,
|
|
LENGTH_METERS,
|
|
LENGTH_MILES,
|
|
STATE_UNKNOWN,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.event import track_time_interval
|
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|
from homeassistant.util.async_ import run_callback_threadsafe
|
|
from homeassistant.util.distance import convert
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
from .const import (
|
|
CONF_CIRCLES,
|
|
CONF_DRIVING_SPEED,
|
|
CONF_ERROR_THRESHOLD,
|
|
CONF_MAX_GPS_ACCURACY,
|
|
CONF_MAX_UPDATE_WAIT,
|
|
CONF_MEMBERS,
|
|
CONF_SHOW_AS_STATE,
|
|
CONF_WARNING_THRESHOLD,
|
|
DOMAIN,
|
|
SHOW_DRIVING,
|
|
SHOW_MOVING,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
SPEED_FACTOR_MPH = 2.25
|
|
EVENT_DELAY = timedelta(seconds=30)
|
|
|
|
ATTR_ADDRESS = "address"
|
|
ATTR_AT_LOC_SINCE = "at_loc_since"
|
|
ATTR_DRIVING = "driving"
|
|
ATTR_LAST_SEEN = "last_seen"
|
|
ATTR_MOVING = "moving"
|
|
ATTR_PLACE = "place"
|
|
ATTR_RAW_SPEED = "raw_speed"
|
|
ATTR_SPEED = "speed"
|
|
ATTR_WAIT = "wait"
|
|
ATTR_WIFI_ON = "wifi_on"
|
|
|
|
EVENT_UPDATE_OVERDUE = "life360_update_overdue"
|
|
EVENT_UPDATE_RESTORED = "life360_update_restored"
|
|
|
|
|
|
def _include_name(filter_dict, name):
|
|
if not name:
|
|
return False
|
|
if not filter_dict:
|
|
return True
|
|
name = name.lower()
|
|
if filter_dict["include"]:
|
|
return name in filter_dict["list"]
|
|
return name not in filter_dict["list"]
|
|
|
|
|
|
def _exc_msg(exc):
|
|
return f"{exc.__class__.__name__}: {exc}"
|
|
|
|
|
|
def _dump_filter(filter_dict, desc, func=lambda x: x):
|
|
if not filter_dict:
|
|
return
|
|
_LOGGER.debug(
|
|
"%scluding %s: %s",
|
|
"In" if filter_dict["include"] else "Ex",
|
|
desc,
|
|
", ".join([func(name) for name in filter_dict["list"]]),
|
|
)
|
|
|
|
|
|
def setup_scanner(
|
|
hass: HomeAssistant,
|
|
config: ConfigType,
|
|
see: Callable[..., None],
|
|
discovery_info: DiscoveryInfoType | None = None,
|
|
) -> bool:
|
|
"""Set up device scanner."""
|
|
config = hass.data[DOMAIN]["config"]
|
|
apis = hass.data[DOMAIN]["apis"]
|
|
Life360Scanner(hass, config, see, apis)
|
|
return True
|
|
|
|
|
|
def _utc_from_ts(val):
|
|
try:
|
|
return dt_util.utc_from_timestamp(float(val))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _dt_attr_from_ts(timestamp):
|
|
utc = _utc_from_ts(timestamp)
|
|
if utc:
|
|
return utc
|
|
return STATE_UNKNOWN
|
|
|
|
|
|
def _bool_attr_from_int(val):
|
|
try:
|
|
return bool(int(val))
|
|
except (TypeError, ValueError):
|
|
return STATE_UNKNOWN
|
|
|
|
|
|
class Life360Scanner:
|
|
"""Life360 device scanner."""
|
|
|
|
def __init__(self, hass, config, see, apis):
|
|
"""Initialize Life360Scanner."""
|
|
self._hass = hass
|
|
self._see = see
|
|
self._max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
|
|
self._max_update_wait = config.get(CONF_MAX_UPDATE_WAIT)
|
|
self._prefix = config[CONF_PREFIX]
|
|
self._circles_filter = config.get(CONF_CIRCLES)
|
|
self._members_filter = config.get(CONF_MEMBERS)
|
|
self._driving_speed = config.get(CONF_DRIVING_SPEED)
|
|
self._show_as_state = config[CONF_SHOW_AS_STATE]
|
|
self._apis = apis
|
|
self._errs = {}
|
|
self._error_threshold = config[CONF_ERROR_THRESHOLD]
|
|
self._warning_threshold = config[CONF_WARNING_THRESHOLD]
|
|
self._max_errs = self._error_threshold + 1
|
|
self._dev_data = {}
|
|
self._circles_logged = set()
|
|
self._members_logged = set()
|
|
|
|
_dump_filter(self._circles_filter, "Circles")
|
|
_dump_filter(self._members_filter, "device IDs", self._dev_id)
|
|
|
|
self._started = dt_util.utcnow()
|
|
self._update_life360()
|
|
track_time_interval(
|
|
self._hass, self._update_life360, config[CONF_SCAN_INTERVAL]
|
|
)
|
|
|
|
def _dev_id(self, name):
|
|
return self._prefix + name
|
|
|
|
def _ok(self, key):
|
|
if self._errs.get(key, 0) >= self._max_errs:
|
|
_LOGGER.error("%s: OK again", key)
|
|
self._errs[key] = 0
|
|
|
|
def _err(self, key, err_msg):
|
|
_errs = self._errs.get(key, 0)
|
|
if _errs < self._max_errs:
|
|
self._errs[key] = _errs = _errs + 1
|
|
msg = f"{key}: {err_msg}"
|
|
if _errs >= self._error_threshold:
|
|
if _errs == self._max_errs:
|
|
msg = f"Suppressing further errors until OK: {msg}"
|
|
_LOGGER.error(msg)
|
|
elif _errs >= self._warning_threshold:
|
|
_LOGGER.warning(msg)
|
|
|
|
def _exc(self, key, exc):
|
|
self._err(key, _exc_msg(exc))
|
|
|
|
def _prev_seen(self, dev_id, last_seen):
|
|
prev_seen, reported = self._dev_data.get(dev_id, (None, False))
|
|
|
|
if self._max_update_wait:
|
|
now = dt_util.utcnow()
|
|
most_recent_update = last_seen or prev_seen or self._started
|
|
overdue = now - most_recent_update > self._max_update_wait
|
|
if overdue and not reported and now - self._started > EVENT_DELAY:
|
|
self._hass.bus.fire(
|
|
EVENT_UPDATE_OVERDUE,
|
|
{ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}"},
|
|
)
|
|
reported = True
|
|
elif not overdue and reported:
|
|
self._hass.bus.fire(
|
|
EVENT_UPDATE_RESTORED,
|
|
{
|
|
ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}",
|
|
ATTR_WAIT: str(last_seen - (prev_seen or self._started)).split(
|
|
".", maxsplit=1
|
|
)[0],
|
|
},
|
|
)
|
|
reported = False
|
|
|
|
# Don't remember last_seen unless it's really an update.
|
|
if not last_seen or prev_seen and last_seen <= prev_seen:
|
|
last_seen = prev_seen
|
|
self._dev_data[dev_id] = last_seen, reported
|
|
|
|
return prev_seen
|
|
|
|
def _update_member(self, member, dev_id):
|
|
loc = member.get("location")
|
|
try:
|
|
last_seen = _utc_from_ts(loc.get("timestamp"))
|
|
except AttributeError:
|
|
last_seen = None
|
|
prev_seen = self._prev_seen(dev_id, last_seen)
|
|
|
|
if not loc:
|
|
if err_msg := member["issues"]["title"]:
|
|
if member["issues"]["dialog"]:
|
|
err_msg += f": {member['issues']['dialog']}"
|
|
else:
|
|
err_msg = "Location information missing"
|
|
self._err(dev_id, err_msg)
|
|
return
|
|
|
|
# Only update when we truly have an update.
|
|
if not last_seen:
|
|
_LOGGER.warning("%s: Ignoring update because timestamp is missing", dev_id)
|
|
return
|
|
if prev_seen and last_seen < prev_seen:
|
|
_LOGGER.warning(
|
|
"%s: Ignoring update because timestamp is older than last timestamp",
|
|
dev_id,
|
|
)
|
|
_LOGGER.debug("%s < %s", last_seen, prev_seen)
|
|
return
|
|
if last_seen == prev_seen:
|
|
return
|
|
|
|
lat = loc.get("latitude")
|
|
lon = loc.get("longitude")
|
|
gps_accuracy = loc.get("accuracy")
|
|
try:
|
|
lat = float(lat)
|
|
lon = float(lon)
|
|
# Life360 reports accuracy in feet, but Device Tracker expects
|
|
# gps_accuracy in meters.
|
|
gps_accuracy = round(
|
|
convert(float(gps_accuracy), LENGTH_FEET, LENGTH_METERS)
|
|
)
|
|
except (TypeError, ValueError):
|
|
self._err(dev_id, f"GPS data invalid: {lat}, {lon}, {gps_accuracy}")
|
|
return
|
|
|
|
self._ok(dev_id)
|
|
|
|
msg = f"Updating {dev_id}"
|
|
if prev_seen:
|
|
msg += f"; Time since last update: {last_seen - prev_seen}"
|
|
_LOGGER.debug(msg)
|
|
|
|
if self._max_gps_accuracy is not None and gps_accuracy > self._max_gps_accuracy:
|
|
_LOGGER.warning(
|
|
"%s: Ignoring update because expected GPS "
|
|
"accuracy (%.0f) is not met: %.0f",
|
|
dev_id,
|
|
self._max_gps_accuracy,
|
|
gps_accuracy,
|
|
)
|
|
return
|
|
|
|
# Get raw attribute data, converting empty strings to None.
|
|
place = loc.get("name") or None
|
|
address1 = loc.get("address1") or None
|
|
address2 = loc.get("address2") or None
|
|
if address1 and address2:
|
|
address = ", ".join([address1, address2])
|
|
else:
|
|
address = address1 or address2
|
|
raw_speed = loc.get("speed") or None
|
|
driving = _bool_attr_from_int(loc.get("isDriving"))
|
|
moving = _bool_attr_from_int(loc.get("inTransit"))
|
|
try:
|
|
battery = int(float(loc.get("battery")))
|
|
except (TypeError, ValueError):
|
|
battery = None
|
|
|
|
# Try to convert raw speed into real speed.
|
|
try:
|
|
speed = float(raw_speed) * SPEED_FACTOR_MPH
|
|
if self._hass.config.units.is_metric:
|
|
speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS)
|
|
speed = max(0, round(speed))
|
|
except (TypeError, ValueError):
|
|
speed = STATE_UNKNOWN
|
|
|
|
# Make driving attribute True if it isn't and we can derive that it
|
|
# should be True from other data.
|
|
if (
|
|
driving in (STATE_UNKNOWN, False)
|
|
and self._driving_speed is not None
|
|
and speed != STATE_UNKNOWN
|
|
):
|
|
driving = speed >= self._driving_speed
|
|
|
|
attrs = {
|
|
ATTR_ADDRESS: address,
|
|
ATTR_AT_LOC_SINCE: _dt_attr_from_ts(loc.get("since")),
|
|
ATTR_BATTERY_CHARGING: _bool_attr_from_int(loc.get("charge")),
|
|
ATTR_DRIVING: driving,
|
|
ATTR_LAST_SEEN: last_seen,
|
|
ATTR_MOVING: moving,
|
|
ATTR_PLACE: place,
|
|
ATTR_RAW_SPEED: raw_speed,
|
|
ATTR_SPEED: speed,
|
|
ATTR_WIFI_ON: _bool_attr_from_int(loc.get("wifiState")),
|
|
}
|
|
|
|
# If user wants driving or moving to be shown as state, and current
|
|
# location is not in a HA zone, then set location name accordingly.
|
|
loc_name = None
|
|
active_zone = run_callback_threadsafe(
|
|
self._hass.loop, async_active_zone, self._hass, lat, lon, gps_accuracy
|
|
).result()
|
|
if not active_zone:
|
|
if SHOW_DRIVING in self._show_as_state and driving is True:
|
|
loc_name = SHOW_DRIVING
|
|
elif SHOW_MOVING in self._show_as_state and moving is True:
|
|
loc_name = SHOW_MOVING
|
|
|
|
self._see(
|
|
dev_id=dev_id,
|
|
location_name=loc_name,
|
|
gps=(lat, lon),
|
|
gps_accuracy=gps_accuracy,
|
|
battery=battery,
|
|
attributes=attrs,
|
|
picture=member.get("avatar"),
|
|
)
|
|
|
|
def _update_members(self, members, members_updated):
|
|
for member in members:
|
|
member_id = member["id"]
|
|
if member_id in members_updated:
|
|
continue
|
|
err_key = "Member data"
|
|
try:
|
|
first = member.get("firstName")
|
|
last = member.get("lastName")
|
|
if first and last:
|
|
full_name = " ".join([first, last])
|
|
else:
|
|
full_name = first or last
|
|
slug_name = cv.slugify(full_name)
|
|
include_member = _include_name(self._members_filter, slug_name)
|
|
dev_id = self._dev_id(slug_name)
|
|
if member_id not in self._members_logged:
|
|
self._members_logged.add(member_id)
|
|
_LOGGER.debug(
|
|
"%s -> %s: will%s be tracked, id=%s",
|
|
full_name,
|
|
dev_id,
|
|
"" if include_member else " NOT",
|
|
member_id,
|
|
)
|
|
sharing = bool(int(member["features"]["shareLocation"]))
|
|
except (KeyError, TypeError, ValueError, vol.Invalid):
|
|
self._err(err_key, member)
|
|
continue
|
|
self._ok(err_key)
|
|
|
|
if include_member and sharing:
|
|
members_updated.append(member_id)
|
|
self._update_member(member, dev_id)
|
|
|
|
def _update_life360(self, now=None):
|
|
circles_updated = []
|
|
members_updated = []
|
|
|
|
for api in self._apis.values():
|
|
err_key = "get_circles"
|
|
try:
|
|
circles = api.get_circles()
|
|
except Life360Error as exc:
|
|
self._exc(err_key, exc)
|
|
continue
|
|
self._ok(err_key)
|
|
|
|
for circle in circles:
|
|
circle_id = circle["id"]
|
|
if circle_id in circles_updated:
|
|
continue
|
|
circles_updated.append(circle_id)
|
|
circle_name = circle["name"]
|
|
incl_circle = _include_name(self._circles_filter, circle_name)
|
|
if circle_id not in self._circles_logged:
|
|
self._circles_logged.add(circle_id)
|
|
_LOGGER.debug(
|
|
"%s Circle: will%s be included, id=%s",
|
|
circle_name,
|
|
"" if incl_circle else " NOT",
|
|
circle_id,
|
|
)
|
|
try:
|
|
places = api.get_circle_places(circle_id)
|
|
place_data = "Circle's Places:"
|
|
for place in places:
|
|
place_data += f"\n- name: {place['name']}"
|
|
place_data += f"\n latitude: {place['latitude']}"
|
|
place_data += f"\n longitude: {place['longitude']}"
|
|
place_data += f"\n radius: {place['radius']}"
|
|
if not places:
|
|
place_data += " None"
|
|
_LOGGER.debug(place_data)
|
|
except (Life360Error, KeyError):
|
|
pass
|
|
if incl_circle:
|
|
err_key = f'get_circle_members "{circle_name}"'
|
|
try:
|
|
members = api.get_circle_members(circle_id)
|
|
except Life360Error as exc:
|
|
self._exc(err_key, exc)
|
|
continue
|
|
self._ok(err_key)
|
|
|
|
self._update_members(members, members_updated)
|