* Moved climate components with tests into platform dirs. * Updated tests from climate component. * Moved binary_sensor components with tests into platform dirs. * Updated tests from binary_sensor component. * Moved calendar components with tests into platform dirs. * Updated tests from calendar component. * Moved camera components with tests into platform dirs. * Updated tests from camera component. * Moved cover components with tests into platform dirs. * Updated tests from cover component. * Moved device_tracker components with tests into platform dirs. * Updated tests from device_tracker component. * Moved fan components with tests into platform dirs. * Updated tests from fan component. * Moved geo_location components with tests into platform dirs. * Updated tests from geo_location component. * Moved image_processing components with tests into platform dirs. * Updated tests from image_processing component. * Moved light components with tests into platform dirs. * Updated tests from light component. * Moved lock components with tests into platform dirs. * Moved media_player components with tests into platform dirs. * Updated tests from media_player component. * Moved scene components with tests into platform dirs. * Moved sensor components with tests into platform dirs. * Updated tests from sensor component. * Moved switch components with tests into platform dirs. * Updated tests from sensor component. * Moved vacuum components with tests into platform dirs. * Updated tests from vacuum component. * Moved weather components with tests into platform dirs. * Fixed __init__.py files * Fixes for stuff moved as part of this branch. * Fix stuff needed to merge with balloob's branch. * Formatting issues. * Missing __init__.py files. * Fix-ups * Fixup * Regenerated requirements. * Linting errors fixed. * Fixed more broken tests. * Missing init files. * Fix broken tests. * More broken tests * There seems to be a thread race condition. I suspect the logger stuff is running in another thread, which means waiting until the aio loop is done is missing the log messages. Used sleep instead because that allows the logger thread to run. I think the api_streams sensor might not be thread safe. * Disabled tests, will remove sensor in #22147 * Updated coverage and codeowners.
364 lines
13 KiB
Python
364 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_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."""
|
|
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
|