1 """Support for GTFS (Google/General Transport Format Schema)."""
3 from __future__
import annotations
12 from sqlalchemy.sql
import text
13 import voluptuous
as vol
16 PLATFORM_SCHEMA
as SENSOR_PLATFORM_SCHEMA,
28 _LOGGER = logging.getLogger(__name__)
30 ATTR_ARRIVAL =
"arrival"
31 ATTR_BICYCLE =
"trip_bikes_allowed_state"
34 ATTR_DROP_OFF_DESTINATION =
"destination_stop_drop_off_type_state"
35 ATTR_DROP_OFF_ORIGIN =
"origin_stop_drop_off_type_state"
37 ATTR_OFFSET = CONF_OFFSET
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"
51 CONF_DESTINATION =
"destination"
52 CONF_ORIGIN =
"origin"
53 CONF_TOMORROW =
"include_tomorrow"
55 DEFAULT_NAME =
"GTFS Sensor"
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 = {
74 5:
"mdi:train-variant",
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",
105 400:
"mdi:subway-variant",
106 401:
"mdi:subway-variant",
108 403:
"mdi:subway-variant",
109 404:
"mdi:subway-variant",
110 405:
"mdi:subway-variant",
123 712:
"mdi:bus-school",
124 713:
"mdi:bus-school",
137 1100:
"mdi:airplane",
139 1300:
"mdi:airplane",
144 1503:
"mdi:train-variant",
145 1504:
"mdi:bicycle-basket",
147 1506:
"mdi:car-multiple",
149 1700:
"mdi:train-car",
150 1702:
"mdi:horse-variant",
152 LOCATION_TYPE_DEFAULT =
"Stop"
153 LOCATION_TYPE_OPTIONS = {
156 2:
"Station Entrance/Exit",
159 PICKUP_TYPE_DEFAULT = STATE_UNKNOWN
160 PICKUP_TYPE_OPTIONS = {
166 ROUTE_TYPE_OPTIONS = {
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",
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",
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",
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",
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",
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}
257 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
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,
271 start_station_id: Any,
273 offset: datetime.timedelta,
274 include_tomorrow: bool =
False,
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)
287 limit = 24 * 60 * 60 * 2
288 tomorrow_select = tomorrow_where = tomorrow_order =
""
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,"
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,
318 calendar.start_date AS start_date,
319 calendar.end_date AS end_date
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
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,
343 origin_stop_time.departure_time
346 result = schedule.engine.connect().
execute(
349 "origin_station_id": start_station_id,
350 "end_station_id": end_station_id,
360 yesterday_start = today_start = tomorrow_start =
None
361 yesterday_last = today_last =
""
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}
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
382 idx_prefix = tomorrow_date
383 idx = f
"{idx_prefix} {row['origin_depart_time']}"
384 timetable[idx] = {**row, **extras}
389 and row[
"tomorrow"] == 1
390 and tomorrow_date <= row[
"end_date"]
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}
401 for idx
in filter(
None, [yesterday_last, today_last]):
402 timetable[idx][
"last"] =
True
404 _LOGGER.debug(
"Timetable: %s", sorted(timetable.keys()))
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]
411 "Departure found for station %s @ %s -> %s", start_station_id, key, item
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']}"
428 origin_depart_time = f
"{now_date} {item['origin_depart_time']}"
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']}"
437 dest_depart = dest_arrival
438 if item[
"dest_depart_time"] < item[
"dest_arrival_time"]:
439 dest_depart += datetime.timedelta(days=1)
441 f
"{dest_depart.strftime(dt_util.DATE_STR_FORMAT)} {item['dest_depart_time']}"
444 depart_time = dt_util.parse_datetime(origin_depart_time)
445 arrival_time = dt_util.parse_datetime(dest_arrival_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"],
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"],
470 "trip_id": item[
"trip_id"],
471 "route_id": item[
"route_id"],
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,
485 add_entities: AddEntitiesCallback,
486 discovery_info: DiscoveryInfoType |
None =
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]
497 os.makedirs(gtfs_dir, exist_ok=
True)
499 if not os.path.exists(os.path.join(gtfs_dir, data)):
500 _LOGGER.error(
"The given GTFS data file/folder was not found")
503 (gtfs_root, _) = os.path.splitext(data)
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)
510 pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data))
518 """Implementation of a GTFS departure sensor."""
520 _attr_device_class = SensorDeviceClass.TIMESTAMP
528 offset: datetime.timedelta,
529 include_tomorrow: bool,
531 """Initialize the sensor."""
542 self.
_state_state: datetime.datetime |
None =
None
546 self.
_departure_departure: dict[str, Any] = {}
552 self.
locklock = threading.Lock()
557 """Return the name of the sensor."""
558 return self.
_name_name
562 """Return the state of the sensor."""
567 """Return True if entity is available."""
572 """Return the state attributes."""
577 """Icon to use in the frontend, if any."""
578 return self.
_icon_icon
581 """Get the latest data from GTFS and update the states."""
588 _LOGGER.warning(
"Origin stop ID %s not found", self.
originorigin)
597 "Destination stop ID %s not found", self.
destinationdestination
615 self.
_trip_trip =
None
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]
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]
629 _LOGGER.debug(
"Fetching agency details for %s", self.
_route_route.agency_id)
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"
639 self.
_route_route.agency_id,
648 tzinfo=dt_util.get_time_zone(self.
_agency_agency.agency_timezone)
664 self.
_icon_icon = ICONS.get(self.
_route_route.route_type, ICON)
666 self.
_icon_icon = ICON
669 f
"{getattr(self._agency, 'agency_name', DEFAULT_NAME)} "
670 f
"{self.origin} to {self.destination} next departure"
673 name = f
"{DEFAULT_NAME}"
677 """Update state attributes."""
680 self.
_attributes_attributes[ATTR_ARRIVAL] = dt_util.as_utc(
686 if self.
_departure_departure[ATTR_FIRST]
is not None:
691 if self.
_departure_departure[ATTR_LAST]
is not None:
708 if self.
_state_state
is None:
712 else "No more departures today"
722 key =
"origin_station_stop_id"
725 self.
_attributes_attributes[ATTR_LOCATION_ORIGIN] = LOCATION_TYPE_OPTIONS.get(
726 self.
_origin_origin.location_type, LOCATION_TYPE_DEFAULT
728 self.
_attributes_attributes[ATTR_WHEELCHAIR_ORIGIN] = WHEELCHAIR_BOARDING_OPTIONS.get(
729 self.
_origin_origin.wheelchair_boarding, WHEELCHAIR_BOARDING_DEFAULT
732 key =
"destination_station_stop_id"
737 self.
_attributes_attributes[ATTR_LOCATION_DESTINATION] = LOCATION_TYPE_OPTIONS.get(
738 self.
_destination_destination.location_type, LOCATION_TYPE_DEFAULT
740 self.
_attributes_attributes[ATTR_WHEELCHAIR_DESTINATION] = (
741 WHEELCHAIR_BOARDING_OPTIONS.get(
742 self.
_destination_destination.wheelchair_boarding, WHEELCHAIR_BOARDING_DEFAULT
750 elif self.
_route_route
and (
754 self.
_attributes_attributes[ATTR_ROUTE_TYPE] = ROUTE_TYPE_OPTIONS[
755 self.
_route_route.route_type
762 elif self.
_trip_trip
and (
766 self.
_attributes_attributes[ATTR_BICYCLE] = BICYCLE_ALLOWED_OPTIONS.get(
767 self.
_trip_trip.bikes_allowed, BICYCLE_ALLOWED_DEFAULT
769 self.
_attributes_attributes[ATTR_WHEELCHAIR] = WHEELCHAIR_ACCESS_OPTIONS.get(
770 self.
_trip_trip.wheelchair_accessible, WHEELCHAIR_ACCESS_DEFAULT
774 prefix =
"origin_stop"
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,
781 self.
_attributes_attributes[ATTR_PICKUP_ORIGIN] = PICKUP_TYPE_OPTIONS.get(
782 self.
_departure_departure[
"origin_stop_time"][
"Pickup Type"], PICKUP_TYPE_DEFAULT
784 self.
_attributes_attributes[ATTR_TIMEPOINT_ORIGIN] = TIMEPOINT_OPTIONS.get(
785 self.
_departure_departure[
"origin_stop_time"][
"Timepoint"], TIMEPOINT_DEFAULT
790 prefix =
"destination_stop"
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,
797 self.
_attributes_attributes[ATTR_PICKUP_DESTINATION] = PICKUP_TYPE_OPTIONS.get(
798 self.
_departure_departure[
"destination_stop_time"][
"Pickup Type"],
801 self.
_attributes_attributes[ATTR_TIMEPOINT_DESTINATION] = TIMEPOINT_OPTIONS.get(
802 self.
_departure_departure[
"destination_stop_time"][
"Timepoint"], TIMEPOINT_DEFAULT
809 """Return a dictionary for the SQLAlchemy resource given."""
811 for column
in resource.__table__.columns:
812 _dict[column.name] =
str(getattr(resource, column.name))
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":
821 if prefix
and not key.startswith(prefix):
822 key = f
"{prefix} {key}"
827 """Remove attributes whose key starts with prefix."""
829 k: v
for k, v
in self.
_attributes_attributes.items()
if not k.startswith(prefix)
datetime.datetime|None native_value(self)
None update_attributes(self)
dict[str, Any] extra_state_attributes(self)
None __init__(self, Any gtfs, Any|None name, Any origin, Any destination, datetime.timedelta offset, bool include_tomorrow)
None remove_keys(self, str prefix)
None append_keys(self, dict resource, str|None prefix=None)
dict dict_for_table(Any resource)
dict get_next_departure(Any schedule, Any start_station_id, Any end_station_id, datetime.timedelta offset, bool include_tomorrow=False)
None setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
def add_entities(account, async_add_entities, tracked)
def execute(hass, filename, source, data=None, return_response=False)