* Remove unnecessary exception re-wraps * Preserve exception chains on re-raise We slap "from cause" to almost all possible cases here. In some cases it could conceivably be better to do "from None" if we really want to hide the cause. However those should be in the minority, and "from cause" should be an improvement over the corresponding raise without a "from" in all cases anyway. The only case where we raise from None here is in plex, where the exception for an original invalid SSL cert is not the root cause for failure to validate a newly fetched one. Follow local convention on exception variable names if there is a consistent one, otherwise `err` to match with majority of codebase. * Fix mistaken re-wrap in homematicip_cloud/hap.py Missed the difference between HmipConnectionError and HmipcConnectionError. * Do not hide original error on plex new cert validation error Original is not the cause for the new one, but showing old in the traceback is useful nevertheless.
310 lines
10 KiB
Python
310 lines
10 KiB
Python
"""Sensor for the CityBikes data."""
|
|
import asyncio
|
|
from datetime import timedelta
|
|
import logging
|
|
|
|
import aiohttp
|
|
import async_timeout
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA
|
|
from homeassistant.const import (
|
|
ATTR_ATTRIBUTION,
|
|
ATTR_ID,
|
|
ATTR_LATITUDE,
|
|
ATTR_LOCATION,
|
|
ATTR_LONGITUDE,
|
|
ATTR_NAME,
|
|
CONF_LATITUDE,
|
|
CONF_LONGITUDE,
|
|
CONF_NAME,
|
|
CONF_RADIUS,
|
|
LENGTH_FEET,
|
|
LENGTH_METERS,
|
|
)
|
|
from homeassistant.exceptions import PlatformNotReady
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.entity import Entity, async_generate_entity_id
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
from homeassistant.util import distance, location
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
ATTR_EMPTY_SLOTS = "empty_slots"
|
|
ATTR_EXTRA = "extra"
|
|
ATTR_FREE_BIKES = "free_bikes"
|
|
ATTR_NETWORK = "network"
|
|
ATTR_NETWORKS_LIST = "networks"
|
|
ATTR_STATIONS_LIST = "stations"
|
|
ATTR_TIMESTAMP = "timestamp"
|
|
ATTR_UID = "uid"
|
|
|
|
CONF_NETWORK = "network"
|
|
CONF_STATIONS_LIST = "stations"
|
|
|
|
DEFAULT_ENDPOINT = "https://api.citybik.es/{uri}"
|
|
PLATFORM = "citybikes"
|
|
|
|
MONITORED_NETWORKS = "monitored-networks"
|
|
|
|
NETWORKS_URI = "v2/networks"
|
|
|
|
REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout
|
|
|
|
SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API
|
|
|
|
STATIONS_URI = "v2/networks/{uid}?fields=network.stations"
|
|
|
|
CITYBIKES_ATTRIBUTION = (
|
|
"Information provided by the CityBikes Project (https://citybik.es/#about)"
|
|
)
|
|
|
|
CITYBIKES_NETWORKS = "citybikes_networks"
|
|
|
|
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.longitude,
|
|
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."""
|
|
|
|
|
|
async 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):
|
|
req = await session.get(DEFAULT_ENDPOINT.format(uri=uri))
|
|
|
|
json_response = await 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
|
|
|
|
|
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
|
"""Set up the CityBikes platform."""
|
|
if PLATFORM not in hass.data:
|
|
hass.data[PLATFORM] = {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[CONF_NAME]
|
|
if not hass.config.units.is_metric:
|
|
radius = distance.convert(radius, LENGTH_FEET, LENGTH_METERS)
|
|
|
|
# Create a single instance of CityBikesNetworks.
|
|
networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass))
|
|
|
|
if not network_id:
|
|
network_id = await networks.get_closest_network_id(latitude, longitude)
|
|
|
|
if network_id not in hass.data[PLATFORM][MONITORED_NETWORKS]:
|
|
network = CityBikesNetwork(hass, network_id)
|
|
hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network
|
|
hass.async_create_task(network.async_refresh())
|
|
async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL)
|
|
else:
|
|
network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id]
|
|
|
|
await network.ready.wait()
|
|
|
|
devices = []
|
|
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)):
|
|
if name:
|
|
uid = "_".join([network.network_id, name, station_id])
|
|
else:
|
|
uid = "_".join([network.network_id, station_id])
|
|
entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, hass=hass)
|
|
devices.append(CityBikesStation(network, station_id, entity_id))
|
|
|
|
async_add_entities(devices, True)
|
|
|
|
|
|
class CityBikesNetworks:
|
|
"""Represent all CityBikes networks."""
|
|
|
|
def __init__(self, hass):
|
|
"""Initialize the networks instance."""
|
|
self.hass = hass
|
|
self.networks = None
|
|
self.networks_loading = asyncio.Condition()
|
|
|
|
async def get_closest_network_id(self, latitude, longitude):
|
|
"""Return the id of the network closest to provided location."""
|
|
try:
|
|
await self.networks_loading.acquire()
|
|
if self.networks is None:
|
|
networks = await async_citybikes_request(
|
|
self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA
|
|
)
|
|
self.networks = networks[ATTR_NETWORKS_LIST]
|
|
result = None
|
|
minimum_dist = None
|
|
for network in self.networks:
|
|
network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE]
|
|
network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE]
|
|
dist = location.distance(
|
|
latitude, longitude, network_latitude, network_longitude
|
|
)
|
|
if minimum_dist is None or dist < minimum_dist:
|
|
minimum_dist = dist
|
|
result = network[ATTR_ID]
|
|
|
|
return result
|
|
except CityBikesRequestError as err:
|
|
raise PlatformNotReady from err
|
|
finally:
|
|
self.networks_loading.release()
|
|
|
|
|
|
class CityBikesNetwork:
|
|
"""Thin wrapper around a CityBikes network object."""
|
|
|
|
def __init__(self, hass, network_id):
|
|
"""Initialize the network object."""
|
|
self.hass = hass
|
|
self.network_id = network_id
|
|
self.stations = []
|
|
self.ready = asyncio.Event()
|
|
|
|
async def async_refresh(self, now=None):
|
|
"""Refresh the state of the network."""
|
|
try:
|
|
network = await 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 as err:
|
|
if now is not None:
|
|
self.ready.clear()
|
|
else:
|
|
raise PlatformNotReady from err
|
|
|
|
|
|
class CityBikesStation(Entity):
|
|
"""CityBikes API Sensor."""
|
|
|
|
def __init__(self, network, station_id, entity_id):
|
|
"""Initialize the sensor."""
|
|
self._network = network
|
|
self._station_id = station_id
|
|
self._station_data = {}
|
|
self.entity_id = entity_id
|
|
|
|
@property
|
|
def state(self):
|
|
"""Return the state of the sensor."""
|
|
return self._station_data.get(ATTR_FREE_BIKES)
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of the sensor."""
|
|
return self._station_data.get(ATTR_NAME)
|
|
|
|
async def async_update(self):
|
|
"""Update station state."""
|
|
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_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"
|