Skip to content

Commit bfb37af

Browse files
committed
Search GTFS departures across midnight
1 parent 0357378 commit bfb37af

File tree

1 file changed

+138
-57
lines changed
  • homeassistant/components/sensor

1 file changed

+138
-57
lines changed

homeassistant/components/sensor/gtfs.py

+138-57
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
import datetime
1010
import threading
11+
from typing import Optional
1112

1213
import voluptuous as vol
1314

@@ -24,6 +25,7 @@
2425
CONF_DESTINATION = 'destination'
2526
CONF_ORIGIN = 'origin'
2627
CONF_OFFSET = 'offset'
28+
CONF_TOMORROW = 'include_tomorrow'
2729

2830
DEFAULT_NAME = 'GTFS Sensor'
2931
DEFAULT_PATH = 'gtfs'
@@ -40,6 +42,7 @@
4042
7: 'mdi:stairs',
4143
}
4244

45+
DATE_FORMAT = '%Y-%m-%d'
4346
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
4447

4548
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
@@ -48,73 +51,143 @@
4851
vol.Required(CONF_DATA): cv.string,
4952
vol.Optional(CONF_NAME): cv.string,
5053
vol.Optional(CONF_OFFSET, default=0): cv.time_period,
54+
vol.Optional(CONF_TOMORROW, default=False): cv.boolean,
5155
})
5256

5357

54-
def get_next_departure(sched, start_station_id, end_station_id, offset):
58+
def get_next_departure(sched, start_station_id, end_station_id, offset,
59+
include_tomorrow=False) -> Optional[dict]:
5560
"""Get the next departure for the given schedule."""
5661
origin_station = sched.stops_by_id(start_station_id)[0]
5762
destination_station = sched.stops_by_id(end_station_id)[0]
5863

5964
now = datetime.datetime.now() + offset
60-
day_name = now.strftime('%A').lower()
61-
now_str = now.strftime('%H:%M:%S')
62-
today = now.strftime('%Y-%m-%d')
65+
now_date = now.strftime(DATE_FORMAT)
66+
yesterday = now - datetime.timedelta(days=1)
67+
yesterday_date = yesterday.strftime(DATE_FORMAT)
68+
tomorrow = now + datetime.timedelta(days=1)
69+
tomorrow_date = tomorrow.strftime(DATE_FORMAT)
6370

6471
from sqlalchemy.sql import text
6572

66-
sql_query = text("""
67-
SELECT trip.trip_id, trip.route_id,
68-
time(origin_stop_time.arrival_time) AS origin_arrival_time,
69-
time(origin_stop_time.departure_time) AS origin_depart_time,
70-
origin_stop_time.drop_off_type AS origin_drop_off_type,
71-
origin_stop_time.pickup_type AS origin_pickup_type,
72-
origin_stop_time.shape_dist_traveled AS origin_shape_dist_traveled,
73-
origin_stop_time.stop_headsign AS origin_stop_headsign,
74-
origin_stop_time.stop_sequence AS origin_stop_sequence,
75-
time(destination_stop_time.arrival_time) AS dest_arrival_time,
76-
time(destination_stop_time.departure_time) AS dest_depart_time,
77-
destination_stop_time.drop_off_type AS dest_drop_off_type,
78-
destination_stop_time.pickup_type AS dest_pickup_type,
79-
destination_stop_time.shape_dist_traveled AS dest_dist_traveled,
80-
destination_stop_time.stop_headsign AS dest_stop_headsign,
81-
destination_stop_time.stop_sequence AS dest_stop_sequence
82-
FROM trips trip
83-
INNER JOIN calendar calendar
84-
ON trip.service_id = calendar.service_id
85-
INNER JOIN stop_times origin_stop_time
86-
ON trip.trip_id = origin_stop_time.trip_id
87-
INNER JOIN stops start_station
88-
ON origin_stop_time.stop_id = start_station.stop_id
89-
INNER JOIN stop_times destination_stop_time
90-
ON trip.trip_id = destination_stop_time.trip_id
91-
INNER JOIN stops end_station
92-
ON destination_stop_time.stop_id = end_station.stop_id
93-
WHERE calendar.{day_name} = 1
94-
AND origin_depart_time > time(:now_str)
95-
AND start_station.stop_id = :origin_station_id
96-
AND end_station.stop_id = :end_station_id
97-
AND origin_stop_sequence < dest_stop_sequence
98-
AND calendar.start_date <= :today
99-
AND calendar.end_date >= :today
100-
ORDER BY origin_stop_time.departure_time
101-
LIMIT 1
102-
""".format(day_name=day_name))
103-
result = sched.engine.execute(sql_query, now_str=now_str,
73+
# Fetch all departures for yesterday, today and optionally tomorrow,
74+
# up to an overkill maximum in case of a departure every minute for those
75+
# days.
76+
limit = 24 * 60 * 60 * 2
77+
tomorrow_select = tomorrow_where = tomorrow_order = ''
78+
if include_tomorrow:
79+
limit = limit / 2 * 3
80+
tomorrow_name = tomorrow.strftime('%A').lower()
81+
tomorrow_select = "calendar.{} AS tomorrow,".format(tomorrow_name)
82+
tomorrow_where = "OR calendar.{} = 1".format(tomorrow_name)
83+
tomorrow_order = "calendar.{} DESC,".format(tomorrow_name)
84+
85+
sql_query = """
86+
SELECT trip.trip_id, trip.route_id,
87+
time(origin_stop_time.arrival_time) AS origin_arrival_time,
88+
time(origin_stop_time.departure_time) AS origin_depart_time,
89+
date(origin_stop_time.departure_time) AS origin_departure_date,
90+
origin_stop_time.drop_off_type AS origin_drop_off_type,
91+
origin_stop_time.pickup_type AS origin_pickup_type,
92+
origin_stop_time.shape_dist_traveled AS origin_dist_traveled,
93+
origin_stop_time.stop_headsign AS origin_stop_headsign,
94+
origin_stop_time.stop_sequence AS origin_stop_sequence,
95+
time(destination_stop_time.arrival_time) AS dest_arrival_time,
96+
time(destination_stop_time.departure_time) AS dest_depart_time,
97+
destination_stop_time.drop_off_type AS dest_drop_off_type,
98+
destination_stop_time.pickup_type AS dest_pickup_type,
99+
destination_stop_time.shape_dist_traveled AS dest_dist_traveled,
100+
destination_stop_time.stop_headsign AS dest_stop_headsign,
101+
destination_stop_time.stop_sequence AS dest_stop_sequence,
102+
calendar.{yesterday_name} AS yesterday,
103+
calendar.{today_name} AS today,
104+
{tomorrow_select}
105+
calendar.start_date AS start_date,
106+
calendar.end_date AS end_date
107+
FROM trips trip
108+
INNER JOIN calendar calendar
109+
ON trip.service_id = calendar.service_id
110+
INNER JOIN stop_times origin_stop_time
111+
ON trip.trip_id = origin_stop_time.trip_id
112+
INNER JOIN stops start_station
113+
ON origin_stop_time.stop_id = start_station.stop_id
114+
INNER JOIN stop_times destination_stop_time
115+
ON trip.trip_id = destination_stop_time.trip_id
116+
INNER JOIN stops end_station
117+
ON destination_stop_time.stop_id = end_station.stop_id
118+
WHERE (calendar.{yesterday_name} = 1
119+
OR calendar.{today_name} = 1
120+
{tomorrow_where}
121+
)
122+
AND start_station.stop_id = :origin_station_id
123+
AND end_station.stop_id = :end_station_id
124+
AND origin_stop_sequence < dest_stop_sequence
125+
AND calendar.start_date <= :today
126+
AND calendar.end_date >= :today
127+
ORDER BY calendar.{yesterday_name} DESC,
128+
calendar.{today_name} DESC,
129+
{tomorrow_order}
130+
origin_stop_time.departure_time
131+
LIMIT :limit
132+
""".format(yesterday_name=yesterday.strftime('%A').lower(),
133+
today_name=now.strftime('%A').lower(),
134+
tomorrow_select=tomorrow_select,
135+
tomorrow_where=tomorrow_where,
136+
tomorrow_order=tomorrow_order)
137+
result = sched.engine.execute(text(sql_query),
104138
origin_station_id=origin_station.id,
105139
end_station_id=destination_station.id,
106-
today=today)
107-
item = {}
140+
today=now_date,
141+
limit=limit)
142+
143+
# Create lookup timetable for today and possibly tomorrow, taking into
144+
# account any departures from yesterday scheduled after midnight,
145+
# as long as all departures are within the calendar date range.
146+
timetable = {}
147+
yesterday_first = today_first = tomorrow_first = None
108148
for row in result:
109-
item = row
149+
if row['yesterday'] == 1 and yesterday_date >= row['start_date']:
150+
if yesterday_first is None:
151+
yesterday_first = row['origin_departure_date']
152+
if yesterday_first != row['origin_departure_date']:
153+
idx = '{} {}'.format(now_date,
154+
row['origin_depart_time'])
155+
timetable[idx] = {**row, **{'day': 'yesterday'}}
156+
if row['today'] == 1:
157+
if today_first is None:
158+
today_first = row['origin_departure_date']
159+
if today_first == row['origin_departure_date']:
160+
idx_prefix = now_date
161+
else:
162+
idx_prefix = tomorrow_date
163+
idx = '{} {}'.format(idx_prefix, row['origin_depart_time'])
164+
timetable[idx] = {**row, **{'day': 'today'}}
165+
if 'tomorrow' in row and row['tomorrow'] == 1 and tomorrow_date <= \
166+
row['end_date']:
167+
if tomorrow_first is None:
168+
tomorrow_first = row['origin_departure_date']
169+
if tomorrow_first == row['origin_departure_date']:
170+
idx = '{} {}'.format(tomorrow_date,
171+
row['origin_depart_time'])
172+
timetable[idx] = {**row, **{'day': 'tomorrow'}}
173+
174+
_LOGGER.debug("Timetable: %s", sorted(timetable.keys()))
175+
176+
item = {}
177+
for key in sorted(timetable.keys()):
178+
if datetime.datetime.strptime(key, TIME_FORMAT) > now:
179+
item = timetable[key]
180+
_LOGGER.debug("Departure found for station %s @ %s -> %s",
181+
start_station_id, key, item)
182+
break
110183

111184
if item == {}:
112185
return None
113186

114-
origin_arrival_time = '{} {}'.format(today, item['origin_arrival_time'])
115-
origin_depart_time = '{} {}'.format(today, item['origin_depart_time'])
116-
dest_arrival_time = '{} {}'.format(today, item['dest_arrival_time'])
117-
dest_depart_time = '{} {}'.format(today, item['dest_depart_time'])
187+
origin_arrival_time = '{} {}'.format(now_date, item['origin_arrival_time'])
188+
origin_depart_time = '{} {}'.format(now_date, item['origin_depart_time'])
189+
dest_arrival_time = '{} {}'.format(now_date, item['dest_arrival_time'])
190+
dest_depart_time = '{} {}'.format(now_date, item['dest_depart_time'])
118191

119192
depart_time = datetime.datetime.strptime(origin_depart_time, TIME_FORMAT)
120193
arrival_time = datetime.datetime.strptime(dest_arrival_time, TIME_FORMAT)
@@ -129,7 +202,7 @@ def get_next_departure(sched, start_station_id, end_station_id, offset):
129202
'Departure Time': origin_depart_time,
130203
'Drop Off Type': item['origin_drop_off_type'],
131204
'Pickup Type': item['origin_pickup_type'],
132-
'Shape Dist Traveled': item['origin_shape_dist_traveled'],
205+
'Shape Dist Traveled': item['origin_dist_traveled'],
133206
'Headsign': item['origin_stop_headsign'],
134207
'Sequence': item['origin_stop_sequence']
135208
}
@@ -146,6 +219,7 @@ def get_next_departure(sched, start_station_id, end_station_id, offset):
146219

147220
return {
148221
'trip_id': item['trip_id'],
222+
'day': item['day'],
149223
'trip': sched.trips_by_id(item['trip_id'])[0],
150224
'route': route,
151225
'agency': sched.agencies_by_id(route.agency_id)[0],
@@ -168,6 +242,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
168242
destination = config.get(CONF_DESTINATION)
169243
name = config.get(CONF_NAME)
170244
offset = config.get(CONF_OFFSET)
245+
include_tomorrow = config.get(CONF_TOMORROW)
171246

172247
if not os.path.exists(gtfs_dir):
173248
os.makedirs(gtfs_dir)
@@ -189,17 +264,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
189264
pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data))
190265

191266
add_entities([
192-
GTFSDepartureSensor(gtfs, name, origin, destination, offset)])
267+
GTFSDepartureSensor(gtfs, name, origin, destination, offset,
268+
include_tomorrow)])
193269

194270

195271
class GTFSDepartureSensor(Entity):
196272
"""Implementation of an GTFS departures sensor."""
197273

198-
def __init__(self, pygtfs, name, origin, destination, offset):
274+
def __init__(self, pygtfs, name, origin, destination, offset,
275+
include_tomorrow) -> None:
199276
"""Initialize the sensor."""
200277
self._pygtfs = pygtfs
201278
self.origin = origin
202279
self.destination = destination
280+
self._include_tomorrow = include_tomorrow
203281
self._offset = offset
204282
self._custom_name = name
205283
self._icon = ICON
@@ -239,10 +317,13 @@ def update(self):
239317
"""Get the latest data from GTFS and update the states."""
240318
with self.lock:
241319
self._departure = get_next_departure(
242-
self._pygtfs, self.origin, self.destination, self._offset)
320+
self._pygtfs, self.origin, self.destination, self._offset,
321+
self._include_tomorrow)
243322
if not self._departure:
244323
self._state = None
245-
self._attributes = {'Info': 'No more departures today'}
324+
self._attributes = {}
325+
self._attributes['Info'] = "No more departures" if \
326+
self._include_tomorrow else "No more departures today"
246327
if self._name == '':
247328
self._name = (self._custom_name or DEFAULT_NAME)
248329
return
@@ -263,12 +344,12 @@ def update(self):
263344
origin_station.stop_id,
264345
destination_station.stop_id))
265346

347+
self._icon = ICONS.get(route.route_type, ICON)
348+
266349
# Build attributes
267-
self._attributes = {}
350+
self._attributes['day'] = self._departure['day']
268351
self._attributes['offset'] = self._offset.seconds / 60
269352

270-
self._icon = ICONS.get(route.route_type, ICON)
271-
272353
def dict_for_table(resource):
273354
"""Return a dict for the SQLAlchemy resource given."""
274355
return dict((col, getattr(resource, col))

0 commit comments

Comments
 (0)