238 lines
8.3 KiB
Python
238 lines
8.3 KiB
Python
"""DataUpdateCoordinator for the Life360 integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
from life360 import Life360, Life360Error, LoginError
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import (
|
|
LENGTH_FEET,
|
|
LENGTH_KILOMETERS,
|
|
LENGTH_METERS,
|
|
LENGTH_MILES,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
from homeassistant.util.distance import convert
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
from .const import (
|
|
COMM_TIMEOUT,
|
|
CONF_AUTHORIZATION,
|
|
DOMAIN,
|
|
LOGGER,
|
|
SPEED_DIGITS,
|
|
SPEED_FACTOR_MPH,
|
|
UPDATE_INTERVAL,
|
|
)
|
|
|
|
|
|
class MissingLocReason(Enum):
|
|
"""Reason member location information is missing."""
|
|
|
|
VAGUE_ERROR_REASON = "vague error reason"
|
|
EXPLICIT_ERROR_REASON = "explicit error reason"
|
|
|
|
|
|
@dataclass
|
|
class Life360Place:
|
|
"""Life360 Place data."""
|
|
|
|
name: str
|
|
latitude: float
|
|
longitude: float
|
|
radius: float
|
|
|
|
|
|
@dataclass
|
|
class Life360Circle:
|
|
"""Life360 Circle data."""
|
|
|
|
name: str
|
|
places: dict[str, Life360Place]
|
|
|
|
|
|
@dataclass
|
|
class Life360Member:
|
|
"""Life360 Member data."""
|
|
|
|
# Don't include address field in eq comparison because it often changes (back and
|
|
# forth) between updates. If it was included there would be way more state changes
|
|
# and database updates than is useful.
|
|
address: str | None = field(compare=False)
|
|
at_loc_since: datetime
|
|
battery_charging: bool
|
|
battery_level: int
|
|
driving: bool
|
|
entity_picture: str
|
|
gps_accuracy: int
|
|
last_seen: datetime
|
|
latitude: float
|
|
longitude: float
|
|
name: str
|
|
place: str | None
|
|
speed: float
|
|
wifi_on: bool
|
|
|
|
|
|
@dataclass
|
|
class Life360Data:
|
|
"""Life360 data."""
|
|
|
|
circles: dict[str, Life360Circle] = field(init=False, default_factory=dict)
|
|
members: dict[str, Life360Member] = field(init=False, default_factory=dict)
|
|
|
|
|
|
class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]):
|
|
"""Life360 data update coordinator."""
|
|
|
|
config_entry: ConfigEntry
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Initialize data update coordinator."""
|
|
super().__init__(
|
|
hass,
|
|
LOGGER,
|
|
name=f"{DOMAIN} ({entry.unique_id})",
|
|
update_interval=UPDATE_INTERVAL,
|
|
)
|
|
self._hass = hass
|
|
self._api = Life360(
|
|
session=async_get_clientsession(hass),
|
|
timeout=COMM_TIMEOUT,
|
|
authorization=entry.data[CONF_AUTHORIZATION],
|
|
)
|
|
self._missing_loc_reason = hass.data[DOMAIN].missing_loc_reason
|
|
|
|
async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]:
|
|
"""Get data from Life360."""
|
|
try:
|
|
return await getattr(self._api, func)(*args)
|
|
except LoginError as exc:
|
|
LOGGER.debug("Login error: %s", exc)
|
|
raise ConfigEntryAuthFailed from exc
|
|
except Life360Error as exc:
|
|
LOGGER.debug("%s: %s", exc.__class__.__name__, exc)
|
|
raise UpdateFailed from exc
|
|
|
|
async def _async_update_data(self) -> Life360Data:
|
|
"""Get & process data from Life360."""
|
|
|
|
data = Life360Data()
|
|
|
|
for circle in await self._retrieve_data("get_circles"):
|
|
circle_id = circle["id"]
|
|
circle_members = await self._retrieve_data("get_circle_members", circle_id)
|
|
circle_places = await self._retrieve_data("get_circle_places", circle_id)
|
|
|
|
data.circles[circle_id] = Life360Circle(
|
|
circle["name"],
|
|
{
|
|
place["id"]: Life360Place(
|
|
place["name"],
|
|
float(place["latitude"]),
|
|
float(place["longitude"]),
|
|
float(place["radius"]),
|
|
)
|
|
for place in circle_places
|
|
},
|
|
)
|
|
|
|
for member in circle_members:
|
|
# Member isn't sharing location.
|
|
if not int(member["features"]["shareLocation"]):
|
|
continue
|
|
|
|
member_id = member["id"]
|
|
|
|
first = member["firstName"]
|
|
last = member["lastName"]
|
|
if first and last:
|
|
name = " ".join([first, last])
|
|
else:
|
|
name = first or last
|
|
|
|
cur_missing_reason = self._missing_loc_reason.get(member_id)
|
|
|
|
# Check if location information is missing. This can happen if server
|
|
# has not heard from member's device in a long time (e.g., has been off
|
|
# for a long time, or has lost service, etc.)
|
|
if loc := member["location"]:
|
|
with suppress(KeyError):
|
|
del self._missing_loc_reason[member_id]
|
|
else:
|
|
if explicit_reason := member["issues"]["title"]:
|
|
if extended_reason := member["issues"]["dialog"]:
|
|
explicit_reason += f": {extended_reason}"
|
|
# Note that different Circles can report missing location in
|
|
# different ways. E.g., one might report an explicit reason and
|
|
# another does not. If a vague reason has already been logged but a
|
|
# more explicit reason is now available, log that, too.
|
|
if (
|
|
cur_missing_reason is None
|
|
or cur_missing_reason == MissingLocReason.VAGUE_ERROR_REASON
|
|
and explicit_reason
|
|
):
|
|
if explicit_reason:
|
|
self._missing_loc_reason[
|
|
member_id
|
|
] = MissingLocReason.EXPLICIT_ERROR_REASON
|
|
err_msg = explicit_reason
|
|
else:
|
|
self._missing_loc_reason[
|
|
member_id
|
|
] = MissingLocReason.VAGUE_ERROR_REASON
|
|
err_msg = "Location information missing"
|
|
LOGGER.error("%s: %s", name, err_msg)
|
|
continue
|
|
|
|
# Note that member may be in more than one circle. If that's the case
|
|
# just go ahead and process the newly retrieved data (overwriting the
|
|
# older data), since it might be slightly newer than what was retrieved
|
|
# while processing another circle.
|
|
|
|
place = loc["name"] or None
|
|
|
|
if place:
|
|
address: str | None = place
|
|
else:
|
|
address1 = loc["address1"] or None
|
|
address2 = loc["address2"] or None
|
|
if address1 and address2:
|
|
address = ", ".join([address1, address2])
|
|
else:
|
|
address = address1 or address2
|
|
|
|
speed = max(0, float(loc["speed"]) * SPEED_FACTOR_MPH)
|
|
if self._hass.config.units.is_metric:
|
|
speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS)
|
|
|
|
data.members[member_id] = Life360Member(
|
|
address,
|
|
dt_util.utc_from_timestamp(int(loc["since"])),
|
|
bool(int(loc["charge"])),
|
|
int(float(loc["battery"])),
|
|
bool(int(loc["isDriving"])),
|
|
member["avatar"],
|
|
# Life360 reports accuracy in feet, but Device Tracker expects
|
|
# gps_accuracy in meters.
|
|
round(convert(float(loc["accuracy"]), LENGTH_FEET, LENGTH_METERS)),
|
|
dt_util.utc_from_timestamp(int(loc["timestamp"])),
|
|
float(loc["latitude"]),
|
|
float(loc["longitude"]),
|
|
name,
|
|
place,
|
|
round(speed, SPEED_DIGITS),
|
|
bool(int(loc["wifiState"])),
|
|
)
|
|
|
|
return data
|