Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Sensor for the CityBikes data."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import timedelta
7 import logging
8 
9 import aiohttp
10 import voluptuous as vol
11 
13  ENTITY_ID_FORMAT,
14  PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
15  SensorEntity,
16 )
17 from homeassistant.const import (
18  ATTR_ID,
19  ATTR_LATITUDE,
20  ATTR_LOCATION,
21  ATTR_LONGITUDE,
22  ATTR_NAME,
23  CONF_LATITUDE,
24  CONF_LONGITUDE,
25  CONF_NAME,
26  CONF_RADIUS,
27  UnitOfLength,
28 )
29 from homeassistant.core import HomeAssistant
30 from homeassistant.exceptions import PlatformNotReady
31 from homeassistant.helpers.aiohttp_client import async_get_clientsession
33 from homeassistant.helpers.entity import async_generate_entity_id
34 from homeassistant.helpers.entity_platform import AddEntitiesCallback
35 from homeassistant.helpers.event import async_track_time_interval
36 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
37 from homeassistant.util import location
38 from homeassistant.util.unit_conversion import DistanceConverter
39 from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
40 
41 _LOGGER = logging.getLogger(__name__)
42 
43 ATTR_EMPTY_SLOTS = "empty_slots"
44 ATTR_EXTRA = "extra"
45 ATTR_FREE_BIKES = "free_bikes"
46 ATTR_NETWORK = "network"
47 ATTR_NETWORKS_LIST = "networks"
48 ATTR_STATIONS_LIST = "stations"
49 ATTR_TIMESTAMP = "timestamp"
50 ATTR_UID = "uid"
51 
52 CONF_NETWORK = "network"
53 CONF_STATIONS_LIST = "stations"
54 
55 DEFAULT_ENDPOINT = "https://api.citybik.es/{uri}"
56 PLATFORM = "citybikes"
57 
58 MONITORED_NETWORKS = "monitored-networks"
59 
60 NETWORKS_URI = "v2/networks"
61 
62 REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout
63 
64 SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API
65 
66 STATIONS_URI = "v2/networks/{uid}?fields=network.stations"
67 
68 CITYBIKES_ATTRIBUTION = (
69  "Information provided by the CityBikes Project (https://citybik.es/#about)"
70 )
71 
72 CITYBIKES_NETWORKS = "citybikes_networks"
73 
74 PLATFORM_SCHEMA = vol.All(
75  cv.has_at_least_one_key(CONF_RADIUS, CONF_STATIONS_LIST),
76  SENSOR_PLATFORM_SCHEMA.extend(
77  {
78  vol.Optional(CONF_NAME, default=""): cv.string,
79  vol.Optional(CONF_NETWORK): cv.string,
80  vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude,
81  vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude,
82  vol.Optional(CONF_RADIUS, "station_filter"): cv.positive_int,
83  vol.Optional(CONF_STATIONS_LIST, "station_filter"): vol.All(
84  cv.ensure_list, vol.Length(min=1), [cv.string]
85  ),
86  }
87  ),
88 )
89 
90 NETWORK_SCHEMA = vol.Schema(
91  {
92  vol.Required(ATTR_ID): cv.string,
93  vol.Required(ATTR_NAME): cv.string,
94  vol.Required(ATTR_LOCATION): vol.Schema(
95  {
96  vol.Required(ATTR_LATITUDE): cv.latitude,
97  vol.Required(ATTR_LONGITUDE): cv.longitude,
98  },
99  extra=vol.REMOVE_EXTRA,
100  ),
101  },
102  extra=vol.REMOVE_EXTRA,
103 )
104 
105 NETWORKS_RESPONSE_SCHEMA = vol.Schema(
106  {vol.Required(ATTR_NETWORKS_LIST): [NETWORK_SCHEMA]}
107 )
108 
109 STATION_SCHEMA = vol.Schema(
110  {
111  vol.Required(ATTR_FREE_BIKES): cv.positive_int,
112  vol.Required(ATTR_EMPTY_SLOTS): vol.Any(cv.positive_int, None),
113  vol.Required(ATTR_LATITUDE): cv.latitude,
114  vol.Required(ATTR_LONGITUDE): cv.longitude,
115  vol.Required(ATTR_ID): cv.string,
116  vol.Required(ATTR_NAME): cv.string,
117  vol.Required(ATTR_TIMESTAMP): cv.string,
118  vol.Optional(ATTR_EXTRA): vol.Schema(
119  {vol.Optional(ATTR_UID): cv.string}, extra=vol.REMOVE_EXTRA
120  ),
121  },
122  extra=vol.REMOVE_EXTRA,
123 )
124 
125 STATIONS_RESPONSE_SCHEMA = vol.Schema(
126  {
127  vol.Required(ATTR_NETWORK): vol.Schema(
128  {vol.Required(ATTR_STATIONS_LIST): [STATION_SCHEMA]}, extra=vol.REMOVE_EXTRA
129  )
130  }
131 )
132 
133 
134 class CityBikesRequestError(Exception):
135  """Error to indicate a CityBikes API request has failed."""
136 
137 
138 async def async_citybikes_request(hass, uri, schema):
139  """Perform a request to CityBikes API endpoint, and parse the response."""
140  try:
141  session = async_get_clientsession(hass)
142 
143  async with asyncio.timeout(REQUEST_TIMEOUT):
144  req = await session.get(DEFAULT_ENDPOINT.format(uri=uri))
145 
146  json_response = await req.json()
147  return schema(json_response)
148  except (TimeoutError, aiohttp.ClientError):
149  _LOGGER.error("Could not connect to CityBikes API endpoint")
150  except ValueError:
151  _LOGGER.error("Received non-JSON data from CityBikes API endpoint")
152  except vol.Invalid as err:
153  _LOGGER.error("Received unexpected JSON from CityBikes API endpoint: %s", err)
154  raise CityBikesRequestError
155 
156 
158  hass: HomeAssistant,
159  config: ConfigType,
160  async_add_entities: AddEntitiesCallback,
161  discovery_info: DiscoveryInfoType | None = None,
162 ) -> None:
163  """Set up the CityBikes platform."""
164  if PLATFORM not in hass.data:
165  hass.data[PLATFORM] = {MONITORED_NETWORKS: {}}
166 
167  latitude = config.get(CONF_LATITUDE, hass.config.latitude)
168  longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
169  network_id = config.get(CONF_NETWORK)
170  stations_list = set(config.get(CONF_STATIONS_LIST, []))
171  radius = config.get(CONF_RADIUS, 0)
172  name = config[CONF_NAME]
173  if hass.config.units is US_CUSTOMARY_SYSTEM:
174  radius = DistanceConverter.convert(
175  radius, UnitOfLength.FEET, UnitOfLength.METERS
176  )
177 
178  # Create a single instance of CityBikesNetworks.
179  networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass))
180 
181  if not network_id:
182  network_id = await networks.get_closest_network_id(latitude, longitude)
183 
184  if network_id not in hass.data[PLATFORM][MONITORED_NETWORKS]:
185  network = CityBikesNetwork(hass, network_id)
186  hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network
187  hass.async_create_task(network.async_refresh())
188  async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL)
189  else:
190  network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id]
191 
192  await network.ready.wait()
193 
194  devices = []
195  for station in network.stations:
196  dist = location.distance(
197  latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE]
198  )
199  station_id = station[ATTR_ID]
200  station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, ""))
201 
202  if radius > dist or stations_list.intersection((station_id, station_uid)):
203  if name:
204  uid = f"{network.network_id}_{name}_{station_id}"
205  else:
206  uid = f"{network.network_id}_{station_id}"
207  entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, hass=hass)
208  devices.append(CityBikesStation(network, station_id, entity_id))
209 
210  async_add_entities(devices, True)
211 
212 
214  """Represent all CityBikes networks."""
215 
216  def __init__(self, hass):
217  """Initialize the networks instance."""
218  self.hasshass = hass
219  self.networksnetworks = None
220  self.networks_loadingnetworks_loading = asyncio.Condition()
221 
222  async def get_closest_network_id(self, latitude, longitude):
223  """Return the id of the network closest to provided location."""
224  try:
225  await self.networks_loadingnetworks_loading.acquire()
226  if self.networksnetworks is None:
227  networks = await async_citybikes_request(
228  self.hasshass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA
229  )
230  self.networksnetworks = networks[ATTR_NETWORKS_LIST]
231  except CityBikesRequestError as err:
232  raise PlatformNotReady from err
233  else:
234  result = None
235  minimum_dist = None
236  for network in self.networksnetworks:
237  network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE]
238  network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE]
239  dist = location.distance(
240  latitude, longitude, network_latitude, network_longitude
241  )
242  if minimum_dist is None or dist < minimum_dist:
243  minimum_dist = dist
244  result = network[ATTR_ID]
245 
246  return result
247  finally:
248  self.networks_loadingnetworks_loading.release()
249 
250 
252  """Thin wrapper around a CityBikes network object."""
253 
254  def __init__(self, hass, network_id):
255  """Initialize the network object."""
256  self.hasshass = hass
257  self.network_idnetwork_id = network_id
258  self.stationsstations = []
259  self.readyready = asyncio.Event()
260 
261  async def async_refresh(self, now=None):
262  """Refresh the state of the network."""
263  try:
264  network = await async_citybikes_request(
265  self.hasshass,
266  STATIONS_URI.format(uid=self.network_idnetwork_id),
267  STATIONS_RESPONSE_SCHEMA,
268  )
269  self.stationsstations = network[ATTR_NETWORK][ATTR_STATIONS_LIST]
270  self.readyready.set()
271  except CityBikesRequestError as err:
272  if now is not None:
273  self.readyready.clear()
274  else:
275  raise PlatformNotReady from err
276 
277 
279  """CityBikes API Sensor."""
280 
281  _attr_attribution = CITYBIKES_ATTRIBUTION
282  _attr_native_unit_of_measurement = "bikes"
283  _attr_icon = "mdi:bike"
284 
285  def __init__(self, network, station_id, entity_id):
286  """Initialize the sensor."""
287  self._network_network = network
288  self._station_id_station_id = station_id
289  self.entity_identity_identity_id = entity_id
290 
291  async def async_update(self) -> None:
292  """Update station state."""
293  for station in self._network_network.stations:
294  if station[ATTR_ID] == self._station_id_station_id:
295  station_data = station
296  break
297  self._attr_name_attr_name = station_data.get(ATTR_NAME)
298  self._attr_native_value_attr_native_value = station_data.get(ATTR_FREE_BIKES)
299  self._attr_extra_state_attributes_attr_extra_state_attributes = {
300  ATTR_UID: station_data.get(ATTR_EXTRA, {}).get(ATTR_UID),
301  ATTR_LATITUDE: station_data.get(ATTR_LATITUDE),
302  ATTR_LONGITUDE: station_data.get(ATTR_LONGITUDE),
303  ATTR_EMPTY_SLOTS: station_data.get(ATTR_EMPTY_SLOTS),
304  ATTR_TIMESTAMP: station_data.get(ATTR_TIMESTAMP),
305  }
def get_closest_network_id(self, latitude, longitude)
Definition: sensor.py:222
def __init__(self, network, station_id, entity_id)
Definition: sensor.py:285
def async_citybikes_request(hass, uri, schema)
Definition: sensor.py:138
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: sensor.py:162
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
str async_generate_entity_id(str entity_id_format, str|None name, Iterable[str]|None current_ids=None, HomeAssistant|None hass=None)
Definition: entity.py:119
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679