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.
This commit is contained in:
Lev Aronsky 2017-07-05 08:55:21 +03:00 committed by Paulus Schoutsen
parent a12fa2e5bf
commit 83a5f932d1
2 changed files with 295 additions and 1 deletions

View File

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

View File

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