Use third-party lib aioautomatic for automatic (#7126)

This commit is contained in:
Adam Mills 2017-04-15 21:11:36 -04:00 committed by Paulus Schoutsen
parent 815422a886
commit 35de3a1dc4
3 changed files with 146 additions and 321 deletions

View File

@ -4,19 +4,20 @@ Support for the Automatic platform.
For more details about this platform, please refer to the documentation at For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/device_tracker.automatic/ https://home-assistant.io/components/device_tracker.automatic/
""" """
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
import re
import requests
import voluptuous as vol import voluptuous as vol
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
PLATFORM_SCHEMA, ATTR_ATTRIBUTES) PLATFORM_SCHEMA, ATTR_ATTRIBUTES)
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import datetime as dt_util
REQUIREMENTS = ['aioautomatic==0.1.1']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -24,129 +25,101 @@ CONF_CLIENT_ID = 'client_id'
CONF_SECRET = 'secret' CONF_SECRET = 'secret'
CONF_DEVICES = 'devices' CONF_DEVICES = 'devices'
SCOPE = 'scope:location scope:vehicle:profile scope:user:profile scope:trip' DEFAULT_TIMEOUT = 5
ATTR_ACCESS_TOKEN = 'access_token'
ATTR_EXPIRES_IN = 'expires_in'
ATTR_RESULTS = 'results'
ATTR_VEHICLE = 'vehicle'
ATTR_ENDED_AT = 'ended_at'
ATTR_END_LOCATION = 'end_location'
URL_AUTHORIZE = 'https://accounts.automatic.com/oauth/access_token/'
URL_VEHICLES = 'https://api.automatic.com/vehicle/'
URL_TRIPS = 'https://api.automatic.com/trip/'
_VEHICLE_ID_REGEX = re.compile(
(URL_VEHICLES + '(.*)?[/]$').replace('/', r'\/'))
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_SECRET): cv.string, vol.Required(CONF_SECRET): cv.string,
vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]) vol.Optional(CONF_DEVICES, default=None): vol.All(
cv.ensure_list, [cv.string])
}) })
def setup_scanner(hass, config: dict, see, discovery_info=None): @asyncio.coroutine
def async_setup_scanner(hass, config, async_see, discovery_info=None):
"""Validate the configuration and return an Automatic scanner.""" """Validate the configuration and return an Automatic scanner."""
import aioautomatic
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})
try: try:
AutomaticDeviceScanner(hass, config, see) session = yield from client.create_session_from_password(
except requests.HTTPError as err: config[CONF_USERNAME], config[CONF_PASSWORD])
data = AutomaticData(hass, session, config[CONF_DEVICES], async_see)
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err)) _LOGGER.error(str(err))
return False return False
yield from data.update()
return True return True
class AutomaticDeviceScanner(object): class AutomaticData(object):
"""A class representing an Automatic device.""" """A class representing an Automatic cloud service connection."""
def __init__(self, hass, config: dict, see) -> None: def __init__(self, hass, session, devices, async_see):
"""Initialize the automatic device scanner.""" """Initialize the automatic device scanner."""
self.hass = hass self.hass = hass
self._devices = config.get(CONF_DEVICES, None) self.devices = devices
self._access_token_payload = { self.session = session
'username': config.get(CONF_USERNAME), self.async_see = async_see
'password': config.get(CONF_PASSWORD),
'client_id': config.get(CONF_CLIENT_ID),
'client_secret': config.get(CONF_SECRET),
'grant_type': 'password',
'scope': SCOPE
}
self._headers = None
self._token_expires = dt_util.now()
self.last_results = {}
self.last_trips = {}
self.see = see
self._update_info() async_track_time_interval(hass, self.update, timedelta(seconds=30))
track_utc_time_change(self.hass, self._update_info, @asyncio.coroutine
second=range(0, 60, 30)) def update(self, now=None):
def _update_headers(self):
"""Get the access token from automatic."""
if self._headers is None or self._token_expires <= dt_util.now():
resp = requests.post(
URL_AUTHORIZE,
data=self._access_token_payload)
resp.raise_for_status()
json = resp.json()
access_token = json[ATTR_ACCESS_TOKEN]
self._token_expires = dt_util.now() + timedelta(
seconds=json[ATTR_EXPIRES_IN])
self._headers = {
'Authorization': 'Bearer {}'.format(access_token)
}
def _update_info(self, now=None) -> None:
"""Update the device info.""" """Update the device info."""
import aioautomatic
_LOGGER.debug('Updating devices %s', now) _LOGGER.debug('Updating devices %s', now)
self._update_headers()
response = requests.get(URL_VEHICLES, headers=self._headers) try:
vehicles = yield from self.session.get_vehicles()
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err))
return False
response.raise_for_status() for vehicle in vehicles:
name = vehicle.display_name
if name is None:
name = ' '.join(filter(None, (
str(vehicle.year), vehicle.make, vehicle.model)))
self.last_results = [item for item in response.json()[ATTR_RESULTS] if self.devices is not None and name not in self.devices:
if self._devices is None or item[ continue
'display_name'] in self._devices]
response = requests.get(URL_TRIPS, headers=self._headers) self.hass.async_add_job(self.update_vehicle(vehicle, name))
if response.status_code == 200: @asyncio.coroutine
for trip in response.json()[ATTR_RESULTS]: def update_vehicle(self, vehicle, name):
vehicle_id = _VEHICLE_ID_REGEX.match( """Updated the specified vehicle's data."""
trip[ATTR_VEHICLE]).group(1) import aioautomatic
if vehicle_id not in self.last_trips:
self.last_trips[vehicle_id] = trip
elif self.last_trips[vehicle_id][ATTR_ENDED_AT] < trip[
ATTR_ENDED_AT]:
self.last_trips[vehicle_id] = trip
for vehicle in self.last_results:
dev_id = vehicle.get('id')
host_name = vehicle.get('display_name')
attrs = {
'fuel_level': vehicle.get('fuel_level_percent')
}
kwargs = { kwargs = {
'dev_id': dev_id, 'dev_id': vehicle.id,
'host_name': host_name, 'host_name': name,
'mac': dev_id, 'mac': vehicle.id,
ATTR_ATTRIBUTES: attrs ATTR_ATTRIBUTES: {
'fuel_level': vehicle.fuel_level_percent,
}
} }
if dev_id in self.last_trips: trips = []
end_location = self.last_trips[dev_id][ATTR_END_LOCATION] try:
kwargs['gps'] = (end_location['lat'], end_location['lon']) # Get the most recent trip for this vehicle
kwargs['gps_accuracy'] = end_location['accuracy_m'] trips = yield from self.session.get_trips(
vehicle=vehicle.id, limit=1)
except aioautomatic.exceptions.AutomaticError as err:
_LOGGER.error(str(err))
self.see(**kwargs) if trips:
end_location = trips[0].end_location
kwargs['gps'] = (end_location.lat, end_location.lon)
kwargs['gps_accuracy'] = end_location.accuracy_m
yield from self.async_see(**kwargs)

View File

@ -37,6 +37,9 @@ SoCo==0.12
# homeassistant.components.notify.twitter # homeassistant.components.notify.twitter
TwitterAPI==2.4.5 TwitterAPI==2.4.5
# homeassistant.components.device_tracker.automatic
aioautomatic==0.1.1
# homeassistant.components.sensor.dnsip # homeassistant.components.sensor.dnsip
aiodns==1.1.1 aiodns==1.1.1

View File

@ -1,241 +1,90 @@
"""Test the automatic device tracker platform.""" """Test the automatic device tracker platform."""
import asyncio
import logging import logging
import requests from unittest.mock import patch, MagicMock
import unittest import aioautomatic
from unittest.mock import patch
from homeassistant.components.device_tracker.automatic import ( from homeassistant.components.device_tracker.automatic import (
URL_AUTHORIZE, URL_VEHICLES, URL_TRIPS, setup_scanner) async_setup_scanner)
from tests.common import get_test_home_assistant
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
INVALID_USERNAME = 'bob'
VALID_USERNAME = 'jim'
PASSWORD = 'password'
CLIENT_ID = '12345'
CLIENT_SECRET = '54321'
FUEL_LEVEL = 77.2
LATITUDE = 32.82336
LONGITUDE = -117.23743
ACCURACY = 8
DISPLAY_NAME = 'My Vehicle'
@patch('aioautomatic.Client.create_session_from_password')
def test_invalid_credentials(mock_create_session, hass):
"""Test with invalid credentials."""
@asyncio.coroutine
def get_session(*args, **kwargs):
"""Return the test session."""
raise aioautomatic.exceptions.ForbiddenError()
def mocked_requests(*args, **kwargs): mock_create_session.side_effect = get_session
"""Mock requests.get invocations."""
class MockResponse:
"""Class to represent a mocked response."""
def __init__(self, json_data, status_code):
"""Initialize the mock response class."""
self.json_data = json_data
self.status_code = status_code
def json(self):
"""Return the json of the response."""
return self.json_data
@property
def content(self):
"""Return the content of the response."""
return self.json()
def raise_for_status(self):
"""Raise an HTTPError if status is not 200."""
if self.status_code != 200:
raise requests.HTTPError(self.status_code)
data = kwargs.get('data')
if data and data.get('username', None) == INVALID_USERNAME:
return MockResponse({
"error": "invalid_credentials"
}, 401)
elif str(args[0]).startswith(URL_AUTHORIZE):
return MockResponse({
"user": {
"sid": "sid",
"id": "id"
},
"token_type": "Bearer",
"access_token": "accesstoken",
"refresh_token": "refreshtoken",
"expires_in": 31521669,
"scope": ""
}, 200)
elif str(args[0]).startswith(URL_VEHICLES):
return MockResponse({
"_metadata": {
"count": 2,
"next": None,
"previous": None
},
"results": [
{
"url": "https://api.automatic.com/vehicle/vid/",
"id": "vid",
"created_at": "2016-03-05T20:05:16.240000Z",
"updated_at": "2016-08-29T01:52:59.597898Z",
"make": "Honda",
"model": "Element",
"year": 2007,
"submodel": "EX",
"display_name": DISPLAY_NAME,
"fuel_grade": "regular",
"fuel_level_percent": FUEL_LEVEL,
"active_dtcs": []
}]
}, 200)
elif str(args[0]).startswith(URL_TRIPS):
return MockResponse({
"_metadata": {
"count": 1594,
"next": "https://api.automatic.com/trip/?page=2",
"previous": None
},
"results": [
{
"url": "https://api.automatic.com/trip/tid1/",
"id": "tid1",
"driver": "https://api.automatic.com/user/uid/",
"user": "https://api.automatic.com/user/uid/",
"started_at": "2016-08-28T19:37:23.986000Z",
"ended_at": "2016-08-28T19:43:22.500000Z",
"distance_m": 3931.6,
"duration_s": 358.5,
"vehicle": "https://api.automatic.com/vehicle/vid/",
"start_location": {
"lat": 32.87336,
"lon": -117.22743,
"accuracy_m": 10
},
"start_address": {
"name": "123 Fake St, Nowhere, NV 12345",
"display_name": "123 Fake St, Nowhere, NV",
"street_number": "Unknown",
"street_name": "Fake St",
"city": "Nowhere",
"state": "NV",
"country": "US"
},
"end_location": {
"lat": LATITUDE,
"lon": LONGITUDE,
"accuracy_m": ACCURACY
},
"end_address": {
"name": "321 Fake St, Nowhere, NV 12345",
"display_name": "321 Fake St, Nowhere, NV",
"street_number": "Unknown",
"street_name": "Fake St",
"city": "Nowhere",
"state": "NV",
"country": "US"
},
"path": "path",
"vehicle_events": [],
"start_timezone": "America/Denver",
"end_timezone": "America/Denver",
"idling_time_s": 0,
"tags": []
},
{
"url": "https://api.automatic.com/trip/tid2/",
"id": "tid2",
"driver": "https://api.automatic.com/user/uid/",
"user": "https://api.automatic.com/user/uid/",
"started_at": "2016-08-28T18:48:00.727000Z",
"ended_at": "2016-08-28T18:55:25.800000Z",
"distance_m": 3969.1,
"duration_s": 445.1,
"vehicle": "https://api.automatic.com/vehicle/vid/",
"start_location": {
"lat": 32.87336,
"lon": -117.22743,
"accuracy_m": 11
},
"start_address": {
"name": "123 Fake St, Nowhere, NV, USA",
"display_name": "Fake St, Nowhere, NV",
"street_number": "123",
"street_name": "Fake St",
"city": "Nowhere",
"state": "NV",
"country": "US"
},
"end_location": {
"lat": 32.82336,
"lon": -117.23743,
"accuracy_m": 10
},
"end_address": {
"name": "321 Fake St, Nowhere, NV, USA",
"display_name": "Fake St, Nowhere, NV",
"street_number": "Unknown",
"street_name": "Fake St",
"city": "Nowhere",
"state": "NV",
"country": "US"
},
"path": "path",
"vehicle_events": [],
"start_timezone": "America/Denver",
"end_timezone": "America/Denver",
"idling_time_s": 0,
"tags": []
}
]
}, 200)
else:
_LOGGER.debug('UNKNOWN ROUTE')
class TestAutomatic(unittest.TestCase):
"""Test cases around the automatic device scanner."""
def see_mock(self, **kwargs):
"""Mock see function."""
self.assertEqual('vid', kwargs.get('dev_id'))
self.assertEqual(FUEL_LEVEL,
kwargs.get('attributes', {}).get('fuel_level'))
self.assertEqual((LATITUDE, LONGITUDE), kwargs.get('gps'))
self.assertEqual(ACCURACY, kwargs.get('gps_accuracy'))
def setUp(self):
"""Set up test data."""
self.hass = get_test_home_assistant()
def tearDown(self):
"""Tear down test data."""
self.hass.stop()
@patch('requests.get', side_effect=mocked_requests)
@patch('requests.post', side_effect=mocked_requests)
def test_invalid_credentials(self, mock_get, mock_post):
"""Test error is raised with invalid credentials."""
config = { config = {
'platform': 'automatic', 'platform': 'automatic',
'username': INVALID_USERNAME, 'username': 'bad_username',
'password': PASSWORD, 'password': 'bad_password',
'client_id': CLIENT_ID, 'client_id': 'client_id',
'secret': CLIENT_SECRET 'secret': 'client_secret',
'devices': None,
} }
result = hass.loop.run_until_complete(
async_setup_scanner(hass, config, None))
assert not result
self.assertFalse(setup_scanner(self.hass, config, self.see_mock))
@patch('requests.get', side_effect=mocked_requests) @patch('aioautomatic.Client.create_session_from_password')
@patch('requests.post', side_effect=mocked_requests) def test_valid_credentials(mock_create_session, hass):
def test_valid_credentials(self, mock_get, mock_post): """Test with valid credentials."""
"""Test error is raised with invalid credentials.""" session = MagicMock()
vehicle = MagicMock()
trip = MagicMock()
mock_see = MagicMock()
vehicle.id = 'mock_id'
vehicle.display_name = 'mock_display_name'
vehicle.fuel_level_percent = 45.6
trip.end_location.lat = 45.567
trip.end_location.lon = 34.345
trip.end_location.accuracy_m = 5.6
@asyncio.coroutine
def get_session(*args, **kwargs):
"""Return the test session."""
return session
@asyncio.coroutine
def get_vehicles(*args, **kwargs):
"""Return list of test vehicles."""
return [vehicle]
@asyncio.coroutine
def get_trips(*args, **kwargs):
"""Return list of test trips."""
return [trip]
mock_create_session.side_effect = get_session
session.get_vehicles.side_effect = get_vehicles
session.get_trips.side_effect = get_trips
config = { config = {
'platform': 'automatic', 'platform': 'automatic',
'username': VALID_USERNAME, 'username': 'bad_username',
'password': PASSWORD, 'password': 'bad_password',
'client_id': CLIENT_ID, 'client_id': 'client_id',
'secret': CLIENT_SECRET 'secret': 'client_secret',
'devices': None,
} }
result = hass.loop.run_until_complete(
async_setup_scanner(hass, config, mock_see))
self.assertTrue(setup_scanner(self.hass, config, self.see_mock)) assert result
assert mock_see.called
assert len(mock_see.mock_calls) == 2
assert mock_see.mock_calls[0][2]['dev_id'] == 'mock_id'
assert mock_see.mock_calls[0][2]['mac'] == 'mock_id'
assert mock_see.mock_calls[0][2]['host_name'] == 'mock_display_name'
assert mock_see.mock_calls[0][2]['attributes'] == {'fuel_level': 45.6}
assert mock_see.mock_calls[0][2]['gps'] == (45.567, 34.345)
assert mock_see.mock_calls[0][2]['gps_accuracy'] == 5.6