From 83a5f932d1bec13720b5f9a1b07e49ae8f83e0b9 Mon Sep 17 00:00:00 2001 From: Lev Aronsky Date: Wed, 5 Jul 2017 08:55:21 +0300 Subject: [PATCH] Add citybikes platform (#8202) * Initial commit - new CityBikes platform * Several syntax fixes. * Added imperial unit support. * Added station list lenght validation. * Style fixes. * Updated requirements. * Updated .coveragerc. * Fixed style problems according to pylint output. * Updated SCAN_INTERVAL value. * Fixed station names. Removed unnecessary calls to `slugify`. Changed the base name to reflect the name of the bike sharing network, instead of the more generic `citybikes`. * Small style fix. * Use async version of python-citybikes * Made platform setup async. * Made some more things async. * Switched to constants. * WIP: different approach to async. * Removed python-citybikes depnedency to fix async issues. * Removed unnecessary hidden property. * Style fixes. * Retry network detection. * Style fixes, and base name usage. * Fixes according to comments. * Use cv.latitude instead of coercing to float. * Updated requirements. * Several fixes and improvements. * Started using PlatformNotReady exception. * Cached the networks list result to avoid unnecessary API requests. * Switched the asyncio.timeout to use a constant. * Refactored CityBikes API requests into a separate function * Fixed linting errors. * Removed unnecessary requirement. --- .coveragerc | 3 +- homeassistant/components/sensor/citybikes.py | 293 +++++++++++++++++++ 2 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sensor/citybikes.py diff --git a/.coveragerc b/.coveragerc index 9f538911a6e..1f2454fc292 100644 --- a/.coveragerc +++ b/.coveragerc @@ -392,7 +392,7 @@ omit = homeassistant/components/sensor/bom.py homeassistant/components/sensor/broadlink.py homeassistant/components/sensor/buienradar.py - homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/citybikes.py homeassistant/components/sensor/coinmarketcap.py homeassistant/components/sensor/cert_expiry.py homeassistant/components/sensor/comed_hourly_pricing.py @@ -406,6 +406,7 @@ omit = homeassistant/components/sensor/dnsip.py homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py + homeassistant/components/sensor/dublin_bus_transport.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py new file mode 100644 index 00000000000..15046897732 --- /dev/null +++ b/homeassistant/components/sensor/citybikes.py @@ -0,0 +1,293 @@ +""" +Sensor for the CityBikes data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.citybikes/ +""" +import logging +from datetime import timedelta + +import asyncio +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, + ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_FRIENDLY_NAME, STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import location, distance +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_ENDPOINT = 'https://api.citybik.es/{uri}' +NETWORKS_URI = 'v2/networks' +STATIONS_URI = 'v2/networks/{uid}?fields=network.stations' + +REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout +SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API +DOMAIN = 'citybikes' +MONITORED_NETWORKS = 'monitored-networks' +CONF_NETWORK = 'network' +CONF_RADIUS = 'radius' +CONF_STATIONS_LIST = 'stations' +ATTR_NETWORKS_LIST = 'networks' +ATTR_NETWORK = 'network' +ATTR_STATIONS_LIST = 'stations' +ATTR_ID = 'id' +ATTR_UID = 'uid' +ATTR_NAME = 'name' +ATTR_EXTRA = 'extra' +ATTR_TIMESTAMP = 'timestamp' +ATTR_EMPTY_SLOTS = 'empty_slots' +ATTR_FREE_BIKES = 'free_bikes' +ATTR_TIMESTAMP = 'timestamp' +CITYBIKES_ATTRIBUTION = "Information provided by the CityBikes Project "\ + "(https://citybik.es/#about)" + + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_RADIUS, CONF_STATIONS_LIST), + PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=''): cv.string, + vol.Optional(CONF_NETWORK): cv.string, + vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude, + vol.Optional(CONF_RADIUS, 'station_filter'): cv.positive_int, + vol.Optional(CONF_STATIONS_LIST, 'station_filter'): + vol.All( + cv.ensure_list, + vol.Length(min=1), + [cv.string]) + })) + +NETWORK_SCHEMA = vol.Schema({ + vol.Required(ATTR_ID): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_LOCATION): vol.Schema({ + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + }, extra=vol.REMOVE_EXTRA), + }, extra=vol.REMOVE_EXTRA) + +NETWORKS_RESPONSE_SCHEMA = vol.Schema({ + vol.Required(ATTR_NETWORKS_LIST): [NETWORK_SCHEMA], + }) + +STATION_SCHEMA = vol.Schema({ + vol.Required(ATTR_FREE_BIKES): cv.positive_int, + vol.Required(ATTR_EMPTY_SLOTS): cv.positive_int, + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.latitude, + vol.Required(ATTR_ID): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TIMESTAMP): cv.string, + vol.Optional(ATTR_EXTRA): vol.Schema({ + vol.Optional(ATTR_UID): cv.string + }, extra=vol.REMOVE_EXTRA) + }, extra=vol.REMOVE_EXTRA) + +STATIONS_RESPONSE_SCHEMA = vol.Schema({ + vol.Required(ATTR_NETWORK): vol.Schema({ + vol.Required(ATTR_STATIONS_LIST): [STATION_SCHEMA] + }, extra=vol.REMOVE_EXTRA) + }) + + +class CityBikesRequestError(Exception): + """Error to indicate a CityBikes API request has failed.""" + + pass + + +@asyncio.coroutine +def async_citybikes_request(hass, uri, schema): + """Perform a request to CityBikes API endpoint, and parse the response.""" + try: + session = async_get_clientsession(hass) + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + req = yield from session.get(DEFAULT_ENDPOINT.format(uri=uri)) + + json_response = yield from req.json() + return schema(json_response) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Could not connect to CityBikes API endpoint") + except ValueError: + _LOGGER.error("Received non-JSON data from CityBikes API endpoint") + except vol.Invalid as err: + _LOGGER.error("Received unexpected JSON from CityBikes" + " API endpoint: %s", err) + raise CityBikesRequestError + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the CityBikes platform.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {MONITORED_NETWORKS: {}} + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + network_id = config.get(CONF_NETWORK) + stations_list = set(config.get(CONF_STATIONS_LIST, [])) + radius = config.get(CONF_RADIUS, 0) + name = config.get(CONF_NAME) + if not hass.config.units.is_metric: + radius = distance.convert(radius, LENGTH_FEET, LENGTH_METERS) + + if not network_id: + network_id = yield from CityBikesNetwork.get_closest_network_id( + hass, latitude, longitude) + + if network_id not in hass.data[DOMAIN][MONITORED_NETWORKS]: + network = CityBikesNetwork(hass, network_id) + hass.data[DOMAIN][MONITORED_NETWORKS][network_id] = network + hass.async_add_job(network.async_refresh) + async_track_time_interval(hass, network.async_refresh, + SCAN_INTERVAL) + else: + network = hass.data[DOMAIN][MONITORED_NETWORKS][network_id] + + yield from network.ready.wait() + + entities = [] + for station in network.stations: + dist = location.distance(latitude, longitude, + station[ATTR_LATITUDE], + station[ATTR_LONGITUDE]) + station_id = station[ATTR_ID] + station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, '')) + + if radius > dist or stations_list.intersection((station_id, + station_uid)): + entities.append(CityBikesStation(network, station_id, name)) + + async_add_entities(entities, True) + + +class CityBikesNetwork: + """Thin wrapper around a CityBikes network object.""" + + NETWORKS_LIST = None + NETWORKS_LIST_LOADING = asyncio.Condition() + + @classmethod + @asyncio.coroutine + def get_closest_network_id(cls, hass, latitude, longitude): + """Return the id of the network closest to provided location.""" + try: + yield from cls.NETWORKS_LIST_LOADING.acquire() + if cls.NETWORKS_LIST is None: + networks = yield from async_citybikes_request( + hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA) + cls.NETWORKS_LIST = networks[ATTR_NETWORKS_LIST] + networks_list = cls.NETWORKS_LIST + network = networks_list[0] + result = network[ATTR_ID] + minimum_dist = location.distance( + latitude, longitude, + network[ATTR_LOCATION][ATTR_LATITUDE], + network[ATTR_LOCATION][ATTR_LONGITUDE]) + for network in networks_list[1:]: + network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE] + network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE] + dist = location.distance(latitude, longitude, + network_latitude, network_longitude) + if dist < minimum_dist: + minimum_dist = dist + result = network[ATTR_ID] + + return result + except CityBikesRequestError: + raise PlatformNotReady + finally: + cls.NETWORKS_LIST_LOADING.release() + + def __init__(self, hass, network_id): + """Initialize the network object.""" + self.hass = hass + self.network_id = network_id + self.stations = [] + self.ready = asyncio.Event() + + @asyncio.coroutine + def async_refresh(self, now=None): + """Refresh the state of the network.""" + try: + network = yield from async_citybikes_request( + self.hass, STATIONS_URI.format(uid=self.network_id), + STATIONS_RESPONSE_SCHEMA) + self.stations = network[ATTR_NETWORK][ATTR_STATIONS_LIST] + self.ready.set() + except CityBikesRequestError: + if now is not None: + self.ready.clear() + else: + raise PlatformNotReady + + +class CityBikesStation(Entity): + """CityBikes API Sensor.""" + + def __init__(self, network, station_id, base_name=''): + """Initialize the sensor.""" + self._network = network + self._station_id = station_id + self._station_data = {} + self._base_name = base_name + + @property + def state(self): + """Return the state of the sensor.""" + return self._station_data.get(ATTR_FREE_BIKES, STATE_UNKNOWN) + + @property + def name(self): + """Return the name of the sensor.""" + if self._base_name: + return "{} {} {}".format(self._network.network_id, self._base_name, + self._station_id) + return "{} {}".format(self._network.network_id, self._station_id) + + @asyncio.coroutine + def async_update(self): + """Update station state.""" + if self._network.ready.is_set(): + for station in self._network.stations: + if station[ATTR_ID] == self._station_id: + self._station_data = station + break + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._station_data: + return { + ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION, + ATTR_UID: self._station_data.get(ATTR_EXTRA, {}).get(ATTR_UID), + ATTR_LATITUDE: self._station_data[ATTR_LATITUDE], + ATTR_LONGITUDE: self._station_data[ATTR_LONGITUDE], + ATTR_EMPTY_SLOTS: self._station_data[ATTR_EMPTY_SLOTS], + ATTR_FRIENDLY_NAME: self._station_data[ATTR_NAME], + ATTR_TIMESTAMP: self._station_data[ATTR_TIMESTAMP], + } + return {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return 'bikes' + + @property + def icon(self): + """Return the icon.""" + return 'mdi:bike'