8
8
import logging
9
9
import datetime
10
10
import threading
11
+ from typing import Optional
11
12
12
13
import voluptuous as vol
13
14
24
25
CONF_DESTINATION = 'destination'
25
26
CONF_ORIGIN = 'origin'
26
27
CONF_OFFSET = 'offset'
28
+ CONF_TOMORROW = 'include_tomorrow'
27
29
28
30
DEFAULT_NAME = 'GTFS Sensor'
29
31
DEFAULT_PATH = 'gtfs'
40
42
7 : 'mdi:stairs' ,
41
43
}
42
44
45
+ DATE_FORMAT = '%Y-%m-%d'
43
46
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
44
47
45
48
PLATFORM_SCHEMA = PLATFORM_SCHEMA .extend ({
48
51
vol .Required (CONF_DATA ): cv .string ,
49
52
vol .Optional (CONF_NAME ): cv .string ,
50
53
vol .Optional (CONF_OFFSET , default = 0 ): cv .time_period ,
54
+ vol .Optional (CONF_TOMORROW , default = False ): cv .boolean ,
51
55
})
52
56
53
57
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 ]:
55
60
"""Get the next departure for the given schedule."""
56
61
origin_station = sched .stops_by_id (start_station_id )[0 ]
57
62
destination_station = sched .stops_by_id (end_station_id )[0 ]
58
63
59
64
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 )
63
70
64
71
from sqlalchemy .sql import text
65
72
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 ),
104
138
origin_station_id = origin_station .id ,
105
139
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
108
148
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
110
183
111
184
if item == {}:
112
185
return None
113
186
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' ])
118
191
119
192
depart_time = datetime .datetime .strptime (origin_depart_time , TIME_FORMAT )
120
193
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):
129
202
'Departure Time' : origin_depart_time ,
130
203
'Drop Off Type' : item ['origin_drop_off_type' ],
131
204
'Pickup Type' : item ['origin_pickup_type' ],
132
- 'Shape Dist Traveled' : item ['origin_shape_dist_traveled ' ],
205
+ 'Shape Dist Traveled' : item ['origin_dist_traveled ' ],
133
206
'Headsign' : item ['origin_stop_headsign' ],
134
207
'Sequence' : item ['origin_stop_sequence' ]
135
208
}
@@ -146,6 +219,7 @@ def get_next_departure(sched, start_station_id, end_station_id, offset):
146
219
147
220
return {
148
221
'trip_id' : item ['trip_id' ],
222
+ 'day' : item ['day' ],
149
223
'trip' : sched .trips_by_id (item ['trip_id' ])[0 ],
150
224
'route' : route ,
151
225
'agency' : sched .agencies_by_id (route .agency_id )[0 ],
@@ -168,6 +242,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
168
242
destination = config .get (CONF_DESTINATION )
169
243
name = config .get (CONF_NAME )
170
244
offset = config .get (CONF_OFFSET )
245
+ include_tomorrow = config .get (CONF_TOMORROW )
171
246
172
247
if not os .path .exists (gtfs_dir ):
173
248
os .makedirs (gtfs_dir )
@@ -189,17 +264,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
189
264
pygtfs .append_feed (gtfs , os .path .join (gtfs_dir , data ))
190
265
191
266
add_entities ([
192
- GTFSDepartureSensor (gtfs , name , origin , destination , offset )])
267
+ GTFSDepartureSensor (gtfs , name , origin , destination , offset ,
268
+ include_tomorrow )])
193
269
194
270
195
271
class GTFSDepartureSensor (Entity ):
196
272
"""Implementation of an GTFS departures sensor."""
197
273
198
- def __init__ (self , pygtfs , name , origin , destination , offset ):
274
+ def __init__ (self , pygtfs , name , origin , destination , offset ,
275
+ include_tomorrow ) -> None :
199
276
"""Initialize the sensor."""
200
277
self ._pygtfs = pygtfs
201
278
self .origin = origin
202
279
self .destination = destination
280
+ self ._include_tomorrow = include_tomorrow
203
281
self ._offset = offset
204
282
self ._custom_name = name
205
283
self ._icon = ICON
@@ -239,10 +317,13 @@ def update(self):
239
317
"""Get the latest data from GTFS and update the states."""
240
318
with self .lock :
241
319
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 )
243
322
if not self ._departure :
244
323
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"
246
327
if self ._name == '' :
247
328
self ._name = (self ._custom_name or DEFAULT_NAME )
248
329
return
@@ -263,12 +344,12 @@ def update(self):
263
344
origin_station .stop_id ,
264
345
destination_station .stop_id ))
265
346
347
+ self ._icon = ICONS .get (route .route_type , ICON )
348
+
266
349
# Build attributes
267
- self ._attributes = {}
350
+ self ._attributes [ 'day' ] = self . _departure [ 'day' ]
268
351
self ._attributes ['offset' ] = self ._offset .seconds / 60
269
352
270
- self ._icon = ICONS .get (route .route_type , ICON )
271
-
272
353
def dict_for_table (resource ):
273
354
"""Return a dict for the SQLAlchemy resource given."""
274
355
return dict ((col , getattr (resource , col ))
0 commit comments