"""Support for Life360 device tracking.""" from datetime import timedelta import logging from life360 import Life360Error import voluptuous as vol from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL from homeassistant.components.device_tracker.const import ( ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT) 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) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_time_interval 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 '{}: {}'.format(exc.__class__.__name__, str(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, config, see, discovery_info=None): """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 = '{}: {}'.format(key, err_msg) if _errs >= self._error_threshold: if _errs == self._max_errs: msg = '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: DT_ENTITY_ID_FORMAT.format(dev_id)}) reported = True elif not overdue and reported: self._hass.bus.fire( EVENT_UPDATE_RESTORED, { ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id), ATTR_WAIT: str(last_seen - (prev_seen or self._started)) .split('.')[0]}) reported = False self._dev_data[dev_id] = last_seen or prev_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: err_msg = member['issues']['title'] if err_msg: if member['issues']['dialog']: err_msg += ': ' + 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 or prev_seen and 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, 'GPS data invalid: {}, {}, {}'.format( lat, lon, gps_accuracy)) return self._ok(dev_id) msg = 'Updating {}'.format(dev_id) if prev_seen: msg += '; Time since last update: {}'.format(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 += '\n- name: {}'.format(place['name']) place_data += '\n latitude: {}'.format( place['latitude']) place_data += '\n longitude: {}'.format( place['longitude']) place_data += '\n radius: {}'.format( place['radius']) if not places: place_data += ' None' _LOGGER.debug(place_data) except (Life360Error, KeyError): pass if incl_circle: err_key = 'get_circle_members "{}"'.format(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)