mirror of
https://github.com/home-assistant/core.git
synced 2025-05-30 18:57:10 +00:00

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
294 lines
10 KiB
Python
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'
|