core/homeassistant/components/automatic/device_tracker.py

362 lines
12 KiB
Python

"""Support for the Automatic platform."""
import asyncio
from datetime import timedelta
import json
import logging
import os
import aioautomatic
from aiohttp import web
import voluptuous as vol
from homeassistant.components.device_tracker import (
ATTR_ATTRIBUTES,
ATTR_DEV_ID,
ATTR_GPS,
ATTR_GPS_ACCURACY,
ATTR_HOST_NAME,
ATTR_MAC,
PLATFORM_SCHEMA,
)
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
_LOGGER = logging.getLogger(__name__)
ATTR_FUEL_LEVEL = "fuel_level"
CONF_CLIENT_ID = "client_id"
CONF_CURRENT_LOCATION = "current_location"
CONF_DEVICES = "devices"
CONF_SECRET = "secret"
DATA_CONFIGURING = "automatic_configurator_clients"
DATA_REFRESH_TOKEN = "refresh_token"
DEFAULT_SCOPE = ["location", "trip", "vehicle:events", "vehicle:profile"]
DEFAULT_TIMEOUT = 5
EVENT_AUTOMATIC_UPDATE = "automatic_update"
FULL_SCOPE = DEFAULT_SCOPE + ["current_location"]
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_SECRET): cv.string,
vol.Optional(CONF_CURRENT_LOCATION, default=False): cv.boolean,
vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]),
}
)
def _get_refresh_token_from_file(hass, filename):
"""Attempt to load session data from file."""
path = hass.config.path(filename)
if not os.path.isfile(path):
return None
try:
with open(path) as data_file:
data = json.load(data_file)
if data is None:
return None
return data.get(DATA_REFRESH_TOKEN)
except ValueError:
return None
def _write_refresh_token_to_file(hass, filename, refresh_token):
"""Attempt to store session data to file."""
path = hass.config.path(filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w+") as data_file:
json.dump({DATA_REFRESH_TOKEN: refresh_token}, data_file)
@asyncio.coroutine
def async_setup_scanner(hass, config, async_see, discovery_info=None):
"""Validate the configuration and return an Automatic scanner."""
hass.http.register_view(AutomaticAuthCallbackView())
scope = FULL_SCOPE if config.get(CONF_CURRENT_LOCATION) else DEFAULT_SCOPE
client = aioautomatic.Client(
client_id=config[CONF_CLIENT_ID],
client_secret=config[CONF_SECRET],
client_session=async_get_clientsession(hass),
request_kwargs={"timeout": DEFAULT_TIMEOUT},
)
filename = f".automatic/session-{config[CONF_CLIENT_ID]}.json"
refresh_token = yield from hass.async_add_job(
_get_refresh_token_from_file, hass, filename
)
@asyncio.coroutine
def initialize_data(session):
"""Initialize the AutomaticData object from the created session."""
hass.async_add_job(
_write_refresh_token_to_file, hass, filename, session.refresh_token
)
data = AutomaticData(hass, client, session, config.get(CONF_DEVICES), async_see)
# Load the initial vehicle data
vehicles = yield from session.get_vehicles()
for vehicle in vehicles:
hass.async_create_task(data.load_vehicle(vehicle))
# Create a task instead of adding a tracking job, since this task will
# run until the websocket connection is closed.
hass.loop.create_task(data.ws_connect())
if refresh_token is not None:
try:
session = yield from client.create_session_from_refresh_token(refresh_token)
yield from initialize_data(session)
return True
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err))
configurator = hass.components.configurator
request_id = configurator.async_request_config(
"Automatic",
description=("Authorization required for Automatic device tracker."),
link_name="Click here to authorize Home Assistant.",
link_url=client.generate_oauth_url(scope),
entity_picture="/static/images/logo_automatic.png",
)
@asyncio.coroutine
def initialize_callback(code, state):
"""Call after OAuth2 response is returned."""
try:
session = yield from client.create_session_from_oauth_code(code, state)
yield from initialize_data(session)
configurator.async_request_done(request_id)
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err))
configurator.async_notify_errors(request_id, str(err))
return False
if DATA_CONFIGURING not in hass.data:
hass.data[DATA_CONFIGURING] = {}
hass.data[DATA_CONFIGURING][client.state] = initialize_callback
return True
class AutomaticAuthCallbackView(HomeAssistantView):
"""Handle OAuth finish callback requests."""
requires_auth = False
url = "/api/automatic/callback"
name = "api:automatic:callback"
@callback
def get(self, request): # pylint: disable=no-self-use
"""Finish OAuth callback request."""
hass = request.app["hass"]
params = request.query
response = web.HTTPFound("/states")
if "state" not in params or "code" not in params:
if "error" in params:
_LOGGER.error("Error authorizing Automatic: %s", params["error"])
return response
_LOGGER.error("Error authorizing Automatic. Invalid response returned")
return response
if (
DATA_CONFIGURING not in hass.data
or params["state"] not in hass.data[DATA_CONFIGURING]
):
_LOGGER.error("Automatic configuration request not found")
return response
code = params["code"]
state = params["state"]
initialize_callback = hass.data[DATA_CONFIGURING][state]
hass.async_create_task(initialize_callback(code, state))
return response
class AutomaticData:
"""A class representing an Automatic cloud service connection."""
def __init__(self, hass, client, session, devices, async_see):
"""Initialize the automatic device scanner."""
self.hass = hass
self.devices = devices
self.vehicle_info = {}
self.vehicle_seen = {}
self.client = client
self.session = session
self.async_see = async_see
self.ws_reconnect_handle = None
self.ws_close_requested = False
self.client.on_app_event(
lambda name, event: self.hass.async_create_task(
self.handle_event(name, event)
)
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.ws_close())
@asyncio.coroutine
def handle_event(self, name, event):
"""Coroutine to update state for a real time event."""
self.hass.bus.async_fire(EVENT_AUTOMATIC_UPDATE, event.data)
if event.vehicle.id not in self.vehicle_info:
# If vehicle hasn't been seen yet, request the detailed
# info for this vehicle.
_LOGGER.info("New vehicle found")
try:
vehicle = yield from event.get_vehicle()
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err))
return
yield from self.get_vehicle_info(vehicle)
if event.created_at < self.vehicle_seen[event.vehicle.id]:
# Skip events received out of order
_LOGGER.debug(
"Skipping out of order event. Event Created %s. Last seen event: %s",
event.created_at,
self.vehicle_seen[event.vehicle.id],
)
return
self.vehicle_seen[event.vehicle.id] = event.created_at
kwargs = self.vehicle_info[event.vehicle.id]
if kwargs is None:
# Ignored device
return
# If this is a vehicle status report, update the fuel level
if name == "vehicle:status_report":
fuel_level = event.vehicle.fuel_level_percent
if fuel_level is not None:
kwargs[ATTR_ATTRIBUTES][ATTR_FUEL_LEVEL] = fuel_level
# Send the device seen notification
if event.location is not None:
kwargs[ATTR_GPS] = (event.location.lat, event.location.lon)
kwargs[ATTR_GPS_ACCURACY] = event.location.accuracy_m
yield from self.async_see(**kwargs)
@asyncio.coroutine
def ws_connect(self, now=None):
"""Open the websocket connection."""
self.ws_close_requested = False
if self.ws_reconnect_handle is not None:
_LOGGER.debug("Retrying websocket connection")
try:
ws_loop_future = yield from self.client.ws_connect()
except aioautomatic.exceptions.UnauthorizedClientError:
_LOGGER.error(
"Client unauthorized for websocket connection. "
"Ensure Websocket is selected in the Automatic "
"developer application event delivery preferences"
)
return
except aioautomatic.exceptions.AutomaticError as err:
if self.ws_reconnect_handle is None:
# Show log error and retry connection every 5 minutes
_LOGGER.error("Error opening websocket connection: %s", err)
self.ws_reconnect_handle = async_track_time_interval(
self.hass, self.ws_connect, timedelta(minutes=5)
)
return
if self.ws_reconnect_handle is not None:
self.ws_reconnect_handle()
self.ws_reconnect_handle = None
_LOGGER.info("Websocket connected")
try:
yield from ws_loop_future
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err))
_LOGGER.info("Websocket closed")
# If websocket was close was not requested, attempt to reconnect
if not self.ws_close_requested:
self.hass.loop.create_task(self.ws_connect())
@asyncio.coroutine
def ws_close(self):
"""Close the websocket connection."""
self.ws_close_requested = True
if self.ws_reconnect_handle is not None:
self.ws_reconnect_handle()
self.ws_reconnect_handle = None
yield from self.client.ws_close()
@asyncio.coroutine
def load_vehicle(self, vehicle):
"""Load the vehicle's initial state and update hass."""
kwargs = yield from self.get_vehicle_info(vehicle)
yield from self.async_see(**kwargs)
@asyncio.coroutine
def get_vehicle_info(self, vehicle):
"""Fetch the latest vehicle info from automatic."""
name = vehicle.display_name
if name is None:
name = " ".join(
filter(None, (str(vehicle.year), vehicle.make, vehicle.model))
)
if self.devices is not None and name not in self.devices:
self.vehicle_info[vehicle.id] = None
return
self.vehicle_info[vehicle.id] = kwargs = {
ATTR_DEV_ID: vehicle.id,
ATTR_HOST_NAME: name,
ATTR_MAC: vehicle.id,
ATTR_ATTRIBUTES: {ATTR_FUEL_LEVEL: vehicle.fuel_level_percent},
}
self.vehicle_seen[vehicle.id] = vehicle.updated_at or vehicle.created_at
if vehicle.latest_location is not None:
location = vehicle.latest_location
kwargs[ATTR_GPS] = (location.lat, location.lon)
kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m
return kwargs
trips = []
try:
# Get the most recent trip for this vehicle
trips = yield from self.session.get_trips(vehicle=vehicle.id, limit=1)
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err))
if trips:
location = trips[0].end_location
kwargs[ATTR_GPS] = (location.lat, location.lon)
kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m
if trips[0].ended_at >= self.vehicle_seen[vehicle.id]:
self.vehicle_seen[vehicle.id] = trips[0].ended_at
return kwargs