Compare commits

..

No commits in common. "main" and "gtfs-r" have entirely different histories.
main ... gtfs-r

12 changed files with 332 additions and 256 deletions

2
.gitignore vendored
View File

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

View File

@ -0,0 +1,48 @@
{
"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 Normal file
View File

@ -0,0 +1,16 @@
{
// 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,16 +47,15 @@ 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 python3-urllib3 $ sudo apt install python3-iso8601 python3-zeep libsdl2-ttf-2.0-0 python3-numpy python3-pandas python3-fiona python3-pyproj libspatialindex-c6 python3-yaml
``` ```
* pygame 2 * pygame 2
* GTFS-Kit * GTFS-Kit
```shell ```
$ sudo apt install python3-pip $ sudo pip3 install pygame gtfs_kit
$ 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 is_due(self) -> bool: def isDue(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,45 +4,40 @@ 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())
# Preload some dictionaries to simplify lookups # Pre-load 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[str(s["stop_id"])] = s["walk_time"] self.__walk_time_by_stop[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:
minutes = self.walk_time_by_stop.get(stop_id, 0) return 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/TripUpdates?format=json" gtfs-r-api-url: "https://api.nationaltransport.ie/gtfsr/v2/gtfsr?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,9 +12,6 @@ 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,11 +8,12 @@ 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):
@ -20,20 +21,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 = '/tmp/' + feed_url.split('/')[-1] feed_name = 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 = int(os.stat(feed_name).st_mtime) last_mtime = os.stat(feed_name).st_mtime
except: except:
last_mtime = 0 last_mtime = 0
_, new_mtime = refresh_feed.update_local_file_from_url_v1(last_mtime, feed_name, feed_url) refreshed, 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') self.feed = self._read_feed(feed_name, dist_units='km', stop_codes = stop_codes)
gc.collect() gc.collect()
self.stop_ids = self.__wanted_stop_ids() self.stop_ids = self.__wanted_stop_ids()
self.deltas = {} self.deltas = {}
@ -44,8 +45,9 @@ 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: str, dist_units: str) -> gk.Feed: def _read_feed(self, path: gk.Path, dist_units: str, stop_codes: list[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,
@ -54,7 +56,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',
@ -64,15 +66,15 @@ class GTFSClient:
'agency.txt' 'agency.txt'
] ]
if not os.path.exists(path): path = gk.Path(path)
raise ValueError("Path {} does not exist".format(path)) if not path.exists():
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:
@ -214,22 +216,20 @@ 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
@staticmethod def __time_to_seconds(self, s: str) -> int:
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])
@staticmethod def __due_in_seconds(self, time_str: str) -> int:
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 = GTFSClient.__time_to_seconds(now) tnow = self.__time_to_seconds(now)
tstop = GTFSClient.__time_to_seconds(time_str) tstop = self.__time_to_seconds(time_str)
if tstop > tnow: if tstop > tnow:
return tstop - tnow return tstop - tnow
else: else:
@ -242,96 +242,88 @@ 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
destination = trips[(trips["route_id"] == route_id) & (trips["direction_id"] == direction_id)].head(1)["trip_headsign"].item() return 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) -> tuple[dict, list, list]: def __poll_gtfsr_deltas(self) -> list[map, set]:
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 {}, [], []
deltas_json = json.loads(response.text) # Poll GTFS-R API
else: if self.gtfs_r_api_key != "":
deltas_json = json.load(open("example.json")) headers = {"x-api-key": self.gtfs_r_api_key}
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 = {} deltas_json = json.loads(response.text)
canceled_trips = set() else:
added_stops = [] deltas_json = json.load(open("example.json"))
# Pre-compute some data to use for added trips: deltas = {}
relevant_service_ids = self.__current_service_ids() canceled_trips = set()
relevant_trips = self.feed.trips[self.feed.trips["service_id"].isin(relevant_service_ids)] added_stops = []
relevant_route_ids = set(relevant_trips["route_id"])
today = datetime.date.today().strftime("%Y%m%d")
for e in deltas_json.get("entity", []): # Pre-compute some data to use for added trips:
try: relevant_service_ids = self.__current_service_ids()
trip_update = e.get("trip_update") relevant_trips = self.feed.trips[self.feed.trips["service_id"].isin(relevant_service_ids)]
trip = trip_update.get("trip") relevant_route_ids = set(relevant_trips["route_id"])
trip_id = trip.get("trip_id") today = datetime.date.today().strftime("%Y%m%d")
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": for e in deltas_json.get("entity", []):
start_date = trip.get("start_date") is_deleted = e.get("is_deleted") or False
start_time = trip.get("start_time") try:
route_id = trip.get("route_id") trip_update = e.get("trip_update")
direction_id = trip.get("direction_id") trip = trip_update.get("trip")
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
# Check if the route is part of the routes we care about elif trip_action == "ADDED":
if not route_id in relevant_route_ids: start_date = trip.get("start_date")
continue start_time = trip.get("start_time")
route_id = trip.get("route_id")
direction_id = trip.get("direction_id")
# And that it's for today # Check if the route is part of the routes we care about
current_time = datetime.datetime.now().strftime("%H:%M:%S") if not route_id in relevant_route_ids:
if start_date > today or start_time > current_time: continue
continue
# Look for the entry for any of the stops we want # And that it's for today
wanted_stop_ids = self.__wanted_stop_ids() current_time = datetime.datetime.now().strftime("%H:%M:%S")
for stop_time_update in e.get("trip_update").get("stop_time_update", []): if start_date > today or start_time > current_time:
if stop_time_update.get("stop_id", "") in wanted_stop_ids: continue
arrival_time = int((stop_time_update.get("arrival", stop_time_update.get("departure", {})).get("time", 0)))
if arrival_time < int(time.time()):
continue
new_arrival = ArrivalTime(
stop_id = stop_time_update.get("stop_code"),
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),
due_in_seconds = arrival_time - int(time.time()),
is_added = True
)
print("Added route:", new_arrival)
added_stops.append(new_arrival)
elif trip_action == "CANCELED": # Look for the entry for any of the stops we want
canceled_trips.add(trip_id) wanted_stop_ids = self.__wanted_stop_ids()
else: for stop_time_update in e.get("trip_update").get("stop_time_update", []):
print("Unsupported action:", trip_action) if stop_time_update.get("stop_id", "") in wanted_stop_ids:
except Exception as x: arrival_time = int((stop_time_update.get("arrival", stop_time_update.get("departure", {})).get("time", 0)))
print("Error parsing GTFS-R entry:", str(e)) if arrival_time < int(time.time()):
raise x continue
new_arrival = ArrivalTime(
stop_id = stop_time_update.get("stop_id"),
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),
due_in_seconds = arrival_time - int(time.time()),
is_added = True
)
print("Added route:", new_arrival)
added_stops.append(new_arrival)
return deltas, canceled_trips, added_stops elif trip_action == "CANCELED":
except Exception as e: canceled_trips.add(trip_id)
print("Polling for GTFS-R failed:", str(e)) else:
return {}, [], [] print("Unsupported action:", trip_action)
except Exception as x:
print("Error parsing GTFS-R entry:", str(e))
raise(x)
return deltas, canceled_trips, added_stops
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:
@ -346,48 +338,69 @@ 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:
# Retrieve the GTFS-R deltas task()
deltas, canceled_trips, added_stops = self.__poll_gtfsr_deltas() except Exception:
if len(deltas) > 0 or len(canceled_trips) > 0 or len(added_stops) > 0: traceback.print_exc()
# Only update deltas and canceled trips if the API returns data # in production code you might want to have this instead of course:
self.deltas = deltas # logger.exception("Problem while executing repetitive task.")
self.canceled_trips = canceled_trips # skip tasks if we are behind schedule:
self.added_stops = added_stops next_time += (time.time() - next_time) // delay * delay + delay
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,7 +7,6 @@ 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
@ -72,34 +71,31 @@ 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 """
try: updates = updates[0:LINE_COUNT] # take the first X lines
updates = updates[0:LINE_COUNT] # take the first X lines for line_num, update in enumerate(updates):
for line_num, update in enumerate(updates): # Find what color we need to use for the ETA
# Find what color we need to use for the ETA time_to_walk = update.due_in_minutes - (config.minutes_to_stop(update.stop_id) or 0)
time_to_walk = update.due_in_minutes - (config.minutes_to_stop(update.stop_id) or 0) lcd_color = None
lcd_color = None if time_to_walk > 5:
if time_to_walk > 5: lcd_color = COLOR_LCD_GREEN
lcd_color = COLOR_LCD_GREEN elif time_to_walk > 1:
elif time_to_walk > 1: lcd_color = COLOR_LCD_AMBER
lcd_color = COLOR_LCD_AMBER else:
else: lcd_color = COLOR_LCD_RED
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.is_due() else update.due_in_str(), time_left = 'Due' if update.isDue() 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 """
@ -124,12 +120,10 @@ def main():
config = Config() config = Config()
# Initialise graphics context # Initialise graphics context
pygame.display.init() pygame.init()
pygame.font.init()
window = init_screen() window = init_screen()
pygame.font.init() pygame.font.init()
font = pygame.font.Font(config.font_file or TEXT_FONT, TEXT_SIZE) font = pygame.font.Font(TEXT_FONT, TEXT_SIZE)
# Init screen # Init screen
clear_screen() clear_screen()
@ -146,39 +140,33 @@ def main():
update_queue=update_queue, update_queue=update_queue,
update_interval_seconds=config.update_interval_seconds) update_interval_seconds=config.update_interval_seconds)
# Schedule feed refresh, and force the first one scheduler.start()
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:
try: # Pygame event handling begins
# Pygame event handling begins if pygame.event.peek():
if pygame.event.peek(): for e in pygame.event.get():
for e in pygame.event.get(): if e.type == pygame.QUIT:
if e.type == pygame.QUIT: running = False
elif e.type == pygame.KEYDOWN:
if e.key == pygame.K_ESCAPE:
running = False running = False
elif e.type == pygame.KEYDOWN: pygame.display.flip()
if e.key == pygame.K_ESCAPE: # Pygame event handling ends
running = False
pygame.display.flip()
# Pygame event handling ends
# Display update begins # Display update begins
schedule.run_pending() if update_queue.qsize() > 0:
if update_queue.qsize() > 0: clear_screen()
clear_screen() updates = update_queue.get()
updates = update_queue.get() update_screen(config, updates)
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,7 +7,6 @@ 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):
@ -18,6 +17,16 @@ 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.
@ -42,34 +51,54 @@ 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
print('Comparing feed mtimes: feed: {} vs remote {}'.format(str(last_mtime), str(mtime)), file=sys.stderr) if mtime > int(last_mtime):
if not last_mtime or mtime > int(last_mtime):
print('Refreshing feed..', file=sys.stderr)
updated = True updated = True
# download the new file content r2 = requests.get(url) # download the new file content
conn = urllib3.connection_from_url(url) if r2.status_code != requests.codes.ok:
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
with open(local_file,'bw') as f:
for chunk in r2.stream(amt=65536, decode_content=True):
f.write(chunk)
r2.release_conn()
# Change the mtime of the file
os.utime(local_file, (mtime, mtime))
# write new content to local file # write new content to local file
print('Downloaded {}.'.format(local_file), file=sys.stderr) write_file_with_time(local_file, r2.content, mtime)
else:
print('No need to refresh feed.', file=sys.stderr) return updated, 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_file_with_time(local_file, r.content, mtime)
# Update our notion of the file's last modification time
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 return updated, mtime

View File

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