From 316eb59de2a0de0efcd6db3fb358f112c3e66eb8 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Tue, 20 Feb 2018 23:02:08 +0100 Subject: [PATCH] Add new component: BMW connected drive (#12277) * first working version of BMW connected drive sensor * extended coveragerc * fixed blank line * fixed pylint * major refactoring after major refactoring in bimmer_connected * Update are now triggered from BMWConnectedDriveVehicle. * removed polling from sensor and device_tracker * backend URL is not detected automatically based on current country * vehicles are discovered automatically * updates are async now resolves: * https://github.com/ChristianKuehnel/bimmer_connected/issues/3 * https://github.com/ChristianKuehnel/bimmer_connected/issues/5 * improved exception handing * fixed static analysis findings * fixed review comments from @MartinHjelmare * improved startup, data is updated right after sensors were created. * fixed pylint issue * updated to latest release of the bimmer_connected library * updated requirements-all.txt * fixed comments from @MartinHjelmare * calling self.update from async_add_job * removed unused attribute "account" --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/bmw_connected_drive.py | 105 ++++++++++++++++++ .../device_tracker/bmw_connected_drive.py | 51 +++++++++ .../components/sensor/bmw_connected_drive.py | 99 +++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 263 insertions(+) create mode 100644 homeassistant/components/bmw_connected_drive.py create mode 100644 homeassistant/components/device_tracker/bmw_connected_drive.py create mode 100644 homeassistant/components/sensor/bmw_connected_drive.py diff --git a/.coveragerc b/.coveragerc index 34e9ddbd5d2..563ea2b5387 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,6 +29,9 @@ omit = homeassistant/components/arduino.py homeassistant/components/*/arduino.py + homeassistant/components/bmw_connected_drive.py + homeassistant/components/*/bmw_connected_drive.py + homeassistant/components/android_ip_webcam.py homeassistant/components/*/android_ip_webcam.py diff --git a/CODEOWNERS b/CODEOWNERS index 846eb20b3fe..a5b5cfcb32c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -43,6 +43,7 @@ homeassistant/components/hassio.py @home-assistant/hassio # Individual components homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt +homeassistant/components/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 homeassistant/components/climate/eq3btsmart.py @rytilahti @@ -74,6 +75,7 @@ homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/axis.py @kane610 +homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline diff --git a/homeassistant/components/bmw_connected_drive.py b/homeassistant/components/bmw_connected_drive.py new file mode 100644 index 00000000000..98c25df79f6 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive.py @@ -0,0 +1,105 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/bmw_connected_drive/ +""" +import logging +import datetime + +import voluptuous as vol +from homeassistant.helpers import discovery +from homeassistant.helpers.event import track_utc_time_change + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD +) + +REQUIREMENTS = ['bimmer_connected==0.3.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'bmw_connected_drive' +CONF_VALUES = 'values' +CONF_COUNTRY = 'country' + +ACCOUNT_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_COUNTRY): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: ACCOUNT_SCHEMA + }, +}, extra=vol.ALLOW_EXTRA) + + +BMW_COMPONENTS = ['device_tracker', 'sensor'] +UPDATE_INTERVAL = 5 # in minutes + + +def setup(hass, config): + """Set up the BMW connected drive components.""" + accounts = [] + for name, account_config in config[DOMAIN].items(): + username = account_config[CONF_USERNAME] + password = account_config[CONF_PASSWORD] + country = account_config[CONF_COUNTRY] + _LOGGER.debug('Adding new account %s', name) + bimmer = BMWConnectedDriveAccount(username, password, country, name) + accounts.append(bimmer) + + # update every UPDATE_INTERVAL minutes, starting now + # this should even out the load on the servers + + now = datetime.datetime.now() + track_utc_time_change( + hass, bimmer.update, + minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), + second=now.second) + + hass.data[DOMAIN] = accounts + + for account in accounts: + account.update() + + for component in BMW_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class BMWConnectedDriveAccount(object): + """Representation of a BMW vehicle.""" + + def __init__(self, username: str, password: str, country: str, + name: str) -> None: + """Constructor.""" + from bimmer_connected.account import ConnectedDriveAccount + + self.account = ConnectedDriveAccount(username, password, country) + self.name = name + self._update_listeners = [] + + def update(self, *_): + """Update the state of all vehicles. + + Notify all listeners about the update. + """ + _LOGGER.debug('Updating vehicle state for account %s, ' + 'notifying %d listeners', + self.name, len(self._update_listeners)) + try: + self.account.update_vehicle_states() + for listener in self._update_listeners: + listener() + except IOError as exception: + _LOGGER.error('Error updating the vehicle state.') + _LOGGER.exception(exception) + + def add_update_listener(self, listener): + """Add a listener for update notifications.""" + self._update_listeners.append(listener) diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py new file mode 100644 index 00000000000..6ba2681e4cd --- /dev/null +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -0,0 +1,51 @@ +"""Device tracker for BMW Connected Drive vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN \ + as BMW_DOMAIN +from homeassistant.util import slugify + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the BMW tracker.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + for account in accounts: + for vehicle in account.account.vehicles: + tracker = BMWDeviceTracker(see, vehicle) + account.add_update_listener(tracker.update) + tracker.update() + return True + + +class BMWDeviceTracker(object): + """BMW Connected Drive device tracker.""" + + def __init__(self, see, vehicle): + """Initialize the Tracker.""" + self._see = see + self.vehicle = vehicle + + def update(self) -> None: + """Update the device info.""" + dev_id = slugify(self.vehicle.modelName) + _LOGGER.debug('Updating %s', dev_id) + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': self.vehicle.modelName + } + self._see( + dev_id=dev_id, host_name=self.vehicle.modelName, + gps=self.vehicle.state.gps_position, attributes=attrs, + icon='mdi:car' + ) diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py new file mode 100644 index 00000000000..26bfd19e6fc --- /dev/null +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -0,0 +1,99 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.bmw_connected_drive/ +""" +import logging +import asyncio + +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + +LENGTH_ATTRIBUTES = [ + 'remaining_range_fuel', + 'mileage', + ] + +VALID_ATTRIBUTES = LENGTH_ATTRIBUTES + [ + 'remaining_fuel', +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the BMW sensors.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + devices = [] + for account in accounts: + for vehicle in account.account.vehicles: + for sensor in VALID_ATTRIBUTES: + device = BMWConnectedDriveSensor(account, vehicle, sensor) + devices.append(device) + add_devices(devices) + + +class BMWConnectedDriveSensor(Entity): + """Representation of a BMW vehicle sensor.""" + + def __init__(self, account, vehicle, attribute: str): + """Constructor.""" + self._vehicle = vehicle + self._account = account + self._attribute = attribute + self._state = None + self._unit_of_measurement = None + self._name = '{} {}'.format(self._vehicle.modelName, self._attribute) + + @property + def should_poll(self) -> bool: + """Data update is triggered from BMWConnectedDriveEntity.""" + return False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor. + + The return type of this call depends on the attribute that + is configured. + """ + return self._state + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement.""" + return self._unit_of_measurement + + def update(self) -> None: + """Read new state data from the library.""" + _LOGGER.debug('Updating %s', self.entity_id) + vehicle_state = self._vehicle.state + self._state = getattr(vehicle_state, self._attribute) + + if self._attribute in LENGTH_ATTRIBUTES: + self._unit_of_measurement = vehicle_state.unit_of_length + elif self._attribute == 'remaining_fuel': + self._unit_of_measurement = vehicle_state.unit_of_volume + else: + self._unit_of_measurement = None + + self.schedule_update_ha_state() + + @asyncio.coroutine + def async_added_to_hass(self): + """Add callback after being added to hass. + + Show latest data after startup. + """ + self._account.add_update_listener(self.update) + yield from self.hass.async_add_job(self.update) diff --git a/requirements_all.txt b/requirements_all.txt index eb10e28fc43..d90078ad402 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -135,6 +135,9 @@ beautifulsoup4==4.6.0 # homeassistant.components.zha bellows==0.5.0 +# homeassistant.components.bmw_connected_drive +bimmer_connected==0.3.0 + # homeassistant.components.blink blinkpy==0.6.0