Compare commits

..

24 Commits
gtfs-r ... main

Author SHA1 Message Date
Nahuel Lofeudo 014e9dd0d3 Fixed rounded corners in the backing 2025-12-26 17:45:13 +00:00
Nahuel Lofeudo d3d55eb85e changes to the enclosure when migrating to FreeCad 1.0 2025-12-26 14:18:08 +00:00
Nahuel Lofeudo 03d22f47d7 Cleaned up a bunch of typos, removed unused code and made it tidier. 2025-01-12 14:35:04 +00:00
Nahuel Lofeudo d9811d3f91 Updated requirements.txt 2025-01-12 14:32:38 +00:00
Nahuel Lofeudo cc2b63e816 Use Urllib3 to stream the initial feed. 2023-11-18 16:13:01 +00:00
Nahuel Lofeudo b5d9fa1dd8 Add timeouts to the calls to the GTFS-R API 2023-11-11 15:46:51 +00:00
Nahuel Lofeudo 3c3199e3eb Catch exceptions in refresh() 2023-11-10 09:57:10 +00:00
Nahuel Lofeudo 9f87527d9a Add some last-resort exception handling 2023-10-08 15:27:35 +01:00
Nahuel Lofeudo 35d0261682 Make the GTFS-R client resilient to network errors. 2023-09-23 21:21:34 +01:00
Nahuel Lofeudo 36749669b4 Replace ad-hoc scheduler with package schedule 2023-09-17 17:47:08 +01:00
Nahuel Lofeudo d5c57b35eb
Merge pull request #6 from Gultak/Gultak-patch-4
Make the font file configurable.
2023-09-17 16:43:38 +01:00
Nahuel Lofeudo 3c2aa25cd8
Merge pull request #7 from Gultak/Gultak-patch-1
Update README.md
2023-09-08 14:12:43 +01:00
Gultak 076066ce0b Make the font file configurable. 2023-08-27 16:46:16 +01:00
Gultak e59201bfa3
Update README.md
Update README.md to include installation of `python3-pip`.
2023-08-22 22:50:15 +01:00
Nahuel Lofeudo 8bf7503a39 Do not initialize Pygame modules that we don't use (avoid sound underruns) 2023-07-09 22:01:49 +01:00
Nahuel Lofeudo c244a4bc21 Update gitignore 2023-06-24 22:45:30 +01:00
Nahuel Lofeudo e39e18d243 Fix lookup of walk time to the stop 2023-06-24 22:44:03 +01:00
Nahuel Lofeudo ccf7c62727 Remove VS Code directories from repo 2023-06-24 06:21:00 +01:00
Nahuel Lofeudo b16830020d Add more debug info 2023-06-24 06:06:17 +01:00
Nahuel Lofeudo 12cb88ed20 Move the feed .zip file to /tmp. Add debug info on refresh. 2023-06-24 05:55:23 +01:00
Nahuel Lofeudo f8857b3015 Add debug info for when a bus's headsign isnt found. 2023-06-24 05:26:52 +01:00
Nahuel Lofeudo 053e1191e0 Update enclosure model. Ignore exported STLs 2023-06-24 05:22:51 +01:00
Nahuel Lofeudo 4517bf0dbb Add code to debug an issue with destinations 2023-06-20 16:28:40 +01:00
Nahuel Lofeudo a89d28129f Update GTFS-R URL for nationaltransport.ie 2023-06-20 06:26:29 +01:00
12 changed files with 256 additions and 332 deletions

2
.gitignore vendored
View File

@ -1,4 +1,6 @@
__pycache__/* __pycache__/*
.vscode/*
Enclosure/*.stl
*test* *test*
*.svg *.svg
*.FCStd1 *.FCStd1

View File

@ -1,48 +0,0 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 2,
"id": "1f552469",
"metadata": {},
"outputs": [],
"source": [
"import gtfs_kit as gk\n",
"import pandas as pd\n",
"import datetime\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "292cc196",
"metadata": {},
"outputs": [],
"source": [
"feed=gk.read_feed('google_transit_combined.zip', dist_units='km')\n",
"feed"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

16
.vscode/launch.json vendored
View File

@ -1,16 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: GTFS Client",
"type": "python",
"request": "launch",
"program": "gtfs_client.py",
"console": "integratedTerminal",
"justMyCode": true
}
]
}

Binary file not shown.

View File

@ -47,15 +47,16 @@ If your display's resolution is not 1920x720, you will also need to change the c
* libspatialindex-c6 * libspatialindex-c6
* yaml * yaml
``` ```shell
$ sudo apt install python3-iso8601 python3-zeep libsdl2-ttf-2.0-0 python3-numpy python3-pandas python3-fiona python3-pyproj libspatialindex-c6 python3-yaml $ sudo apt install python3-iso8601 python3-zeep libsdl2-ttf-2.0-0 python3-numpy python3-pandas python3-fiona python3-pyproj libspatialindex-c6 python3-yaml python3-urllib3
``` ```
* pygame 2 * pygame 2
* GTFS-Kit * GTFS-Kit
``` ```shell
$ sudo pip3 install pygame gtfs_kit $ sudo apt install python3-pip
$ sudo pip3 install pygame gtfs_kit schedule --break-system-packages
``` ```

View File

@ -1,6 +1,6 @@
import datetime import datetime
class ArrivalTime(): class ArrivalTime:
""" Represents the arrival times of buses at one of the configured stops """ """ Represents the arrival times of buses at one of the configured stops """
def __init__(self, stop_id: str, route_id: str, destination: str, due_in_seconds: int, is_added: bool = False) -> None: def __init__(self, stop_id: str, route_id: str, destination: str, due_in_seconds: int, is_added: bool = False) -> None:
@ -14,7 +14,7 @@ class ArrivalTime():
def due_in_minutes(self) -> int: def due_in_minutes(self) -> int:
return int(self.due_in_seconds / 60) return int(self.due_in_seconds / 60)
def isDue(self) -> bool: def is_due(self) -> bool:
return self.due_in_minutes < 1 return self.due_in_minutes < 1
def due_in_str(self) -> str: def due_in_str(self) -> str:

View File

@ -4,40 +4,45 @@ class Config:
def __init__(self): def __init__(self):
# Load the config file # Load the config file
with open("config.yaml") as f: with open("config.yaml") as f:
self.__config = yaml.safe_load(f.read()) self.config = yaml.safe_load(f.read())
# Pre-load some dictionaries to simplify lookups # Preload some dictionaries to simplify lookups
self.__walk_time_by_stop = {} self.walk_time_by_stop = {}
for s in self.__config.get("stops", []): for s in self.config.get("stops", []):
self.__walk_time_by_stop[s["stop_id"]] = s["walk_time"] self.walk_time_by_stop[str(s["stop_id"])] = s["walk_time"]
@property @property
def gtfs_feed_url(self) -> str: def gtfs_feed_url(self) -> str:
return self.__config.get("gtfs-feed-url") return self.config.get("gtfs-feed-url")
@property @property
def gtfs_api_url(self) -> str: def gtfs_api_url(self) -> str:
return self.__config.get("gtfs-r-api-url") return self.config.get("gtfs-r-api-url")
@property @property
def gtfs_api_key(self) -> str: def gtfs_api_key(self) -> str:
return self.__config.get("gtfs-r-api_key") return self.config.get("gtfs-r-api_key")
@property @property
def update_interval_seconds(self) -> int: def update_interval_seconds(self) -> int:
return self.__config.get("update-interval-seconds") return self.config.get("update-interval-seconds")
@property
def font_file(self) -> str:
return self.config.get("font-file")
@property @property
def stop_codes(self) -> list[str]: def stop_codes(self) -> list[str]:
return [str(s["stop_id"]) for s in self.__config.get("stops")] return [str(s["stop_id"]) for s in self.config.get("stops")]
def minutes_to_stop(self, stop_id) -> int: def minutes_to_stop(self, stop_id) -> int:
return self.__walk_time_by_stop.get(stop_id, 0) minutes = self.walk_time_by_stop.get(stop_id, 0)
return minutes
def routes_for_stops(self) -> map: def routes_for_stops(self) -> map:
result = {} result = {}
for s in self.__config.get("stops"): for s in self.config.get("stops"):
for r in s.get("routes", []): for r in s.get("routes", []):
routes = (result.get(s.get("stop_id")) or []) routes = (result.get(s.get("stop_id")) or [])
routes.append(r) routes.append(r)

View File

@ -3,7 +3,7 @@
# URLs and API keys for the different parts of the GTFS-R feed # URLs and API keys for the different parts of the GTFS-R feed
# You should not change these unless a new version of the API is released # You should not change these unless a new version of the API is released
gtfs-feed-url: "https://www.transportforireland.ie/transitData/Data/GTFS_Realtime.zip" gtfs-feed-url: "https://www.transportforireland.ie/transitData/Data/GTFS_Realtime.zip"
gtfs-r-api-url: "https://api.nationaltransport.ie/gtfsr/v2/gtfsr?format=json" gtfs-r-api-url: "https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json"
# You should change this one. Use the key you get from TFI when you register for GTFS-R access # You should change this one. Use the key you get from TFI when you register for GTFS-R access
gtfs-r-api_key: "API KEY GOES HERE" gtfs-r-api_key: "API KEY GOES HERE"
@ -12,6 +12,9 @@ gtfs-r-api_key: "API KEY GOES HERE"
# It must be strictly larger than 60 because the GTFS-R API will throttle us otherwise # It must be strictly larger than 60 because the GTFS-R API will throttle us otherwise
update-interval-seconds: 62 update-interval-seconds: 62
# The font to use for the display.
font-file: "jd_lcd_rounded.ttf"
stops: [ stops: [
{ {
# Route 15A # Route 15A

View File

@ -8,12 +8,11 @@ import pandas as pd
import queue import queue
import refresh_feed import refresh_feed
import requests import requests
import sys
import time import time
import threading
import traceback
import zipfile import zipfile
class GTFSClient(): class GTFSClient:
def __init__(self, feed_url: str, gtfs_r_url: str, gtfs_r_api_key: str, def __init__(self, feed_url: str, gtfs_r_url: str, gtfs_r_api_key: str,
stop_codes: list[str], routes_for_stops: dict[str, str], stop_codes: list[str], routes_for_stops: dict[str, str],
update_queue: queue.Queue, update_interval_seconds: int = 60): update_queue: queue.Queue, update_interval_seconds: int = 60):
@ -21,20 +20,20 @@ class GTFSClient():
self.stop_codes = stop_codes self.stop_codes = stop_codes
self.routes_for_stops = routes_for_stops self.routes_for_stops = routes_for_stops
feed_name = feed_url.split('/')[-1] feed_name = '/tmp/' + feed_url.split('/')[-1]
self.gtfs_r_url = gtfs_r_url self.gtfs_r_url = gtfs_r_url
self.gtfs_r_api_key = gtfs_r_api_key self.gtfs_r_api_key = gtfs_r_api_key
# Make sure that the feed file is up to date # Make sure that the feed file is up to date
try: try:
last_mtime = os.stat(feed_name).st_mtime last_mtime = int(os.stat(feed_name).st_mtime)
except: except:
last_mtime = 0 last_mtime = 0
refreshed, new_mtime = refresh_feed.update_local_file_from_url_v1(last_mtime, feed_name, feed_url) _, new_mtime = refresh_feed.update_local_file_from_url_v1(last_mtime, feed_name, feed_url)
# Load the feed # Load the feed
self.feed = self._read_feed(feed_name, dist_units='km', stop_codes = stop_codes) self.feed = self._read_feed(feed_name, dist_units='km')
gc.collect() gc.collect()
self.stop_ids = self.__wanted_stop_ids() self.stop_ids = self.__wanted_stop_ids()
self.deltas = {} self.deltas = {}
@ -45,9 +44,8 @@ class GTFSClient():
self._update_queue = update_queue self._update_queue = update_queue
if update_interval_seconds and update_queue: if update_interval_seconds and update_queue:
self._update_interval_seconds = update_interval_seconds self._update_interval_seconds = update_interval_seconds
self._refresh_thread = threading.Thread(target=lambda: every(update_interval_seconds, self.refresh))
def _read_feed(self, path: gk.Path, dist_units: str, stop_codes: list[str]) -> gk.Feed: def _read_feed(self, path: str, dist_units: str) -> gk.Feed:
""" """
NOTE: This helper method was extracted from gtfs_kit.feed to modify it NOTE: This helper method was extracted from gtfs_kit.feed to modify it
to only load the stop_times for the stops we are interested in, to only load the stop_times for the stops we are interested in,
@ -56,7 +54,7 @@ class GTFSClient():
This version also reads CSV data straight from the zip file to avoid This version also reads CSV data straight from the zip file to avoid
wearing out the Pi's SD card. wearing out the Pi's SD card.
""" """
FILES_TO_LOAD = [ files_to_load = [
# List of feed files to load. stop_times.txt is loaded separately. # List of feed files to load. stop_times.txt is loaded separately.
'trips.txt', 'trips.txt',
'routes.txt', 'routes.txt',
@ -66,15 +64,15 @@ class GTFSClient():
'agency.txt' 'agency.txt'
] ]
path = gk.Path(path) if not os.path.exists(path):
if not path.exists(): raise ValueError("Path {} does not exist".format(path))
raise ValueError(f"Path {path} does not exist")
print("Loading GTFS feed {}".format(path), file=sys.stderr)
gc.collect() gc.collect()
feed_dict = {table: None for table in gk.cs.GTFS_REF["table"]} feed_dict = {table: None for table in gk.cs.GTFS_REF["table"]}
with zipfile.ZipFile(path) as z: with zipfile.ZipFile(path) as z:
for filename in FILES_TO_LOAD: for filename in files_to_load:
table = filename.split(".")[0] table = filename.split(".")[0]
# read the file # read the file
with z.open(filename) as f: with z.open(filename) as f:
@ -216,20 +214,22 @@ class GTFSClient():
next_buses.drop(index=ids_to_delete, inplace=True) next_buses.drop(index=ids_to_delete, inplace=True)
return next_buses return next_buses
def __time_to_seconds(self, s: str) -> int: @staticmethod
def __time_to_seconds(s: str) -> int:
sx = s.split(":") sx = s.split(":")
if len(sx) != 3: if len(sx) != 3:
print("Malformed timestamp:", s) print("Malformed timestamp:", s)
return 0 return 0
return int(sx[0]) * 3600 + int(sx[1]) * 60 + int (sx[2]) return int(sx[0]) * 3600 + int(sx[1]) * 60 + int (sx[2])
def __due_in_seconds(self, time_str: str) -> int: @staticmethod
def __due_in_seconds(time_str: str) -> int:
""" """
Returns the number of seconds in the future that the time_str (format hh:mm:ss) is Returns the number of seconds in the future that the time_str (format hh:mm:ss) is
""" """
now = datetime.datetime.now().strftime("%H:%M:%S") now = datetime.datetime.now().strftime("%H:%M:%S")
tnow = self.__time_to_seconds(now) tnow = GTFSClient.__time_to_seconds(now)
tstop = self.__time_to_seconds(time_str) tstop = GTFSClient.__time_to_seconds(time_str)
if tstop > tnow: if tstop > tnow:
return tstop - tnow return tstop - tnow
else: else:
@ -242,88 +242,96 @@ class GTFSClient():
Look up a destination string in Trips from the route and direction Look up a destination string in Trips from the route and direction
""" """
trips = self.feed.trips trips = self.feed.trips
return trips[(trips["route_id"] == route_id) & (trips["direction_id"] == direction_id)].head(1)["trip_headsign"].item() destination = trips[(trips["route_id"] == route_id) & (trips["direction_id"] == direction_id)].head(1)["trip_headsign"].item()
# For some reason destination sometimes isn't a string. Try to find out why
if not destination.__class__ == str:
sys.stderr.write("Destination not found for route " + str(route_id) + ", direction " + str(direction_id) + "\n")
destination = "---- ?????? ----"
return destination
def __poll_gtfsr_deltas(self) -> list[map, set]: def __poll_gtfsr_deltas(self) -> tuple[dict, list, list]:
try:
# Poll GTFS-R API
if self.gtfs_r_api_key != "":
headers = {"x-api-key": self.gtfs_r_api_key}
response = requests.get(url = self.gtfs_r_url, headers = headers, timeout=(2, 10))
if response.status_code != 200:
print("GTFS-R sent non-OK response: {}\n{}".format(response.status_code, response.text))
return {}, [], []
# Poll GTFS-R API deltas_json = json.loads(response.text)
if self.gtfs_r_api_key != "": else:
headers = {"x-api-key": self.gtfs_r_api_key} deltas_json = json.load(open("example.json"))
response = requests.get(url = self.gtfs_r_url, headers = headers)
if response.status_code != 200:
print("GTFS-R sent non-OK response: {}\n{}".format(response.status_code, response.text))
return ({}, [], [])
deltas_json = json.loads(response.text) deltas = {}
else: canceled_trips = set()
deltas_json = json.load(open("example.json")) added_stops = []
deltas = {} # Pre-compute some data to use for added trips:
canceled_trips = set() relevant_service_ids = self.__current_service_ids()
added_stops = [] relevant_trips = self.feed.trips[self.feed.trips["service_id"].isin(relevant_service_ids)]
relevant_route_ids = set(relevant_trips["route_id"])
today = datetime.date.today().strftime("%Y%m%d")
# Pre-compute some data to use for added trips: for e in deltas_json.get("entity", []):
relevant_service_ids = self.__current_service_ids() try:
relevant_trips = self.feed.trips[self.feed.trips["service_id"].isin(relevant_service_ids)] trip_update = e.get("trip_update")
relevant_route_ids = set(relevant_trips["route_id"]) trip = trip_update.get("trip")
today = datetime.date.today().strftime("%Y%m%d") trip_id = trip.get("trip_id")
trip_action = trip.get("schedule_relationship")
if trip_action == "SCHEDULED":
for u in e.get("trip_update", {}).get("stop_time_update", []):
delay = u.get("arrival", u.get("departure", {})).get("delay", 0)
deltas_for_trip = (deltas.get(trip_id) or {})
deltas_for_trip[u.get("stop_id")] = delay
deltas[trip_id] = deltas_for_trip
for e in deltas_json.get("entity", []): elif trip_action == "ADDED":
is_deleted = e.get("is_deleted") or False start_date = trip.get("start_date")
try: start_time = trip.get("start_time")
trip_update = e.get("trip_update") route_id = trip.get("route_id")
trip = trip_update.get("trip") direction_id = trip.get("direction_id")
trip_id = trip.get("trip_id")
trip_action = trip.get("schedule_relationship")
if trip_action == "SCHEDULED":
for u in e.get("trip_update", {}).get("stop_time_update", []):
delay = u.get("arrival", u.get("departure", {})).get("delay", 0)
deltas_for_trip = (deltas.get(trip_id) or {})
deltas_for_trip[u.get("stop_id")] = delay
deltas[trip_id] = deltas_for_trip
elif trip_action == "ADDED": # Check if the route is part of the routes we care about
start_date = trip.get("start_date") if not route_id in relevant_route_ids:
start_time = trip.get("start_time") continue
route_id = trip.get("route_id")
direction_id = trip.get("direction_id")
# Check if the route is part of the routes we care about
if not route_id in relevant_route_ids:
continue
# And that it's for today # And that it's for today
current_time = datetime.datetime.now().strftime("%H:%M:%S") current_time = datetime.datetime.now().strftime("%H:%M:%S")
if start_date > today or start_time > current_time: if start_date > today or start_time > current_time:
continue continue
# Look for the entry for any of the stops we want # Look for the entry for any of the stops we want
wanted_stop_ids = self.__wanted_stop_ids() wanted_stop_ids = self.__wanted_stop_ids()
for stop_time_update in e.get("trip_update").get("stop_time_update", []): for stop_time_update in e.get("trip_update").get("stop_time_update", []):
if stop_time_update.get("stop_id", "") in wanted_stop_ids: if stop_time_update.get("stop_id", "") in wanted_stop_ids:
arrival_time = int((stop_time_update.get("arrival", stop_time_update.get("departure", {})).get("time", 0))) arrival_time = int((stop_time_update.get("arrival", stop_time_update.get("departure", {})).get("time", 0)))
if arrival_time < int(time.time()): if arrival_time < int(time.time()):
continue continue
new_arrival = ArrivalTime( new_arrival = ArrivalTime(
stop_id = stop_time_update.get("stop_id"), stop_id = stop_time_update.get("stop_code"),
route_id = self.feed.routes[self.feed.routes["route_id"] == route_id]["route_short_name"].item(), route_id = self.feed.routes[self.feed.routes["route_id"] == route_id]["route_short_name"].item(),
destination = self.__lookup_headsign_by_route(route_id, direction_id), destination = self.__lookup_headsign_by_route(route_id, direction_id),
due_in_seconds = arrival_time - int(time.time()), due_in_seconds = arrival_time - int(time.time()),
is_added = True is_added = True
) )
print("Added route:", new_arrival) print("Added route:", new_arrival)
added_stops.append(new_arrival) added_stops.append(new_arrival)
elif trip_action == "CANCELED": elif trip_action == "CANCELED":
canceled_trips.add(trip_id) canceled_trips.add(trip_id)
else: else:
print("Unsupported action:", trip_action) print("Unsupported action:", trip_action)
except Exception as x: except Exception as x:
print("Error parsing GTFS-R entry:", str(e)) print("Error parsing GTFS-R entry:", str(e))
raise(x) raise x
return deltas, canceled_trips, added_stops return deltas, canceled_trips, added_stops
except Exception as e:
print("Polling for GTFS-R failed:", str(e))
return {}, [], []
def get_next_n_buses(self, num_entries: int) -> pd.core.frame.DataFrame: def get_next_n_buses(self, num_entries: int) -> pd.core.frame.DataFrame:
@ -338,69 +346,48 @@ class GTFSClient():
return joined_data return joined_data
def start(self) -> None:
""" Start the refresh thread """
self._refresh_thread.start()
self.refresh()
def refresh(self): def refresh(self):
""" """
Create and enqueue the refreshed stop data Create and enqueue the refreshed stop data
""" """
# Retrieve the GTFS-R deltas
deltas, canceled_trips, added_stops = self.__poll_gtfsr_deltas()
if len(deltas) > 0 or len(canceled_trips) > 0 or len(added_stops) > 0:
# Only update deltas and canceled trips if the API returns data
self.deltas = deltas
self.canceled_trips = canceled_trips
self.added_stops = added_stops
arrivals = []
# take more entries than we need in case there are cancelations
buses = self.get_next_n_buses(15)
for index, bus in buses.iterrows():
if not bus["trip_id"] in self.canceled_trips:
delta = self.deltas.get(bus["trip_id"], {}).get(bus["stop_id"], 0)
if delta != 0:
print("Delta for route {} stop {} is {}".format(bus["route_short_name"], bus["stop_id"], delta))
arrival = ArrivalTime(stop_id = bus["stop_id"],
route_id = bus["route_short_name"],
destination = bus["trip_headsign"],
due_in_seconds = self.__due_in_seconds(bus["arrival_time"]) + delta,
is_added = False
)
arrivals.append(arrival)
if len(self.added_stops) > 0:
# Append the added stops from GTFS-R and re-sort
arrivals.extend(self.added_stops)
arrivals.sort()
# Select the first 5 of what remains
arrivals = arrivals[0:5]
if self._update_queue:
self._update_queue.put(arrivals)
gc.collect()
return arrivals
def every(delay, task) -> None:
""" Auxilliary function to schedule updates.
Taken from https://stackoverflow.com/questions/474528/what-is-the-best-way-to-repeatedly-execute-a-function-every-x-seconds
"""
next_time = time.time() + delay
while True:
time.sleep(max(0, next_time - time.time()))
try: try:
task() # Retrieve the GTFS-R deltas
except Exception: deltas, canceled_trips, added_stops = self.__poll_gtfsr_deltas()
traceback.print_exc() if len(deltas) > 0 or len(canceled_trips) > 0 or len(added_stops) > 0:
# in production code you might want to have this instead of course: # Only update deltas and canceled trips if the API returns data
# logger.exception("Problem while executing repetitive task.") self.deltas = deltas
# skip tasks if we are behind schedule: self.canceled_trips = canceled_trips
next_time += (time.time() - next_time) // delay * delay + delay self.added_stops = added_stops
arrivals = []
# take more entries than we need in case there are cancellations
buses = self.get_next_n_buses(15)
for index, bus in buses.iterrows():
if not bus["trip_id"] in self.canceled_trips:
delta = self.deltas.get(bus["trip_id"], {}).get(bus["stop_id"], 0)
if delta != 0:
print("Delta for route {} stop {} is {}".format(bus["route_short_name"], bus["stop_id"], delta))
arrival = ArrivalTime(stop_id = bus["stop_code"],
route_id = bus["route_short_name"],
destination = bus["trip_headsign"],
due_in_seconds = GTFSClient.__due_in_seconds(bus["arrival_time"]) + delta,
is_added = False
)
arrivals.append(arrival)
if len(self.added_stops) > 0:
# Append the added stops from GTFS-R and re-sort
arrivals.extend(self.added_stops)
arrivals.sort()
# Select the first 5 of what remains
arrivals = arrivals[0:5]
if self._update_queue:
self._update_queue.put(arrivals)
gc.collect()
except Exception as e:
print("Exception in refresh: {}".format(str(e)))

100
main.py
View File

@ -7,6 +7,7 @@ import gc
from glob import glob from glob import glob
import pygame import pygame
from pygame.locals import * from pygame.locals import *
import schedule
from time import sleep from time import sleep
import queue import queue
from arrival_times import ArrivalTime from arrival_times import ArrivalTime
@ -71,31 +72,34 @@ def write_line(line: int, text: str, text_color: Color = COLOR_LCD_AMBER):
def update_screen(config: Config, updates: list[ArrivalTime]) -> None: def update_screen(config: Config, updates: list[ArrivalTime]) -> None:
""" Repaint the screen with the new arrival times """ """ Repaint the screen with the new arrival times """
updates = updates[0:LINE_COUNT] # take the first X lines try:
for line_num, update in enumerate(updates): updates = updates[0:LINE_COUNT] # take the first X lines
# Find what color we need to use for the ETA for line_num, update in enumerate(updates):
time_to_walk = update.due_in_minutes - (config.minutes_to_stop(update.stop_id) or 0) # Find what color we need to use for the ETA
lcd_color = None time_to_walk = update.due_in_minutes - (config.minutes_to_stop(update.stop_id) or 0)
if time_to_walk > 5: lcd_color = None
lcd_color = COLOR_LCD_GREEN if time_to_walk > 5:
elif time_to_walk > 1: lcd_color = COLOR_LCD_GREEN
lcd_color = COLOR_LCD_AMBER elif time_to_walk > 1:
else: lcd_color = COLOR_LCD_AMBER
lcd_color = COLOR_LCD_RED else:
lcd_color = COLOR_LCD_RED
# Draw the line # Draw the line
write_entry( write_entry(
line = line_num, line = line_num,
route = update.route_id, route = update.route_id,
destination = update.destination, destination = update.destination,
time_left = 'Due' if update.isDue() else update.due_in_str(), time_left = 'Due' if update.is_due() else update.due_in_str(),
time_color = lcd_color, time_color = lcd_color,
text_color = COLOR_LCD_GREEN if update.is_added else COLOR_LCD_AMBER text_color = COLOR_LCD_GREEN if update.is_added else COLOR_LCD_AMBER
) )
# Add the current time to the bottom line # Add the current time to the bottom line
datetime_text = "Current time: " + datetime.today().strftime("%d/%m/%Y %H:%M") datetime_text = "Current time: " + datetime.today().strftime("%d/%m/%Y %H:%M")
write_line(5, datetime_text) write_line(5, datetime_text)
except Exception as e:
print("Error updating screen: ", str(e))
def clear_screen() -> None: def clear_screen() -> None:
""" Clear screen """ """ Clear screen """
@ -120,10 +124,12 @@ def main():
config = Config() config = Config()
# Initialise graphics context # Initialise graphics context
pygame.init() pygame.display.init()
pygame.font.init()
window = init_screen() window = init_screen()
pygame.font.init() pygame.font.init()
font = pygame.font.Font(TEXT_FONT, TEXT_SIZE) font = pygame.font.Font(config.font_file or TEXT_FONT, TEXT_SIZE)
# Init screen # Init screen
clear_screen() clear_screen()
@ -140,33 +146,39 @@ def main():
update_queue=update_queue, update_queue=update_queue,
update_interval_seconds=config.update_interval_seconds) update_interval_seconds=config.update_interval_seconds)
scheduler.start() # Schedule feed refresh, and force the first one
schedule.every(config.update_interval_seconds).seconds.do(scheduler.refresh)
scheduler.refresh()
# Main event loop # Main event loop
running = True running = True
while running: while running:
# Pygame event handling begins try:
if pygame.event.peek(): # Pygame event handling begins
for e in pygame.event.get(): if pygame.event.peek():
if e.type == pygame.QUIT: for e in pygame.event.get():
running = False if e.type == pygame.QUIT:
elif e.type == pygame.KEYDOWN:
if e.key == pygame.K_ESCAPE:
running = False running = False
pygame.display.flip() elif e.type == pygame.KEYDOWN:
# Pygame event handling ends if e.key == pygame.K_ESCAPE:
running = False
pygame.display.flip()
# Pygame event handling ends
# Display update begins # Display update begins
if update_queue.qsize() > 0: schedule.run_pending()
clear_screen() if update_queue.qsize() > 0:
updates = update_queue.get() clear_screen()
update_screen(config, updates) updates = update_queue.get()
update_screen(config, updates)
pygame.display.flip() pygame.display.flip()
gc.collect() gc.collect()
# Display update ends # Display update ends
sleep(0.2) sleep(0.2)
except Exception as e:
print("Exception in main loop: ", str(e))
pygame.quit() pygame.quit()
exit(0) exit(0)

View File

@ -7,6 +7,7 @@ import os
import sys import sys
import time import time
import requests import requests
import urllib3
# First we construct a handful of functions - testing happens down at the end # First we construct a handful of functions - testing happens down at the end
def httpdate_to_ts(dt): def httpdate_to_ts(dt):
@ -17,16 +18,6 @@ def httpdate_to_ts(dt):
def ts_to_httpdate(ts): def ts_to_httpdate(ts):
return email.utils.formatdate(timeval=ts, localtime=False, usegmt=True) return email.utils.formatdate(timeval=ts, localtime=False, usegmt=True)
def write_file_with_time(filename, content, timestamp):
# put the content into the file
with open(filename, 'wb') as fp:
fp.write(content)
# Then set the file's timestamps as requested
os.utime(filename, times=(time.time(), timestamp))
# v1: download remote file if HTTP's Last-Modified header indicates that # v1: download remote file if HTTP's Last-Modified header indicates that
# the file has been updated. This requires the remote server to support # the file has been updated. This requires the remote server to support
# sending the Last-Modified header. # sending the Last-Modified header.
@ -51,54 +42,34 @@ def update_local_file_from_url_v1(last_mtime, local_file, url):
# If file is newer than last one we saw, get it # If file is newer than last one we saw, get it
updated = False updated = False
if mtime > int(last_mtime): print('Comparing feed mtimes: feed: {} vs remote {}'.format(str(last_mtime), str(mtime)), file=sys.stderr)
if not last_mtime or mtime > int(last_mtime):
print('Refreshing feed..', file=sys.stderr)
updated = True updated = True
r2 = requests.get(url) # download the new file content # download the new file content
if r2.status_code != requests.codes.ok: conn = urllib3.connection_from_url(url)
r2 = conn.request(method="GET", url=url, preload_content=False)
if r2.status != 200:
# http request failed # http request failed
print('HEY! get for {} returned {}'.format(url, r2.status_code), print('HEY! get for {} returned {}'.format(url, r2.status_code),
file=sys.stderr) file=sys.stderr)
try:
r2.release_conn()
except Exception as e:
print('Could not release connection to {}: {}'.format(url, str(e)))
return False, last_mtime return False, last_mtime
# write new content to local file with open(local_file,'bw') as f:
write_file_with_time(local_file, r2.content, mtime) for chunk in r2.stream(amt=65536, decode_content=True):
f.write(chunk)
return updated, mtime r2.release_conn()
# Change the mtime of the file
os.utime(local_file, (mtime, mtime))
# v2: download remote file conditionally, with HTTP's If-Modified-Since header.
# This requires the remote server to support both sending the Last-Modified
# header and receiving the If-Modified-Since header.
#
def update_local_file_from_url_v2(last_mtime, local_file, url):
# Get the remote file, but only if it has changed
r = requests.get(url, headers={
'If-Modified-Since': ts_to_httpdate(last_mtime)
})
updated, mtime = False, last_mtime
if r.status_code == requests.codes.ok:
# File is updated and we just downloaded the content
updated = True
# write new content to local file # write new content to local file
write_file_with_time(local_file, r.content, mtime) print('Downloaded {}.'.format(local_file), file=sys.stderr)
else:
print('No need to refresh feed.', file=sys.stderr)
# Update our notion of the file's last modification time return updated, mtime
if 'Last-Modified' in r.headers:
mtime = httpdate_to_ts(r.headers['Last-Modified'])
else:
print('HEY! no Last-Modified header for {}'.format(url),
file=sys.stderr)
elif r.status_code == requests.codes.not_modified:
# Successful call, but no updates to file
print('As of {}, server says {} is the same'.format(time.ctime(), url))
else:
# http request failed
print('HEY! get for {} returned {}'.format(url, r.status_code),
file=sys.stderr)
return updated, mtime

View File

@ -1,3 +1,10 @@
iso8601==1.0.2 gtfs_kit
pygame==2.1.2 iso8601
zeep==4.1.0 pandas
pygame
pyyaml
requests
urllib3
schedule
zeep