Jan Losinski fde4a7d029 Citybikes: Allow None as result for empty slots (#8528)
The citibykes API returns "null" as value for empty_slots on some
stations (see #8527). This causes the component to not process the data.

This is fixed by accepting None as valid data. The row in the frontend
is left empty if "null" was returned by the service.

fixes #8527
2017-07-17 22:50:55 +02:00

294 lines
10 KiB
Python

"""
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): vol.Any(cv.positive_int, None),
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'