Add basic mychevy component (#11409)

* mychevy: added basic mychevy component

This implements a component using the mychevy library (which utilizes
selenium to webscrape the mychevy website for onstar for your
car). The architecture works by having a background thread doing
periodic polling of the website, and updating the sensors when new
data is found.

This requires rather more setup than most platforms, as you need
working selenium. Instructions will be provided on the component
list. All the sensors are spawned and coordinated from a single "hub"
as they are really just attributes of the same web scraping session.

* mychevy: only poll every 30 minutes

* mychevy: update sensors

* mychevy: better error handling

* mychevy: tweaking for refactor

* mychevy: bump version to handle odometer > 1000

* mychevy: great sensor reorg

* mychevy: add binary sensors

* mychevy: bump mychevy requirement

* mychevy: use dispatcher

Instead of directly modifying the sensors, this lets us use a
dispatcher to have sensors pull information from the car object when
there is a relevant update for them.

* mychevy: remove from coverage

* mychevy: dedicated constants for dispatch signals

This makes the dispatch signals dedicated topics, and fixes updating
the state on the sensors so that they are correctly updated.

* mychevy: updated with comments from martinhjelmare

* mychevy: set battery icon based with helper function

* Address additional review feedback

* Address additional review comments
This commit is contained in:
Sean Dague 2018-01-15 15:50:56 -05:00 committed by Martin Hjelmare
parent 5546ecd637
commit 6b26154077
5 changed files with 388 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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:<br/>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

View File

@ -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