diff --git a/.coveragerc b/.coveragerc index a264bde79a2..b51829b00bc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -145,6 +145,9 @@ omit = homeassistant/components/modbus.py homeassistant/components/*/modbus.py + homeassistant/components/mychevy.py + homeassistant/components/*/mychevy.py + homeassistant/components/mysensors.py homeassistant/components/*/mysensors.py diff --git a/homeassistant/components/binary_sensor/mychevy.py b/homeassistant/components/binary_sensor/mychevy.py new file mode 100644 index 00000000000..a89395ed86f --- /dev/null +++ b/homeassistant/components/binary_sensor/mychevy.py @@ -0,0 +1,85 @@ +"""Support for MyChevy sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.mychevy/ +""" + +import asyncio +import logging + +from homeassistant.components.mychevy import ( + EVBinarySensorConfig, DOMAIN as MYCHEVY_DOMAIN, UPDATE_TOPIC +) +from homeassistant.components.binary_sensor import ( + ENTITY_ID_FORMAT, BinarySensorDevice) +from homeassistant.core import callback +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +SENSORS = [ + EVBinarySensorConfig("Plugged In", "plugged_in", "plug") +] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the MyChevy sensors.""" + if discovery_info is None: + return + + sensors = [] + hub = hass.data[MYCHEVY_DOMAIN] + for sconfig in SENSORS: + sensors.append(EVBinarySensor(hub, sconfig)) + + async_add_devices(sensors) + + +class EVBinarySensor(BinarySensorDevice): + """Base EVSensor class. + + The only real difference between sensors is which units and what + attribute from the car object they are returning. All logic can be + built with just setting subclass attributes. + + """ + + def __init__(self, connection, config): + """Initialize sensor with car connection.""" + self._conn = connection + self._name = config.name + self._attr = config.attr + self._type = config.device_class + self._is_on = None + + self.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(MYCHEVY_DOMAIN, slugify(self._name))) + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def is_on(self): + """Return if on.""" + return self._is_on + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_update_callback) + + @callback + def async_update_callback(self): + """Update state.""" + if self._conn.car is not None: + self._is_on = getattr(self._conn.car, self._attr, None) + self.async_schedule_update_ha_state() + + @property + def should_poll(self): + """Return the polling state.""" + return False diff --git a/homeassistant/components/mychevy.py b/homeassistant/components/mychevy.py new file mode 100644 index 00000000000..3a4894a2a66 --- /dev/null +++ b/homeassistant/components/mychevy.py @@ -0,0 +1,132 @@ +""" +MyChevy Component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/mychevy/ +""" + +from datetime import timedelta +import logging +import time +import threading + +import voluptuous as vol + +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ["mychevy==0.1.1"] + +DOMAIN = 'mychevy' +UPDATE_TOPIC = DOMAIN +ERROR_TOPIC = DOMAIN + "_error" + +MYCHEVY_SUCCESS = "success" +MYCHEVY_ERROR = "error" + +NOTIFICATION_ID = 'mychevy_website_notification' +NOTIFICATION_TITLE = 'MyChevy website status' + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) +ERROR_SLEEP_TIME = timedelta(minutes=30) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + + +class EVSensorConfig(object): + """EV Sensor Config.""" + + def __init__(self, name, attr, unit_of_measurement=None, icon=None): + """Create new Sensor Config.""" + self.name = name + self.attr = attr + self.unit_of_measurement = unit_of_measurement + self.icon = icon + + +class EVBinarySensorConfig(object): + """EV Binary Sensor Config.""" + + def __init__(self, name, attr, device_class=None): + """Create new Binary Sensor Config.""" + self.name = name + self.attr = attr + self.device_class = device_class + + +def setup(hass, base_config): + """Setup mychevy platform.""" + import mychevy.mychevy as mc + + config = base_config.get(DOMAIN) + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + hass.data[DOMAIN] = MyChevyHub(mc.MyChevy(email, password), hass) + hass.data[DOMAIN].start() + + discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + + return True + + +class MyChevyHub(threading.Thread): + """MyChevy Hub. + + Connecting to the mychevy website is done through a selenium + webscraping process. That can only run synchronously. In order to + prevent blocking of other parts of Home Assistant the architecture + launches a polling loop in a thread. + + When new data is received, sensors are updated, and hass is + signaled that there are updates. Sensors are not created until the + first update, which will be 60 - 120 seconds after the platform + starts. + """ + + def __init__(self, client, hass): + """Initialize MyChevy Hub.""" + super().__init__() + self._client = client + self.hass = hass + self.car = None + self.status = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update sensors from mychevy website. + + This is a synchronous polling call that takes a very long time + (like 2 to 3 minutes long time) + + """ + self.car = self._client.data() + + def run(self): + """Thread run loop.""" + # We add the status device first outside of the loop + + # And then busy wait on threads + while True: + try: + _LOGGER.info("Starting mychevy loop") + self.update() + self.hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC) + time.sleep(MIN_TIME_BETWEEN_UPDATES.seconds) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error updating mychevy data. " + "This probably means the OnStar link is down again") + self.hass.helpers.dispatcher.dispatcher_send(ERROR_TOPIC) + time.sleep(ERROR_SLEEP_TIME.seconds) diff --git a/homeassistant/components/sensor/mychevy.py b/homeassistant/components/sensor/mychevy.py new file mode 100644 index 00000000000..bdbffc46ca8 --- /dev/null +++ b/homeassistant/components/sensor/mychevy.py @@ -0,0 +1,165 @@ +"""Support for MyChevy sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mychevy/ +""" + +import asyncio +import logging + +from homeassistant.components.mychevy import ( + EVSensorConfig, DOMAIN as MYCHEVY_DOMAIN, MYCHEVY_ERROR, MYCHEVY_SUCCESS, + NOTIFICATION_ID, NOTIFICATION_TITLE, UPDATE_TOPIC, ERROR_TOPIC +) +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util import slugify + +BATTERY_SENSOR = "percent" + +SENSORS = [ + EVSensorConfig("Mileage", "mileage", "miles", "mdi:speedometer"), + EVSensorConfig("Range", "range", "miles", "mdi:speedometer"), + EVSensorConfig("Charging", "charging"), + EVSensorConfig("Charge Mode", "charge_mode"), + EVSensorConfig("EVCharge", BATTERY_SENSOR, "%", "mdi:battery") +] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the MyChevy sensors.""" + if discovery_info is None: + return + + hub = hass.data[MYCHEVY_DOMAIN] + sensors = [MyChevyStatus()] + for sconfig in SENSORS: + sensors.append(EVSensor(hub, sconfig)) + + add_devices(sensors) + + +class MyChevyStatus(Entity): + """A string representing the charge mode.""" + + _name = "MyChevy Status" + _icon = "mdi:car-connected" + + def __init__(self): + """Initialize sensor with car connection.""" + self._state = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.success) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + ERROR_TOPIC, self.error) + + @callback + def success(self): + """Update state, trigger updates.""" + if self._state != MYCHEVY_SUCCESS: + _LOGGER.debug("Successfully connected to mychevy website") + self._state = MYCHEVY_SUCCESS + self.async_schedule_update_ha_state() + + @callback + def error(self): + """Update state, trigger updates.""" + if self._state != MYCHEVY_ERROR: + self.hass.components.persistent_notification.create( + "Error:
Connection to mychevy website failed. " + "This probably means the mychevy to OnStar link is down.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + self._state = MYCHEVY_ERROR + self.async_schedule_update_ha_state() + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def should_poll(self): + """Return the polling state.""" + return False + + +class EVSensor(Entity): + """Base EVSensor class. + + The only real difference between sensors is which units and what + attribute from the car object they are returning. All logic can be + built with just setting subclass attributes. + + """ + + def __init__(self, connection, config): + """Initialize sensor with car connection.""" + self._conn = connection + self._name = config.name + self._attr = config.attr + self._unit_of_measurement = config.unit_of_measurement + self._icon = config.icon + self._state = None + + self.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(MYCHEVY_DOMAIN, slugify(self._name))) + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_update_callback) + + @property + def icon(self): + """Return the icon.""" + if self._attr == BATTERY_SENSOR: + return icon_for_battery_level(self.state) + return self._icon + + @property + def name(self): + """Return the name.""" + return self._name + + @callback + def async_update_callback(self): + """Update state.""" + if self._conn.car is not None: + self._state = getattr(self._conn.car, self._attr, None) + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement the state is expressed in.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """Return the polling state.""" + return False diff --git a/requirements_all.txt b/requirements_all.txt index e33ad03c34d..1e9efd05723 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -490,6 +490,9 @@ motorparts==1.0.2 # homeassistant.components.tts mutagen==1.39 +# homeassistant.components.mychevy +mychevy==0.1.1 + # homeassistant.components.mycroft mycroftapi==2.0