Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Support for GTFS (Google/General Transport Format Schema)."""
2 
3 from __future__ import annotations
4 
5 import datetime
6 import logging
7 import os
8 import threading
9 from typing import Any
10 
11 import pygtfs
12 from sqlalchemy.sql import text
13 import voluptuous as vol
14 
16  PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
17  SensorDeviceClass,
18  SensorEntity,
19 )
20 from homeassistant.const import CONF_NAME, CONF_OFFSET, STATE_UNKNOWN
21 from homeassistant.core import HomeAssistant
23 from homeassistant.helpers.entity_platform import AddEntitiesCallback
24 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
25 from homeassistant.util import slugify
26 import homeassistant.util.dt as dt_util
27 
28 _LOGGER = logging.getLogger(__name__)
29 
30 ATTR_ARRIVAL = "arrival"
31 ATTR_BICYCLE = "trip_bikes_allowed_state"
32 ATTR_DAY = "day"
33 ATTR_FIRST = "first"
34 ATTR_DROP_OFF_DESTINATION = "destination_stop_drop_off_type_state"
35 ATTR_DROP_OFF_ORIGIN = "origin_stop_drop_off_type_state"
36 ATTR_INFO = "info"
37 ATTR_OFFSET = CONF_OFFSET
38 ATTR_LAST = "last"
39 ATTR_LOCATION_DESTINATION = "destination_station_location_type_name"
40 ATTR_LOCATION_ORIGIN = "origin_station_location_type_name"
41 ATTR_PICKUP_DESTINATION = "destination_stop_pickup_type_state"
42 ATTR_PICKUP_ORIGIN = "origin_stop_pickup_type_state"
43 ATTR_ROUTE_TYPE = "route_type_name"
44 ATTR_TIMEPOINT_DESTINATION = "destination_stop_timepoint_exact"
45 ATTR_TIMEPOINT_ORIGIN = "origin_stop_timepoint_exact"
46 ATTR_WHEELCHAIR = "trip_wheelchair_access_available"
47 ATTR_WHEELCHAIR_DESTINATION = "destination_station_wheelchair_boarding_available"
48 ATTR_WHEELCHAIR_ORIGIN = "origin_station_wheelchair_boarding_available"
49 
50 CONF_DATA = "data"
51 CONF_DESTINATION = "destination"
52 CONF_ORIGIN = "origin"
53 CONF_TOMORROW = "include_tomorrow"
54 
55 DEFAULT_NAME = "GTFS Sensor"
56 DEFAULT_PATH = "gtfs"
57 
58 BICYCLE_ALLOWED_DEFAULT = STATE_UNKNOWN
59 BICYCLE_ALLOWED_OPTIONS = {1: True, 2: False}
60 DROP_OFF_TYPE_DEFAULT = STATE_UNKNOWN
61 DROP_OFF_TYPE_OPTIONS = {
62  0: "Regular",
63  1: "Not Available",
64  2: "Call Agency",
65  3: "Contact Driver",
66 }
67 ICON = "mdi:train"
68 ICONS = {
69  0: "mdi:tram",
70  1: "mdi:subway",
71  2: "mdi:train",
72  3: "mdi:bus",
73  4: "mdi:ferry",
74  5: "mdi:train-variant",
75  6: "mdi:gondola",
76  7: "mdi:stairs",
77  100: "mdi:train",
78  101: "mdi:train",
79  102: "mdi:train",
80  103: "mdi:train",
81  104: "mdi:train-car",
82  105: "mdi:train",
83  106: "mdi:train",
84  107: "mdi:train",
85  108: "mdi:train",
86  109: "mdi:train",
87  110: "mdi:train-variant",
88  111: "mdi:train-variant",
89  112: "mdi:train-variant",
90  113: "mdi:train-variant",
91  114: "mdi:train-variant",
92  115: "mdi:train-variant",
93  116: "mdi:train-variant",
94  117: "mdi:train-variant",
95  200: "mdi:bus",
96  201: "mdi:bus",
97  202: "mdi:bus",
98  203: "mdi:bus",
99  204: "mdi:bus",
100  205: "mdi:bus",
101  206: "mdi:bus",
102  207: "mdi:bus",
103  208: "mdi:bus",
104  209: "mdi:bus",
105  400: "mdi:subway-variant",
106  401: "mdi:subway-variant",
107  402: "mdi:subway",
108  403: "mdi:subway-variant",
109  404: "mdi:subway-variant",
110  405: "mdi:subway-variant",
111  700: "mdi:bus",
112  701: "mdi:bus",
113  702: "mdi:bus",
114  703: "mdi:bus",
115  704: "mdi:bus",
116  705: "mdi:bus",
117  706: "mdi:bus",
118  707: "mdi:bus",
119  708: "mdi:bus",
120  709: "mdi:bus",
121  710: "mdi:bus",
122  711: "mdi:bus",
123  712: "mdi:bus-school",
124  713: "mdi:bus-school",
125  714: "mdi:bus",
126  715: "mdi:bus",
127  716: "mdi:bus",
128  800: "mdi:bus",
129  900: "mdi:tram",
130  901: "mdi:tram",
131  902: "mdi:tram",
132  903: "mdi:tram",
133  904: "mdi:tram",
134  905: "mdi:tram",
135  906: "mdi:tram",
136  1000: "mdi:ferry",
137  1100: "mdi:airplane",
138  1200: "mdi:ferry",
139  1300: "mdi:airplane",
140  1400: "mdi:gondola",
141  1500: "mdi:taxi",
142  1501: "mdi:taxi",
143  1502: "mdi:ferry",
144  1503: "mdi:train-variant",
145  1504: "mdi:bicycle-basket",
146  1505: "mdi:taxi",
147  1506: "mdi:car-multiple",
148  1507: "mdi:taxi",
149  1700: "mdi:train-car",
150  1702: "mdi:horse-variant",
151 }
152 LOCATION_TYPE_DEFAULT = "Stop"
153 LOCATION_TYPE_OPTIONS = {
154  0: "Station",
155  1: "Stop",
156  2: "Station Entrance/Exit",
157  3: "Other",
158 }
159 PICKUP_TYPE_DEFAULT = STATE_UNKNOWN
160 PICKUP_TYPE_OPTIONS = {
161  0: "Regular",
162  1: "None Available",
163  2: "Call Agency",
164  3: "Contact Driver",
165 }
166 ROUTE_TYPE_OPTIONS = {
167  0: "Tram",
168  1: "Subway",
169  2: "Rail",
170  3: "Bus",
171  4: "Ferry",
172  5: "Cable Tram",
173  6: "Aerial Lift",
174  7: "Funicular",
175  100: "Railway Service",
176  101: "High Speed Rail Service",
177  102: "Long Distance Trains",
178  103: "Inter Regional Rail Service",
179  104: "Car Transport Rail Service",
180  105: "Sleeper Rail Service",
181  106: "Regional Rail Service",
182  107: "Tourist Railway Service",
183  108: "Rail Shuttle (Within Complex)",
184  109: "Suburban Railway",
185  110: "Replacement Rail Service",
186  111: "Special Rail Service",
187  112: "Lorry Transport Rail Service",
188  113: "All Rail Services",
189  114: "Cross-Country Rail Service",
190  115: "Vehicle Transport Rail Service",
191  116: "Rack and Pinion Railway",
192  117: "Additional Rail Service",
193  200: "Coach Service",
194  201: "International Coach Service",
195  202: "National Coach Service",
196  203: "Shuttle Coach Service",
197  204: "Regional Coach Service",
198  205: "Special Coach Service",
199  206: "Sightseeing Coach Service",
200  207: "Tourist Coach Service",
201  208: "Commuter Coach Service",
202  209: "All Coach Services",
203  400: "Urban Railway Service",
204  401: "Metro Service",
205  402: "Underground Service",
206  403: "Urban Railway Service",
207  404: "All Urban Railway Services",
208  405: "Monorail",
209  700: "Bus Service",
210  701: "Regional Bus Service",
211  702: "Express Bus Service",
212  703: "Stopping Bus Service",
213  704: "Local Bus Service",
214  705: "Night Bus Service",
215  706: "Post Bus Service",
216  707: "Special Needs Bus",
217  708: "Mobility Bus Service",
218  709: "Mobility Bus for Registered Disabled",
219  710: "Sightseeing Bus",
220  711: "Shuttle Bus",
221  712: "School Bus",
222  713: "School and Public Service Bus",
223  714: "Rail Replacement Bus Service",
224  715: "Demand and Response Bus Service",
225  716: "All Bus Services",
226  800: "Trolleybus Service",
227  900: "Tram Service",
228  901: "City Tram Service",
229  902: "Local Tram Service",
230  903: "Regional Tram Service",
231  904: "Sightseeing Tram Service",
232  905: "Shuttle Tram Service",
233  906: "All Tram Services",
234  1000: "Water Transport Service",
235  1100: "Air Service",
236  1200: "Ferry Service",
237  1300: "Aerial Lift Service",
238  1400: "Funicular Service",
239  1500: "Taxi Service",
240  1501: "Communal Taxi Service",
241  1502: "Water Taxi Service",
242  1503: "Rail Taxi Service",
243  1504: "Bike Taxi Service",
244  1505: "Licensed Taxi Service",
245  1506: "Private Hire Service Vehicle",
246  1507: "All Taxi Services",
247  1700: "Miscellaneous Service",
248  1702: "Horse-drawn Carriage",
249 }
250 TIMEPOINT_DEFAULT = True
251 TIMEPOINT_OPTIONS = {0: False, 1: True}
252 WHEELCHAIR_ACCESS_DEFAULT = STATE_UNKNOWN
253 WHEELCHAIR_ACCESS_OPTIONS = {1: True, 2: False}
254 WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN
255 WHEELCHAIR_BOARDING_OPTIONS = {1: True, 2: False}
256 
257 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
258  {
259  vol.Required(CONF_ORIGIN): cv.string,
260  vol.Required(CONF_DESTINATION): cv.string,
261  vol.Required(CONF_DATA): cv.string,
262  vol.Optional(CONF_NAME): cv.string,
263  vol.Optional(CONF_OFFSET, default=0): cv.time_period,
264  vol.Optional(CONF_TOMORROW, default=False): cv.boolean,
265  }
266 )
267 
268 
270  schedule: Any,
271  start_station_id: Any,
272  end_station_id: Any,
273  offset: datetime.timedelta,
274  include_tomorrow: bool = False,
275 ) -> dict:
276  """Get the next departure for the given schedule."""
277  now = dt_util.now().replace(tzinfo=None) + offset
278  now_date = now.strftime(dt_util.DATE_STR_FORMAT)
279  yesterday = now - datetime.timedelta(days=1)
280  yesterday_date = yesterday.strftime(dt_util.DATE_STR_FORMAT)
281  tomorrow = now + datetime.timedelta(days=1)
282  tomorrow_date = tomorrow.strftime(dt_util.DATE_STR_FORMAT)
283 
284  # Fetch all departures for yesterday, today and optionally tomorrow,
285  # up to an overkill maximum in case of a departure every minute for those
286  # days.
287  limit = 24 * 60 * 60 * 2
288  tomorrow_select = tomorrow_where = tomorrow_order = ""
289  if include_tomorrow:
290  limit = int(limit / 2 * 3)
291  tomorrow_name = tomorrow.strftime("%A").lower()
292  tomorrow_select = f"calendar.{tomorrow_name} AS tomorrow,"
293  tomorrow_where = f"OR calendar.{tomorrow_name} = 1"
294  tomorrow_order = f"calendar.{tomorrow_name} DESC,"
295 
296  sql_query = f"""
297  SELECT trip.trip_id, trip.route_id,
298  time(origin_stop_time.arrival_time) AS origin_arrival_time,
299  time(origin_stop_time.departure_time) AS origin_depart_time,
300  date(origin_stop_time.departure_time) AS origin_depart_date,
301  origin_stop_time.drop_off_type AS origin_drop_off_type,
302  origin_stop_time.pickup_type AS origin_pickup_type,
303  origin_stop_time.shape_dist_traveled AS origin_dist_traveled,
304  origin_stop_time.stop_headsign AS origin_stop_headsign,
305  origin_stop_time.stop_sequence AS origin_stop_sequence,
306  origin_stop_time.timepoint AS origin_stop_timepoint,
307  time(destination_stop_time.arrival_time) AS dest_arrival_time,
308  time(destination_stop_time.departure_time) AS dest_depart_time,
309  destination_stop_time.drop_off_type AS dest_drop_off_type,
310  destination_stop_time.pickup_type AS dest_pickup_type,
311  destination_stop_time.shape_dist_traveled AS dest_dist_traveled,
312  destination_stop_time.stop_headsign AS dest_stop_headsign,
313  destination_stop_time.stop_sequence AS dest_stop_sequence,
314  destination_stop_time.timepoint AS dest_stop_timepoint,
315  calendar.{yesterday.strftime("%A").lower()} AS yesterday,
316  calendar.{now.strftime("%A").lower()} AS today,
317  {tomorrow_select}
318  calendar.start_date AS start_date,
319  calendar.end_date AS end_date
320  FROM trips trip
321  INNER JOIN calendar calendar
322  ON trip.service_id = calendar.service_id
323  INNER JOIN stop_times origin_stop_time
324  ON trip.trip_id = origin_stop_time.trip_id
325  INNER JOIN stops start_station
326  ON origin_stop_time.stop_id = start_station.stop_id
327  INNER JOIN stop_times destination_stop_time
328  ON trip.trip_id = destination_stop_time.trip_id
329  INNER JOIN stops end_station
330  ON destination_stop_time.stop_id = end_station.stop_id
331  WHERE (calendar.{yesterday.strftime("%A").lower()} = 1
332  OR calendar.{now.strftime("%A").lower()} = 1
333  {tomorrow_where}
334  )
335  AND start_station.stop_id = :origin_station_id
336  AND end_station.stop_id = :end_station_id
337  AND origin_stop_sequence < dest_stop_sequence
338  AND calendar.start_date <= :today
339  AND calendar.end_date >= :today
340  ORDER BY calendar.{yesterday.strftime("%A").lower()} DESC,
341  calendar.{now.strftime("%A").lower()} DESC,
342  {tomorrow_order}
343  origin_stop_time.departure_time
344  LIMIT :limit
345  """ # noqa: S608
346  result = schedule.engine.connect().execute(
347  text(sql_query),
348  {
349  "origin_station_id": start_station_id,
350  "end_station_id": end_station_id,
351  "today": now_date,
352  "limit": limit,
353  },
354  )
355 
356  # Create lookup timetable for today and possibly tomorrow, taking into
357  # account any departures from yesterday scheduled after midnight,
358  # as long as all departures are within the calendar date range.
359  timetable = {}
360  yesterday_start = today_start = tomorrow_start = None
361  yesterday_last = today_last = ""
362 
363  for row_cursor in result:
364  row = row_cursor._asdict()
365  if row["yesterday"] == 1 and yesterday_date >= row["start_date"]:
366  extras = {"day": "yesterday", "first": None, "last": False}
367  if yesterday_start is None:
368  yesterday_start = row["origin_depart_date"]
369  if yesterday_start != row["origin_depart_date"]:
370  idx = f"{now_date} {row['origin_depart_time']}"
371  timetable[idx] = {**row, **extras}
372  yesterday_last = idx
373 
374  if row["today"] == 1:
375  extras = {"day": "today", "first": False, "last": False}
376  if today_start is None:
377  today_start = row["origin_depart_date"]
378  extras["first"] = True
379  if today_start == row["origin_depart_date"]:
380  idx_prefix = now_date
381  else:
382  idx_prefix = tomorrow_date
383  idx = f"{idx_prefix} {row['origin_depart_time']}"
384  timetable[idx] = {**row, **extras}
385  today_last = idx
386 
387  if (
388  "tomorrow" in row
389  and row["tomorrow"] == 1
390  and tomorrow_date <= row["end_date"]
391  ):
392  extras = {"day": "tomorrow", "first": False, "last": None}
393  if tomorrow_start is None:
394  tomorrow_start = row["origin_depart_date"]
395  extras["first"] = True
396  if tomorrow_start == row["origin_depart_date"]:
397  idx = f"{tomorrow_date} {row['origin_depart_time']}"
398  timetable[idx] = {**row, **extras}
399 
400  # Flag last departures.
401  for idx in filter(None, [yesterday_last, today_last]):
402  timetable[idx]["last"] = True
403 
404  _LOGGER.debug("Timetable: %s", sorted(timetable.keys()))
405 
406  item = {}
407  for key in sorted(timetable.keys()):
408  if (value := dt_util.parse_datetime(key)) is not None and value > now:
409  item = timetable[key]
410  _LOGGER.debug(
411  "Departure found for station %s @ %s -> %s", start_station_id, key, item
412  )
413  break
414 
415  if item == {}:
416  return {}
417 
418  # Format arrival and departure dates and times, accounting for the
419  # possibility of times crossing over midnight.
420  origin_arrival = now
421  if item["origin_arrival_time"] > item["origin_depart_time"]:
422  origin_arrival -= datetime.timedelta(days=1)
423  origin_arrival_time = (
424  f"{origin_arrival.strftime(dt_util.DATE_STR_FORMAT)} "
425  f"{item['origin_arrival_time']}"
426  )
427 
428  origin_depart_time = f"{now_date} {item['origin_depart_time']}"
429 
430  dest_arrival = now
431  if item["dest_arrival_time"] < item["origin_depart_time"]:
432  dest_arrival += datetime.timedelta(days=1)
433  dest_arrival_time = (
434  f"{dest_arrival.strftime(dt_util.DATE_STR_FORMAT)} {item['dest_arrival_time']}"
435  )
436 
437  dest_depart = dest_arrival
438  if item["dest_depart_time"] < item["dest_arrival_time"]:
439  dest_depart += datetime.timedelta(days=1)
440  dest_depart_time = (
441  f"{dest_depart.strftime(dt_util.DATE_STR_FORMAT)} {item['dest_depart_time']}"
442  )
443 
444  depart_time = dt_util.parse_datetime(origin_depart_time)
445  arrival_time = dt_util.parse_datetime(dest_arrival_time)
446 
447  origin_stop_time = {
448  "Arrival Time": origin_arrival_time,
449  "Departure Time": origin_depart_time,
450  "Drop Off Type": item["origin_drop_off_type"],
451  "Pickup Type": item["origin_pickup_type"],
452  "Shape Dist Traveled": item["origin_dist_traveled"],
453  "Headsign": item["origin_stop_headsign"],
454  "Sequence": item["origin_stop_sequence"],
455  "Timepoint": item["origin_stop_timepoint"],
456  }
457 
458  destination_stop_time = {
459  "Arrival Time": dest_arrival_time,
460  "Departure Time": dest_depart_time,
461  "Drop Off Type": item["dest_drop_off_type"],
462  "Pickup Type": item["dest_pickup_type"],
463  "Shape Dist Traveled": item["dest_dist_traveled"],
464  "Headsign": item["dest_stop_headsign"],
465  "Sequence": item["dest_stop_sequence"],
466  "Timepoint": item["dest_stop_timepoint"],
467  }
468 
469  return {
470  "trip_id": item["trip_id"],
471  "route_id": item["route_id"],
472  "day": item["day"],
473  "first": item["first"],
474  "last": item["last"],
475  "departure_time": depart_time,
476  "arrival_time": arrival_time,
477  "origin_stop_time": origin_stop_time,
478  "destination_stop_time": destination_stop_time,
479  }
480 
481 
483  hass: HomeAssistant,
484  config: ConfigType,
485  add_entities: AddEntitiesCallback,
486  discovery_info: DiscoveryInfoType | None = None,
487 ) -> None:
488  """Set up the GTFS sensor."""
489  gtfs_dir = hass.config.path(DEFAULT_PATH)
490  data = config[CONF_DATA]
491  origin = config.get(CONF_ORIGIN)
492  destination = config.get(CONF_DESTINATION)
493  name = config.get(CONF_NAME)
494  offset: datetime.timedelta = config[CONF_OFFSET]
495  include_tomorrow = config[CONF_TOMORROW]
496 
497  os.makedirs(gtfs_dir, exist_ok=True)
498 
499  if not os.path.exists(os.path.join(gtfs_dir, data)):
500  _LOGGER.error("The given GTFS data file/folder was not found")
501  return
502 
503  (gtfs_root, _) = os.path.splitext(data)
504 
505  sqlite_file = f"{gtfs_root}.sqlite?check_same_thread=False"
506  joined_path = os.path.join(gtfs_dir, sqlite_file)
507  gtfs = pygtfs.Schedule(joined_path)
508 
509  if not gtfs.feeds:
510  pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data))
511 
512  add_entities(
513  [GTFSDepartureSensor(gtfs, name, origin, destination, offset, include_tomorrow)]
514  )
515 
516 
518  """Implementation of a GTFS departure sensor."""
519 
520  _attr_device_class = SensorDeviceClass.TIMESTAMP
521 
522  def __init__(
523  self,
524  gtfs: Any,
525  name: Any | None,
526  origin: Any,
527  destination: Any,
528  offset: datetime.timedelta,
529  include_tomorrow: bool,
530  ) -> None:
531  """Initialize the sensor."""
532  self._pygtfs_pygtfs = gtfs
533  self.originorigin = origin
534  self.destinationdestination = destination
535  self._include_tomorrow_include_tomorrow = include_tomorrow
536  self._offset_offset = offset
537  self._custom_name_custom_name = name
538 
539  self._available_available = False
540  self._icon_icon = ICON
541  self._name_name = ""
542  self._state_state: datetime.datetime | None = None
543  self._attributes_attributes: dict[str, Any] = {}
544 
545  self._agency_agency = None
546  self._departure_departure: dict[str, Any] = {}
547  self._destination_destination = None
548  self._origin_origin = None
549  self._route_route = None
550  self._trip_trip = None
551 
552  self.locklock = threading.Lock()
553  self.updateupdate()
554 
555  @property
556  def name(self) -> str:
557  """Return the name of the sensor."""
558  return self._name_name
559 
560  @property
561  def native_value(self) -> datetime.datetime | None:
562  """Return the state of the sensor."""
563  return self._state_state
564 
565  @property
566  def available(self) -> bool:
567  """Return True if entity is available."""
568  return self._available_available
569 
570  @property
571  def extra_state_attributes(self) -> dict[str, Any]:
572  """Return the state attributes."""
573  return self._attributes_attributes
574 
575  @property
576  def icon(self) -> str:
577  """Icon to use in the frontend, if any."""
578  return self._icon_icon
579 
580  def update(self) -> None:
581  """Get the latest data from GTFS and update the states."""
582  with self.locklock:
583  # Fetch valid stop information once
584  if not self._origin_origin:
585  stops = self._pygtfs_pygtfs.stops_by_id(self.originorigin)
586  if not stops:
587  self._available_available = False
588  _LOGGER.warning("Origin stop ID %s not found", self.originorigin)
589  return
590  self._origin_origin = stops[0]
591 
592  if not self._destination_destination:
593  stops = self._pygtfs_pygtfs.stops_by_id(self.destinationdestination)
594  if not stops:
595  self._available_available = False
596  _LOGGER.warning(
597  "Destination stop ID %s not found", self.destinationdestination
598  )
599  return
600  self._destination_destination = stops[0]
601 
602  self._available_available = True
603 
604  # Fetch next departure
605  self._departure_departure = get_next_departure(
606  self._pygtfs_pygtfs,
607  self.originorigin,
608  self.destinationdestination,
609  self._offset_offset,
610  self._include_tomorrow_include_tomorrow,
611  )
612 
613  # Fetch trip and route details once, unless updated
614  if not self._departure_departure:
615  self._trip_trip = None
616  else:
617  trip_id = self._departure_departure["trip_id"]
618  if not self._trip_trip or self._trip_trip.trip_id != trip_id:
619  _LOGGER.debug("Fetching trip details for %s", trip_id)
620  self._trip_trip = self._pygtfs_pygtfs.trips_by_id(trip_id)[0]
621 
622  route_id = self._departure_departure["route_id"]
623  if not self._route_route or self._route_route.route_id != route_id:
624  _LOGGER.debug("Fetching route details for %s", route_id)
625  self._route_route = self._pygtfs_pygtfs.routes_by_id(route_id)[0]
626 
627  # Fetch agency details exactly once
628  if self._agency_agency is None and self._route_route:
629  _LOGGER.debug("Fetching agency details for %s", self._route_route.agency_id)
630  try:
631  self._agency_agency = self._pygtfs_pygtfs.agencies_by_id(self._route_route.agency_id)[0]
632  except IndexError:
633  _LOGGER.warning(
634  (
635  "Agency ID '%s' was not found in agency table, "
636  "you may want to update the routes database table "
637  "to fix this missing reference"
638  ),
639  self._route_route.agency_id,
640  )
641  self._agency_agency = False
642 
643  # Define the state as a UTC timestamp with ISO 8601 format
644  if not self._departure_departure:
645  self._state_state = None
646  elif self._agency_agency:
647  self._state_state = self._departure_departure["departure_time"].replace(
648  tzinfo=dt_util.get_time_zone(self._agency_agency.agency_timezone)
649  )
650  else:
651  self._state_state = self._departure_departure["departure_time"].replace(
652  tzinfo=dt_util.UTC
653  )
654 
655  # Assign attributes, icon and name
656  self.update_attributesupdate_attributes()
657 
658  if self._agency_agency:
659  self._attr_attribution_attr_attribution = self._agency_agency.agency_name
660  else:
661  self._attr_attribution_attr_attribution = None
662 
663  if self._route_route:
664  self._icon_icon = ICONS.get(self._route_route.route_type, ICON)
665  else:
666  self._icon_icon = ICON
667 
668  name = (
669  f"{getattr(self._agency, 'agency_name', DEFAULT_NAME)} "
670  f"{self.origin} to {self.destination} next departure"
671  )
672  if not self._departure_departure:
673  name = f"{DEFAULT_NAME}"
674  self._name_name = self._custom_name_custom_name or name
675 
676  def update_attributes(self) -> None:
677  """Update state attributes."""
678  # Add departure information
679  if self._departure_departure:
680  self._attributes_attributes[ATTR_ARRIVAL] = dt_util.as_utc(
681  self._departure_departure["arrival_time"]
682  ).isoformat()
683 
684  self._attributes_attributes[ATTR_DAY] = self._departure_departure["day"]
685 
686  if self._departure_departure[ATTR_FIRST] is not None:
687  self._attributes_attributes[ATTR_FIRST] = self._departure_departure["first"]
688  elif ATTR_FIRST in self._attributes_attributes:
689  del self._attributes_attributes[ATTR_FIRST]
690 
691  if self._departure_departure[ATTR_LAST] is not None:
692  self._attributes_attributes[ATTR_LAST] = self._departure_departure["last"]
693  elif ATTR_LAST in self._attributes_attributes:
694  del self._attributes_attributes[ATTR_LAST]
695  else:
696  if ATTR_ARRIVAL in self._attributes_attributes:
697  del self._attributes_attributes[ATTR_ARRIVAL]
698  if ATTR_DAY in self._attributes_attributes:
699  del self._attributes_attributes[ATTR_DAY]
700  if ATTR_FIRST in self._attributes_attributes:
701  del self._attributes_attributes[ATTR_FIRST]
702  if ATTR_LAST in self._attributes_attributes:
703  del self._attributes_attributes[ATTR_LAST]
704 
705  # Add contextual information
706  self._attributes_attributes[ATTR_OFFSET] = self._offset_offset.total_seconds() / 60
707 
708  if self._state_state is None:
709  self._attributes_attributes[ATTR_INFO] = (
710  "No more departures"
711  if self._include_tomorrow_include_tomorrow
712  else "No more departures today"
713  )
714  elif ATTR_INFO in self._attributes_attributes:
715  del self._attributes_attributes[ATTR_INFO]
716 
717  # Add extra metadata
718  key = "agency_id"
719  if self._agency_agency and key not in self._attributes_attributes:
720  self.append_keysappend_keys(self.dict_for_tabledict_for_table(self._agency_agency), "Agency")
721 
722  key = "origin_station_stop_id"
723  if self._origin_origin and key not in self._attributes_attributes:
724  self.append_keysappend_keys(self.dict_for_tabledict_for_table(self._origin_origin), "Origin Station")
725  self._attributes_attributes[ATTR_LOCATION_ORIGIN] = LOCATION_TYPE_OPTIONS.get(
726  self._origin_origin.location_type, LOCATION_TYPE_DEFAULT
727  )
728  self._attributes_attributes[ATTR_WHEELCHAIR_ORIGIN] = WHEELCHAIR_BOARDING_OPTIONS.get(
729  self._origin_origin.wheelchair_boarding, WHEELCHAIR_BOARDING_DEFAULT
730  )
731 
732  key = "destination_station_stop_id"
733  if self._destination_destination and key not in self._attributes_attributes:
734  self.append_keysappend_keys(
735  self.dict_for_tabledict_for_table(self._destination_destination), "Destination Station"
736  )
737  self._attributes_attributes[ATTR_LOCATION_DESTINATION] = LOCATION_TYPE_OPTIONS.get(
738  self._destination_destination.location_type, LOCATION_TYPE_DEFAULT
739  )
740  self._attributes_attributes[ATTR_WHEELCHAIR_DESTINATION] = (
741  WHEELCHAIR_BOARDING_OPTIONS.get(
742  self._destination_destination.wheelchair_boarding, WHEELCHAIR_BOARDING_DEFAULT
743  )
744  )
745 
746  # Manage Route metadata
747  key = "route_id"
748  if not self._route_route and key in self._attributes_attributes:
749  self.remove_keysremove_keys("Route")
750  elif self._route_route and (
751  key not in self._attributes_attributes or self._attributes_attributes[key] != self._route_route.route_id
752  ):
753  self.append_keysappend_keys(self.dict_for_tabledict_for_table(self._route_route), "Route")
754  self._attributes_attributes[ATTR_ROUTE_TYPE] = ROUTE_TYPE_OPTIONS[
755  self._route_route.route_type
756  ]
757 
758  # Manage Trip metadata
759  key = "trip_id"
760  if not self._trip_trip and key in self._attributes_attributes:
761  self.remove_keysremove_keys("Trip")
762  elif self._trip_trip and (
763  key not in self._attributes_attributes or self._attributes_attributes[key] != self._trip_trip.trip_id
764  ):
765  self.append_keysappend_keys(self.dict_for_tabledict_for_table(self._trip_trip), "Trip")
766  self._attributes_attributes[ATTR_BICYCLE] = BICYCLE_ALLOWED_OPTIONS.get(
767  self._trip_trip.bikes_allowed, BICYCLE_ALLOWED_DEFAULT
768  )
769  self._attributes_attributes[ATTR_WHEELCHAIR] = WHEELCHAIR_ACCESS_OPTIONS.get(
770  self._trip_trip.wheelchair_accessible, WHEELCHAIR_ACCESS_DEFAULT
771  )
772 
773  # Manage Stop Times metadata
774  prefix = "origin_stop"
775  if self._departure_departure:
776  self.append_keysappend_keys(self._departure_departure["origin_stop_time"], prefix)
777  self._attributes_attributes[ATTR_DROP_OFF_ORIGIN] = DROP_OFF_TYPE_OPTIONS.get(
778  self._departure_departure["origin_stop_time"]["Drop Off Type"],
779  DROP_OFF_TYPE_DEFAULT,
780  )
781  self._attributes_attributes[ATTR_PICKUP_ORIGIN] = PICKUP_TYPE_OPTIONS.get(
782  self._departure_departure["origin_stop_time"]["Pickup Type"], PICKUP_TYPE_DEFAULT
783  )
784  self._attributes_attributes[ATTR_TIMEPOINT_ORIGIN] = TIMEPOINT_OPTIONS.get(
785  self._departure_departure["origin_stop_time"]["Timepoint"], TIMEPOINT_DEFAULT
786  )
787  else:
788  self.remove_keysremove_keys(prefix)
789 
790  prefix = "destination_stop"
791  if self._departure_departure:
792  self.append_keysappend_keys(self._departure_departure["destination_stop_time"], prefix)
793  self._attributes_attributes[ATTR_DROP_OFF_DESTINATION] = DROP_OFF_TYPE_OPTIONS.get(
794  self._departure_departure["destination_stop_time"]["Drop Off Type"],
795  DROP_OFF_TYPE_DEFAULT,
796  )
797  self._attributes_attributes[ATTR_PICKUP_DESTINATION] = PICKUP_TYPE_OPTIONS.get(
798  self._departure_departure["destination_stop_time"]["Pickup Type"],
799  PICKUP_TYPE_DEFAULT,
800  )
801  self._attributes_attributes[ATTR_TIMEPOINT_DESTINATION] = TIMEPOINT_OPTIONS.get(
802  self._departure_departure["destination_stop_time"]["Timepoint"], TIMEPOINT_DEFAULT
803  )
804  else:
805  self.remove_keysremove_keys(prefix)
806 
807  @staticmethod
808  def dict_for_table(resource: Any) -> dict:
809  """Return a dictionary for the SQLAlchemy resource given."""
810  _dict = {}
811  for column in resource.__table__.columns:
812  _dict[column.name] = str(getattr(resource, column.name))
813  return _dict
814 
815  def append_keys(self, resource: dict, prefix: str | None = None) -> None:
816  """Properly format key val pairs to append to attributes."""
817  for attr, val in resource.items():
818  if val == "" or val is None or attr == "feed_id":
819  continue
820  key = attr
821  if prefix and not key.startswith(prefix):
822  key = f"{prefix} {key}"
823  key = slugify(key)
824  self._attributes_attributes[key] = val
825 
826  def remove_keys(self, prefix: str) -> None:
827  """Remove attributes whose key starts with prefix."""
828  self._attributes_attributes = {
829  k: v for k, v in self._attributes_attributes.items() if not k.startswith(prefix)
830  }
None __init__(self, Any gtfs, Any|None name, Any origin, Any destination, datetime.timedelta offset, bool include_tomorrow)
Definition: sensor.py:530
None append_keys(self, dict resource, str|None prefix=None)
Definition: sensor.py:815
dict get_next_departure(Any schedule, Any start_station_id, Any end_station_id, datetime.timedelta offset, bool include_tomorrow=False)
Definition: sensor.py:275
None setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: sensor.py:487
def add_entities(account, async_add_entities, tracked)
Definition: sensor.py:40
def execute(hass, filename, source, data=None, return_response=False)
Definition: __init__.py:194