diff --git a/.coveragerc b/.coveragerc index d2c271acd59..aea77eb9977 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index a391241ed4c..b17d5a354dc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/life360/.translations/en.json b/homeassistant/components/life360/.translations/en.json new file mode 100644 index 00000000000..cff3f39e5d5 --- /dev/null +++ b/homeassistant/components/life360/.translations/en.json @@ -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" + } + } +} diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py new file mode 100644 index 00000000000..3cb5ad83304 --- /dev/null +++ b/homeassistant/components/life360/__init__.py @@ -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 diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py new file mode 100644 index 00000000000..2ec7d34610e --- /dev/null +++ b/homeassistant/components/life360/config_flow.py @@ -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 + } + ) diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py new file mode 100644 index 00000000000..4c4016c6b40 --- /dev/null +++ b/homeassistant/components/life360/const.py @@ -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' diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py new file mode 100644 index 00000000000..1f8574b2d72 --- /dev/null +++ b/homeassistant/components/life360/device_tracker.py @@ -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) diff --git a/homeassistant/components/life360/helpers.py b/homeassistant/components/life360/helpers.py new file mode 100644 index 00000000000..0eb215743df --- /dev/null +++ b/homeassistant/components/life360/helpers.py @@ -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) diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json new file mode 100644 index 00000000000..27d1b1f4c93 --- /dev/null +++ b/homeassistant/components/life360/manifest.json @@ -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" + ] +} diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json new file mode 100644 index 00000000000..cff3f39e5d5 --- /dev/null +++ b/homeassistant/components/life360/strings.json @@ -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" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 955cdf3c8c4..9b789af473e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -26,6 +26,7 @@ FLOWS = [ "ios", "ipma", "iqvia", + "life360", "lifx", "locative", "logi_circle", diff --git a/requirements_all.txt b/requirements_all.txt index 64040c61060..8f5056e2e65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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