Life360 integration (#24227)
This commit is contained in:
parent
156ab7dc2b
commit
1c1363875c
12 changed files with 684 additions and 0 deletions
|
@ -318,6 +318,7 @@ omit =
|
|||
homeassistant/components/lcn/*
|
||||
homeassistant/components/lg_netcast/media_player.py
|
||||
homeassistant/components/lg_soundbar/media_player.py
|
||||
homeassistant/components/life360/*
|
||||
homeassistant/components/lifx/*
|
||||
homeassistant/components/lifx_cloud/scene.py
|
||||
homeassistant/components/lifx_legacy/light.py
|
||||
|
|
|
@ -137,6 +137,7 @@ homeassistant/components/konnected/* @heythisisnate
|
|||
homeassistant/components/lametric/* @robbiet480
|
||||
homeassistant/components/launch_library/* @ludeeus
|
||||
homeassistant/components/lcn/* @alengwenus
|
||||
homeassistant/components/life360/* @pnbruckner
|
||||
homeassistant/components/lifx/* @amelchio
|
||||
homeassistant/components/lifx_cloud/* @amelchio
|
||||
homeassistant/components/lifx_legacy/* @amelchio
|
||||
|
|
27
homeassistant/components/life360/.translations/en.json
Normal file
27
homeassistant/components/life360/.translations/en.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"config": {
|
||||
"title": "Life360",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Life360 Account Info",
|
||||
"data": {
|
||||
"username": "Username",
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_username": "Invalid username",
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"user_already_configured": "Account has already been configured"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "To set advanced options, see [Life360 documentation]({docs_url})."
|
||||
},
|
||||
"abort": {
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"user_already_configured": "Account has already been configured"
|
||||
}
|
||||
}
|
||||
}
|
139
homeassistant/components/life360/__init__.py
Normal file
139
homeassistant/components/life360/__init__.py
Normal file
|
@ -0,0 +1,139 @@
|
|||
"""Life360 integration."""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.device_tracker import (
|
||||
CONF_SCAN_INTERVAL, DOMAIN as DEVICE_TRACKER)
|
||||
from homeassistant.components.device_tracker.const import (
|
||||
SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL)
|
||||
from homeassistant.const import (
|
||||
CONF_EXCLUDE, CONF_INCLUDE, CONF_PASSWORD, CONF_PREFIX, CONF_USERNAME)
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZATION, CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD,
|
||||
CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS,
|
||||
CONF_WARNING_THRESHOLD, DOMAIN)
|
||||
from .helpers import get_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PREFIX = DOMAIN
|
||||
|
||||
CONF_ACCOUNTS = 'accounts'
|
||||
|
||||
|
||||
def _excl_incl_list_to_filter_dict(value):
|
||||
return {
|
||||
'include': CONF_INCLUDE in value,
|
||||
'list': value.get(CONF_EXCLUDE) or value.get(CONF_INCLUDE)
|
||||
}
|
||||
|
||||
|
||||
def _prefix(value):
|
||||
if not value:
|
||||
return ''
|
||||
if not value.endswith('_'):
|
||||
return value + '_'
|
||||
return value
|
||||
|
||||
|
||||
def _thresholds(config):
|
||||
error_threshold = config.get(CONF_ERROR_THRESHOLD)
|
||||
warning_threshold = config.get(CONF_WARNING_THRESHOLD)
|
||||
if error_threshold and warning_threshold:
|
||||
if error_threshold <= warning_threshold:
|
||||
raise vol.Invalid('{} must be larger than {}'.format(
|
||||
CONF_ERROR_THRESHOLD, CONF_WARNING_THRESHOLD))
|
||||
elif not error_threshold and warning_threshold:
|
||||
config[CONF_ERROR_THRESHOLD] = warning_threshold + 1
|
||||
elif error_threshold and not warning_threshold:
|
||||
# Make them the same which effectively prevents warnings.
|
||||
config[CONF_WARNING_THRESHOLD] = error_threshold
|
||||
else:
|
||||
# Log all errors as errors.
|
||||
config[CONF_ERROR_THRESHOLD] = 1
|
||||
config[CONF_WARNING_THRESHOLD] = 1
|
||||
return config
|
||||
|
||||
|
||||
ACCOUNT_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
})
|
||||
|
||||
_SLUG_LIST = vol.All(
|
||||
cv.ensure_list, [cv.slugify],
|
||||
vol.Length(min=1, msg='List cannot be empty'))
|
||||
|
||||
_LOWER_STRING_LIST = vol.All(
|
||||
cv.ensure_list, [vol.All(cv.string, vol.Lower)],
|
||||
vol.Length(min=1, msg='List cannot be empty'))
|
||||
|
||||
_EXCL_INCL_SLUG_LIST = vol.All(
|
||||
vol.Schema({
|
||||
vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _SLUG_LIST,
|
||||
vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _SLUG_LIST,
|
||||
}),
|
||||
cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE),
|
||||
_excl_incl_list_to_filter_dict,
|
||||
)
|
||||
|
||||
_EXCL_INCL_LOWER_STRING_LIST = vol.All(
|
||||
vol.Schema({
|
||||
vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _LOWER_STRING_LIST,
|
||||
vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _LOWER_STRING_LIST,
|
||||
}),
|
||||
cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE),
|
||||
_excl_incl_list_to_filter_dict
|
||||
)
|
||||
|
||||
_THRESHOLD = vol.All(vol.Coerce(int), vol.Range(min=1))
|
||||
|
||||
LIFE360_SCHEMA = vol.All(
|
||||
vol.Schema({
|
||||
vol.Optional(CONF_ACCOUNTS): vol.All(
|
||||
cv.ensure_list, [ACCOUNT_SCHEMA], vol.Length(min=1)),
|
||||
vol.Optional(CONF_CIRCLES): _EXCL_INCL_LOWER_STRING_LIST,
|
||||
vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
|
||||
vol.Optional(CONF_ERROR_THRESHOLD): _THRESHOLD,
|
||||
vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_UPDATE_WAIT): vol.All(
|
||||
cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(CONF_MEMBERS): _EXCL_INCL_SLUG_LIST,
|
||||
vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX):
|
||||
vol.All(vol.Any(None, cv.string), _prefix),
|
||||
vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
|
||||
cv.time_period,
|
||||
vol.Optional(CONF_WARNING_THRESHOLD): _THRESHOLD,
|
||||
}),
|
||||
_thresholds
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: LIFE360_SCHEMA
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up integration."""
|
||||
conf = config.get(DOMAIN, LIFE360_SCHEMA({}))
|
||||
hass.data[DOMAIN] = {'config': conf, 'apis': []}
|
||||
discovery.load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config)
|
||||
|
||||
if CONF_ACCOUNTS in conf:
|
||||
for account in conf[CONF_ACCOUNTS]:
|
||||
hass.async_create_task(hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
|
||||
data=account))
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up config entry."""
|
||||
hass.data[DOMAIN]['apis'].append(
|
||||
get_api(entry.data[CONF_AUTHORIZATION]))
|
||||
return True
|
100
homeassistant/components/life360/config_flow.py
Normal file
100
homeassistant/components/life360/config_flow.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
"""Config flow to configure Life360 integration."""
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
|
||||
from life360 import LoginError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import CONF_AUTHORIZATION, DOMAIN
|
||||
from .helpers import get_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOCS_URL = 'https://www.home-assistant.io/components/life360'
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class Life360ConfigFlow(config_entries.ConfigFlow):
|
||||
"""Life360 integration config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize."""
|
||||
self._api = get_api()
|
||||
self._username = vol.UNDEFINED
|
||||
self._password = vol.UNDEFINED
|
||||
|
||||
@property
|
||||
def configured_usernames(self):
|
||||
"""Return tuple of configured usernames."""
|
||||
entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||
if entries:
|
||||
return (entry.data[CONF_USERNAME] for entry in entries)
|
||||
return ()
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a user initiated config flow."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
try:
|
||||
# pylint: disable=no-value-for-parameter
|
||||
vol.Email()(self._username)
|
||||
authorization = self._api.get_authorization(
|
||||
self._username, self._password)
|
||||
except vol.Invalid:
|
||||
errors[CONF_USERNAME] = 'invalid_username'
|
||||
except LoginError:
|
||||
errors['base'] = 'invalid_credentials'
|
||||
else:
|
||||
if self._username in self.configured_usernames:
|
||||
errors['base'] = 'user_already_configured'
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=self._username,
|
||||
data={
|
||||
CONF_USERNAME: self._username,
|
||||
CONF_PASSWORD: self._password,
|
||||
CONF_AUTHORIZATION: authorization
|
||||
},
|
||||
description_placeholders={'docs_url': DOCS_URL}
|
||||
)
|
||||
|
||||
data_schema = OrderedDict()
|
||||
data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str
|
||||
data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='user',
|
||||
data_schema=vol.Schema(data_schema),
|
||||
errors=errors,
|
||||
description_placeholders={'docs_url': DOCS_URL}
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Import a config flow from configuration."""
|
||||
username = user_input[CONF_USERNAME]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
if username in self.configured_usernames:
|
||||
_LOGGER.warning('%s already configured', username)
|
||||
return self.async_abort(reason='user_already_configured')
|
||||
try:
|
||||
authorization = self._api.get_authorization(username, password)
|
||||
except LoginError:
|
||||
_LOGGER.error('Invalid credentials for %s', username)
|
||||
return self.async_abort(reason='invalid_credentials')
|
||||
return self.async_create_entry(
|
||||
title='{} (from configuration)'.format(username),
|
||||
data={
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_AUTHORIZATION: authorization
|
||||
}
|
||||
)
|
11
homeassistant/components/life360/const.py
Normal file
11
homeassistant/components/life360/const.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
"""Constants for Life360 integration."""
|
||||
DOMAIN = 'life360'
|
||||
|
||||
CONF_AUTHORIZATION = 'authorization'
|
||||
CONF_CIRCLES = 'circles'
|
||||
CONF_DRIVING_SPEED = 'driving_speed'
|
||||
CONF_ERROR_THRESHOLD = 'error_threshold'
|
||||
CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
|
||||
CONF_MAX_UPDATE_WAIT = 'max_update_wait'
|
||||
CONF_MEMBERS = 'members'
|
||||
CONF_WARNING_THRESHOLD = 'warning_threshold'
|
354
homeassistant/components/life360/device_tracker.py
Normal file
354
homeassistant/components/life360/device_tracker.py
Normal file
|
@ -0,0 +1,354 @@
|
|||
"""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.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.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_WARNING_THRESHOLD, DOMAIN)
|
||||
|
||||
_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._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', {})
|
||||
last_seen = _utc_from_ts(loc.get('timestamp'))
|
||||
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')),
|
||||
}
|
||||
|
||||
self._see(dev_id=dev_id, 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
|
||||
members_updated.append(member_id)
|
||||
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:
|
||||
self._update_member(member, dev_id)
|
||||
|
||||
def _update_life360(self, now=None):
|
||||
circles_updated = []
|
||||
members_updated = []
|
||||
|
||||
for api in self._apis:
|
||||
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)
|
7
homeassistant/components/life360/helpers.py
Normal file
7
homeassistant/components/life360/helpers.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
"""Life360 integration helpers."""
|
||||
from life360 import Life360
|
||||
|
||||
|
||||
def get_api(authorization=None):
|
||||
"""Create Life360 api object."""
|
||||
return Life360(timeout=3.05, max_retries=2, authorization=authorization)
|
13
homeassistant/components/life360/manifest.json
Normal file
13
homeassistant/components/life360/manifest.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"domain": "life360",
|
||||
"name": "Life360",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/components/life360",
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
"@pnbruckner"
|
||||
],
|
||||
"requirements": [
|
||||
"life360==4.0.0"
|
||||
]
|
||||
}
|
27
homeassistant/components/life360/strings.json
Normal file
27
homeassistant/components/life360/strings.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"config": {
|
||||
"title": "Life360",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Life360 Account Info",
|
||||
"data": {
|
||||
"username": "Username",
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_username": "Invalid username",
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"user_already_configured": "Account has already been configured"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "To set advanced options, see [Life360 documentation]({docs_url})."
|
||||
},
|
||||
"abort": {
|
||||
"invalid_credentials": "Invalid credentials",
|
||||
"user_already_configured": "Account has already been configured"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,6 +26,7 @@ FLOWS = [
|
|||
"ios",
|
||||
"ipma",
|
||||
"iqvia",
|
||||
"life360",
|
||||
"lifx",
|
||||
"locative",
|
||||
"logi_circle",
|
||||
|
|
|
@ -678,6 +678,9 @@ librouteros==2.2.0
|
|||
# homeassistant.components.soundtouch
|
||||
libsoundtouch==0.7.2
|
||||
|
||||
# homeassistant.components.life360
|
||||
life360==4.0.0
|
||||
|
||||
# homeassistant.components.lifx_legacy
|
||||
liffylights==0.9.4
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue