core/homeassistant/components/device_tracker/automatic.py

365 lines
13 KiB
Python

"""
Support for the Automatic platform.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/device_tracker.automatic/
"""
import asyncio
from datetime import timedelta
import json
import logging
import os
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
REQUIREMENTS = ['aioautomatic==0.6.5']
_LOGGER = logging.getLogger(__name__)
ATTR_FUEL_LEVEL = 'fuel_level'
AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json'
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
DEPENDENCIES = ['http']
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."""
import aioautomatic
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 = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID])
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_add_job(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_add_job(initialize_callback(code, state))
return response
class AutomaticData(object):
"""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_add_job(
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."""
import aioautomatic
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."""
import aioautomatic
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."""
import aioautomatic
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