diff --git a/.coveragerc b/.coveragerc index f4ce71326b6..ed969e54511 100644 --- a/.coveragerc +++ b/.coveragerc @@ -201,6 +201,7 @@ omit = homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py + homeassistant/components/device_tracker/automatic.py homeassistant/components/device_tracker/bbox.py homeassistant/components/device_tracker/bluetooth_le_tracker.py homeassistant/components/device_tracker/bluetooth_tracker.py diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 7fb665a7cc3..56dccd75d6d 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -11,13 +11,17 @@ import logging import voluptuous as vol from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, ATTR_ATTRIBUTES) -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + PLATFORM_SCHEMA, ATTR_ATTRIBUTES, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_MAC, + ATTR_GPS, ATTR_GPS_ACCURACY) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START) +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.2.1'] +REQUIREMENTS = ['aioautomatic==0.3.1'] _LOGGER = logging.getLogger(__name__) @@ -29,6 +33,10 @@ DEFAULT_TIMEOUT = 5 SCOPE = ['location', 'vehicle:profile', 'trip'] +ATTR_FUEL_LEVEL = 'fuel_level' + +EVENT_AUTOMATIC_UPDATE = 'automatic_update' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_SECRET): cv.string, @@ -52,64 +60,172 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): try: session = yield from client.create_session_from_password( SCOPE, config[CONF_USERNAME], config[CONF_PASSWORD]) - data = AutomaticData(hass, session, config[CONF_DEVICES], async_see) + data = AutomaticData( + hass, client, session, config[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)) except aioautomatic.exceptions.AutomaticError as err: _LOGGER.error(str(err)) return False - yield from data.update() + @callback + def ws_connect(event): + """Open the websocket connection.""" + hass.async_add_job(data.ws_connect()) + + @callback + def ws_close(event): + """Close the websocket connection.""" + hass.async_add_job(data.ws_close()) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, ws_connect) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, ws_close) + return True class AutomaticData(object): """A class representing an Automatic cloud service connection.""" - def __init__(self, hass, session, devices, async_see): + def __init__(self, hass, client, session, devices, async_see): """Initialize the automatic device scanner.""" self.hass = hass self.devices = devices + self.vehicle_info = {} + self.client = client self.session = session self.async_see = async_see + self.ws_reconnect_handle = None + self.ws_close_requested = False - async_track_time_interval(hass, self.update, timedelta(seconds=30)) + self.client.on_app_event( + lambda name, event: self.hass.async_add_job( + self.handle_event(name, event))) @asyncio.coroutine - def update(self, now=None): - """Update the device info.""" + def handle_event(self, name, event): + """Coroutine to update state for a realtime event.""" import aioautomatic - _LOGGER.debug('Updating devices %s', now) + # Fire a hass 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) + + 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: - vehicles = yield from self.session.get_vehicles() + yield from ws_loop_future except aioautomatic.exceptions.AutomaticError as err: _LOGGER.error(str(err)) - return False - for vehicle in vehicles: - name = vehicle.display_name - if name is None: - name = ' '.join(filter(None, ( - str(vehicle.year), vehicle.make, vehicle.model))) + _LOGGER.info("Websocket closed.") - if self.devices is not None and name not in self.devices: - continue - - self.hass.async_add_job(self.update_vehicle(vehicle, name)) + # 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 update_vehicle(self, vehicle, name): - """Update the specified vehicle's data.""" + 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 - kwargs = { - 'dev_id': vehicle.id, - 'host_name': name, - 'mac': vehicle.id, - ATTR_ATTRIBUTES: { - 'fuel_level': vehicle.fuel_level_percent, + 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 + else: + 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, } - } + } + + 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: @@ -120,8 +236,8 @@ class AutomaticData(object): _LOGGER.error(str(err)) if trips: - end_location = trips[0].end_location - kwargs['gps'] = (end_location.lat, end_location.lon) - kwargs['gps_accuracy'] = end_location.accuracy_m + location = trips[0].end_location + kwargs[ATTR_GPS] = (location.lat, location.lon) + kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m - yield from self.async_see(**kwargs) + return kwargs diff --git a/requirements_all.txt b/requirements_all.txt index fd35592fb63..64f6500638e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -38,7 +38,7 @@ SoCo==0.12 TwitterAPI==2.4.5 # homeassistant.components.device_tracker.automatic -aioautomatic==0.2.1 +aioautomatic==0.3.1 # homeassistant.components.sensor.dnsip aiodns==1.1.1 diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index dd03fd1da57..f823f3c3262 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -44,6 +44,7 @@ def test_valid_credentials(mock_create_session, hass): vehicle.id = 'mock_id' vehicle.display_name = 'mock_display_name' vehicle.fuel_level_percent = 45.6 + vehicle.latest_location = None trip.end_location.lat = 45.567 trip.end_location.lon = 34.345