Add Journey screen: next connections for a fixed daily commute
New screens/journey.py, ported and simplified from github.com/EiS94/db_info (a Home Assistant integration). Deliberately uses only the regional EFA (Elektronische Fahrplanauskunft) backends the reference project queries (VRR prioritized for NRW routes, falling back to Bavaria/BW instances) and drops its bahn.de-internal-API source entirely -- that's DB's own infrastructure, exactly the kind of thing DB has been actively blocking (see the Departures screen). EFA instances are run by regional transit authorities, not DB, so they aren't part of that clampdown. Also dropped the reference project's parallel async fetch-and-score-all-sources machinery for simple sequential fallback, matching this project's fully synchronous style. Verified against live VRR data for a real NRW route (Münster -> Dortmund): real trains, platforms, transfer counts, even a night bus connection. Caught a real bug during that live test: EFA returns UTC timestamps (Z suffix), and datetime.fromisoformat() parses that correctly into an aware datetime, but strftime() on an aware datetime prints its own stored hour/minute rather than converting to local time first -- everything displayed 2 hours behind (CEST is UTC+2), which looked like "next" departures had already left. Fixed by converting with astimezone() before formatting; re-verified live afterward that departures now correctly show after the query time. Settings: origin/destination resolved via Open-Meteo's free geocoding (same pattern as Weather's city field), how many connections to show (1/2/3/5), and its own configurable update interval.
This commit is contained in:
1 parent
60acdc3756
commit
c182fb2f19
6 files changed
+404
-4
No files matched your search
@@ -1,10 +1,10 @@
|
||||
# Paperwall
|
||||
|
||||
A standalone dashboard for one Waveshare `epd4in0e` (4", 6-color) e-ink panel:
|
||||
Navidrome now-playing, clock, weather, train departures, and pictures
|
||||
(upload or URL), switchable/controllable from a local web UI. Started as an InkyPi
|
||||
replacement, but doesn't depend on InkyPi at all -- own venv, own vendored
|
||||
copy of the Waveshare driver files.
|
||||
Navidrome now-playing, clock, weather, departure boards, a fixed-route
|
||||
journey planner, and pictures (upload or URL), switchable/controllable from
|
||||
a local web UI. Started as an InkyPi replacement, but doesn't depend on
|
||||
InkyPi at all -- own venv, own vendored copy of the Waveshare driver files.
|
||||
|
||||
Confirmed by reading the vendor driver directly: this panel has no
|
||||
partial-refresh mode at all -- every update is a full flash. So the design
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from screens.clock import ClockScreen
|
||||
from screens.departures import DeparturesScreen
|
||||
from screens.journey import JourneyScreen
|
||||
from screens.navidrome import NavidromeScreen
|
||||
from screens.picture import PictureScreen
|
||||
from screens.weather import WeatherScreen
|
||||
@@ -8,6 +9,7 @@ SCREEN_CLASSES = {
|
||||
ClockScreen.id: ClockScreen,
|
||||
WeatherScreen.id: WeatherScreen,
|
||||
DeparturesScreen.id: DeparturesScreen,
|
||||
JourneyScreen.id: JourneyScreen,
|
||||
PictureScreen.id: PictureScreen,
|
||||
NavidromeScreen.id: NavidromeScreen,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Next connections for a fixed daily commute (origin -> destination), via
|
||||
regional EFA (Elektronische Fahrplanauskunft) instances -- transit-planner
|
||||
backends run by regional transit authorities, not Deutsche Bahn itself.
|
||||
|
||||
This deliberately avoids bahn.de's own internal trip-planner API. DB has
|
||||
been actively blocking third-party access to its own backends recently (see
|
||||
the Departures screen's docstring and the outage that motivated it) -- EFA
|
||||
instances aren't DB's infrastructure, so they aren't part of that
|
||||
clampdown. Several are tried in sequence (not parallel, to keep this
|
||||
synchronous like the rest of the app); first one to return usable journeys
|
||||
wins. Ported and simplified from github.com/EiS94/db_info (a Home Assistant
|
||||
integration), which queries these same EFA instances plus bahn.de's API in
|
||||
parallel and scores the results -- dropped the bahn.de source and the
|
||||
parallel/scoring machinery for a simpler, more DB-independent first version.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from screens.base import Screen
|
||||
from screens.render_utils import load_font, text_width, truncate_to_width
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTTP_TIMEOUT = 10
|
||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
|
||||
EFA_SOURCES = [
|
||||
{
|
||||
"name": "vrr",
|
||||
"url": "https://www.vrr.de/vrr-efa/XML_TRIP_REQUEST2",
|
||||
# vrr.de hangs until timeout for trips outside its home region
|
||||
# instead of returning a fast error, so it's only queried when both
|
||||
# ends of the trip roughly fall within North Rhine-Westphalia.
|
||||
"bounds": {"lat_min": 50.3, "lat_max": 52.6, "lon_min": 5.7, "lon_max": 9.5},
|
||||
},
|
||||
{"name": "bahnland-bayern", "url": "https://bahnland-bayern.de/efa/XML_TRIP_REQUEST2", "bounds": None},
|
||||
{"name": "efa", "url": "https://www.efa.de/hit-efa/XML_TRIP_REQUEST2", "bounds": None},
|
||||
{"name": "efa-bw", "url": "http://www.efa-bw.de/nvbw/XML_TRIP_REQUEST2", "bounds": None},
|
||||
]
|
||||
|
||||
# Some EFA instances reject the default requests/python User-Agent as bot
|
||||
# traffic and answer with a 403.
|
||||
REQUEST_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
RED = (200, 0, 0)
|
||||
BLACK = (0, 0, 0)
|
||||
MUTED = (110, 110, 110)
|
||||
|
||||
|
||||
def _in_bounds(lat, lon, bounds) -> bool:
|
||||
if bounds is None:
|
||||
return True
|
||||
return bounds["lat_min"] <= lat <= bounds["lat_max"] and bounds["lon_min"] <= lon <= bounds["lon_max"]
|
||||
|
||||
|
||||
def _parse_time(iso_str):
|
||||
if not iso_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(iso_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class JourneyScreen(Screen):
|
||||
id = "journey"
|
||||
display_name = "Journey"
|
||||
poll_interval = 120 # fallback if update_minutes isn't in settings yet
|
||||
|
||||
def default_settings(self) -> dict:
|
||||
return {
|
||||
"origin_query": "", "origin_label": "", "origin_lat": None, "origin_lon": None,
|
||||
"destination_query": "", "destination_label": "", "destination_lat": None, "destination_lon": None,
|
||||
"results": 3,
|
||||
"update_minutes": 2,
|
||||
}
|
||||
|
||||
def get_poll_interval(self, settings: dict) -> int:
|
||||
raw = settings.get("update_minutes")
|
||||
try:
|
||||
minutes = int(raw) if raw is not None else 2
|
||||
except (TypeError, ValueError):
|
||||
minutes = 2
|
||||
return max(1, minutes) * 60
|
||||
|
||||
def geocode(self, query: str):
|
||||
"""Resolve a place name to coordinates -- same free Open-Meteo
|
||||
geocoding WeatherScreen uses, no key needed."""
|
||||
response = requests.get(
|
||||
GEOCODE_URL, params={"name": query, "count": 1, "language": "de"}, timeout=HTTP_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
results = response.json().get("results") or []
|
||||
if not results:
|
||||
return None
|
||||
top = results[0]
|
||||
label_parts = [top.get("name"), top.get("admin1"), top.get("country")]
|
||||
return {
|
||||
"latitude": top["latitude"],
|
||||
"longitude": top["longitude"],
|
||||
"label": ", ".join(p for p in label_parts if p),
|
||||
}
|
||||
|
||||
def check(self, settings: dict):
|
||||
o_lat, o_lon = settings.get("origin_lat"), settings.get("origin_lon")
|
||||
d_lat, d_lon = settings.get("destination_lat"), settings.get("destination_lon")
|
||||
if o_lat is None or o_lon is None or d_lat is None or d_lon is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
results = max(1, min(5, int(settings.get("results") or 3)))
|
||||
except (TypeError, ValueError):
|
||||
results = 3
|
||||
|
||||
params = self._build_params(o_lat, o_lon, d_lat, d_lon, results)
|
||||
|
||||
for source in EFA_SOURCES:
|
||||
if not (_in_bounds(o_lat, o_lon, source["bounds"]) and _in_bounds(d_lat, d_lon, source["bounds"])):
|
||||
continue
|
||||
try:
|
||||
response = requests.get(
|
||||
source["url"], params=params, headers=REQUEST_HEADERS, timeout=HTTP_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.exceptions.RequestException, ValueError) as exc:
|
||||
logger.warning("Journey source '%s' failed, trying next: %s", source["name"], exc)
|
||||
continue
|
||||
|
||||
journeys = self._parse_journeys(data, results)
|
||||
if journeys:
|
||||
logger.debug("Journey data from '%s'", source["name"])
|
||||
return journeys
|
||||
|
||||
return None
|
||||
|
||||
def _build_params(self, o_lat, o_lon, d_lat, d_lon, results) -> dict:
|
||||
now = datetime.now().astimezone()
|
||||
return {
|
||||
"outputFormat": "rapidJSON",
|
||||
"language": "de",
|
||||
"type_origin": "coord",
|
||||
"name_origin": f"{o_lon}:{o_lat}:WGS84[dd.ddddd]",
|
||||
"type_destination": "coord",
|
||||
"name_destination": f"{d_lon}:{d_lat}:WGS84[dd.ddddd]",
|
||||
"itdDateDayMonthYear": now.strftime("%d.%m.%Y"),
|
||||
"itdTime": now.strftime("%H:%M"),
|
||||
"itdTripDateTimeDepArr": "dep",
|
||||
"coordOutputFormat": "WGS84[dd.ddddd]",
|
||||
"useRealtime": "1",
|
||||
"calcOneDirection": "1",
|
||||
"calcNumberOfTrips": str(results),
|
||||
"ptOptionsActive": "1",
|
||||
"lineRestriction": "400",
|
||||
"serverInfo": "0",
|
||||
}
|
||||
|
||||
def _parse_journeys(self, data: dict, results: int):
|
||||
journeys = []
|
||||
for raw_journey in (data.get("journeys") or [])[:results]:
|
||||
legs = []
|
||||
for leg in raw_journey.get("legs", []):
|
||||
transportation = leg.get("transportation") or {}
|
||||
product = transportation.get("product") or {}
|
||||
is_walk = (
|
||||
product.get("class") == 100
|
||||
or product.get("name") == "footpath"
|
||||
or not transportation.get("name")
|
||||
)
|
||||
if is_walk:
|
||||
continue
|
||||
legs.append({
|
||||
"line": transportation.get("name") or "?",
|
||||
"origin": leg.get("origin") or {},
|
||||
"destination": leg.get("destination") or {},
|
||||
})
|
||||
|
||||
if not legs:
|
||||
continue
|
||||
|
||||
first, last = legs[0], legs[-1]
|
||||
dep_planned = _parse_time(first["origin"].get("departureTimePlanned"))
|
||||
dep_estimated = _parse_time(first["origin"].get("departureTimeEstimated"))
|
||||
arr_planned = _parse_time(last["destination"].get("arrivalTimePlanned"))
|
||||
arr_estimated = _parse_time(last["destination"].get("arrivalTimeEstimated"))
|
||||
if dep_planned is None:
|
||||
continue
|
||||
|
||||
dep_actual = dep_estimated or dep_planned
|
||||
delay_min = int((dep_estimated - dep_planned).total_seconds() // 60) if dep_estimated else 0
|
||||
arr_actual = arr_estimated or arr_planned
|
||||
|
||||
# EFA returns UTC timestamps (Z suffix) -- astimezone() with no
|
||||
# args converts to the system's local timezone before display.
|
||||
# Without this, times showed 2h behind (CEST is UTC+2), making
|
||||
# "next" departures look like they'd already left.
|
||||
journeys.append({
|
||||
"departure": dep_actual.astimezone().strftime("%H:%M"),
|
||||
"delay_min": max(0, delay_min),
|
||||
"arrival": arr_actual.astimezone().strftime("%H:%M") if arr_actual else "--:--",
|
||||
"lines": [leg["line"] for leg in legs],
|
||||
"transfers": len(legs) - 1,
|
||||
"platform": (first["origin"].get("properties") or {}).get("platformName") or "",
|
||||
})
|
||||
|
||||
return journeys or None
|
||||
|
||||
# --------------------------------------------------------------- render
|
||||
|
||||
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||
width, height = resolution
|
||||
image = Image.new("RGB", (width, height), "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
scale = min(width, height)
|
||||
|
||||
have_route = settings.get("origin_lat") is not None and settings.get("destination_lat") is not None
|
||||
if not have_route:
|
||||
self._render_message(draw, width, height, scale, "Keine Strecke konfiguriert")
|
||||
return image
|
||||
if not state:
|
||||
self._render_message(draw, width, height, scale, "Keine Verbindungen verfügbar")
|
||||
return image
|
||||
|
||||
pad = width * 0.04
|
||||
origin = settings.get("origin_label") or settings.get("origin_query") or "?"
|
||||
destination = settings.get("destination_label") or settings.get("destination_query") or "?"
|
||||
header_font = load_font(size=int(scale * 0.05), bold=True)
|
||||
header = truncate_to_width(draw, f"{origin} → {destination}", header_font, width - 2 * pad)
|
||||
draw.text((pad, height * 0.045), header, font=header_font, fill=BLACK)
|
||||
|
||||
header_bottom = height * 0.045 + header_font.size * 1.35
|
||||
draw.rectangle((pad, header_bottom, width - pad, header_bottom + 2), fill=BLACK)
|
||||
|
||||
row_top = header_bottom + height * 0.03
|
||||
row_h = (height - row_top - height * 0.03) / len(state)
|
||||
|
||||
time_font = load_font(size=int(scale * 0.07), bold=True)
|
||||
arrow_font = load_font(size=int(scale * 0.05))
|
||||
detail_font = load_font(size=int(scale * 0.038))
|
||||
|
||||
for i, journey in enumerate(state):
|
||||
row_top_y = row_top + i * row_h
|
||||
if i > 0:
|
||||
draw.line((pad, row_top_y, width - pad, row_top_y), fill=(228, 228, 228), width=1)
|
||||
|
||||
top_line_y = row_top_y + row_h * 0.14
|
||||
dep_color = RED if journey["delay_min"] > 0 else BLACK
|
||||
dep_text = journey["departure"]
|
||||
if journey["delay_min"] > 0:
|
||||
dep_text += f" +{journey['delay_min']}"
|
||||
draw.text((pad, top_line_y), dep_text, font=time_font, fill=dep_color)
|
||||
dep_w = text_width(draw, dep_text, time_font)
|
||||
|
||||
arrow_x = pad + dep_w + scale * 0.02
|
||||
draw.text((arrow_x, top_line_y + (time_font.size - arrow_font.size) / 2), "→", font=arrow_font, fill=MUTED)
|
||||
arrow_w = text_width(draw, "→", arrow_font)
|
||||
|
||||
draw.text((arrow_x + arrow_w + scale * 0.02, top_line_y), journey["arrival"], font=time_font, fill=BLACK)
|
||||
|
||||
detail_parts = [" → ".join(journey["lines"])]
|
||||
if journey["transfers"] > 0:
|
||||
detail_parts.append(f"{journey['transfers']}x umsteigen")
|
||||
if journey["platform"]:
|
||||
detail_parts.append(f"Gl. {journey['platform']}")
|
||||
detail_line = truncate_to_width(draw, " · ".join(detail_parts), detail_font, width - 2 * pad)
|
||||
draw.text((pad, top_line_y + time_font.size * 1.25), detail_line, font=detail_font, fill=MUTED)
|
||||
|
||||
return image
|
||||
|
||||
def _render_message(self, draw, width, height, scale, msg):
|
||||
font = load_font(size=int(scale * 0.06))
|
||||
mw = text_width(draw, msg, font)
|
||||
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=MUTED)
|
||||
+58
@@ -130,6 +130,64 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
||||
|
||||
return render_template("settings_departures.html", settings=config.get_screen_settings("departures"), error=error)
|
||||
|
||||
@app.route("/settings/journey", methods=["GET", "POST"])
|
||||
def settings_journey():
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
origin_query = request.form.get("origin", "").strip()
|
||||
destination_query = request.form.get("destination", "").strip()
|
||||
try:
|
||||
results = max(1, min(5, int(request.form.get("results", 3) or 3)))
|
||||
except (TypeError, ValueError):
|
||||
results = 3
|
||||
update_minutes = max(1, int(request.form.get("update_minutes", 2) or 2))
|
||||
|
||||
updates = {"results": results, "update_minutes": update_minutes}
|
||||
journey_screen = screens["journey"]
|
||||
|
||||
if origin_query:
|
||||
try:
|
||||
found = journey_screen.geocode(origin_query)
|
||||
except requests.exceptions.RequestException:
|
||||
found = None
|
||||
if found is None:
|
||||
error = f'Could not find "{origin_query}" as a starting point -- try a different spelling.'
|
||||
else:
|
||||
updates.update({
|
||||
"origin_query": origin_query,
|
||||
"origin_label": found["label"],
|
||||
"origin_lat": found["latitude"],
|
||||
"origin_lon": found["longitude"],
|
||||
})
|
||||
|
||||
if not error and destination_query:
|
||||
try:
|
||||
found = journey_screen.geocode(destination_query)
|
||||
except requests.exceptions.RequestException:
|
||||
found = None
|
||||
if found is None:
|
||||
error = f'Could not find "{destination_query}" as a destination -- try a different spelling.'
|
||||
else:
|
||||
updates.update({
|
||||
"destination_query": destination_query,
|
||||
"destination_label": found["label"],
|
||||
"destination_lat": found["latitude"],
|
||||
"destination_lon": found["longitude"],
|
||||
})
|
||||
|
||||
if not error:
|
||||
config.update_screen_settings("journey", updates)
|
||||
found_parts = [v for k, v in (("origin", origin_query), ("destination", destination_query)) if v]
|
||||
if found_parts:
|
||||
current = config.get_screen_settings("journey")
|
||||
return redirect(url_for(
|
||||
"settings_journey",
|
||||
found=f'{current["origin_label"]} → {current["destination_label"]}',
|
||||
))
|
||||
return redirect(url_for("settings_journey", saved=1))
|
||||
|
||||
return render_template("settings_journey.html", settings=config.get_screen_settings("journey"), error=error)
|
||||
|
||||
@app.route("/settings/picture", methods=["GET", "POST"])
|
||||
def settings_picture():
|
||||
error = None
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
<a href="{{ url_for('settings_navidrome') }}" class="{{ 'active' if request.endpoint == 'settings_navidrome' }}">Navidrome</a>
|
||||
<a href="{{ url_for('settings_weather') }}" class="{{ 'active' if request.endpoint == 'settings_weather' }}">Weather</a>
|
||||
<a href="{{ url_for('settings_departures') }}" class="{{ 'active' if request.endpoint == 'settings_departures' }}">Departures</a>
|
||||
<a href="{{ url_for('settings_journey') }}" class="{{ 'active' if request.endpoint == 'settings_journey' }}">Journey</a>
|
||||
<a href="{{ url_for('settings_picture') }}" class="{{ 'active' if request.endpoint == 'settings_picture' }}">Picture</a>
|
||||
<a href="{{ url_for('settings_rotation') }}" class="{{ 'active' if request.endpoint == 'settings_rotation' }}">Rotation</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Journey</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="toast error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if request.args.get('found') %}
|
||||
<div class="toast">Found: {{ request.args.get('found') }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="hint" style="margin: -0.5rem 0 1.25rem;">
|
||||
Next connections for a fixed route -- your daily commute, say. Uses
|
||||
regional EFA transit-planner backends (not Deutsche Bahn's own
|
||||
infrastructure, which has been actively blocking third-party access
|
||||
lately) -- several are tried in order, so this can work even when the
|
||||
Departures screen's DB-based source can't.
|
||||
</p>
|
||||
|
||||
<form method="post">
|
||||
<div class="card">
|
||||
<div class="section-label">Route</div>
|
||||
<div class="field">
|
||||
<label for="origin">From</label>
|
||||
<input type="text" id="origin" name="origin" placeholder="e.g. Münster Hauptbahnhof">
|
||||
{% if settings.origin_label %}
|
||||
<p class="hint">Currently: {{ settings.origin_label }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="destination">To</label>
|
||||
<input type="text" id="destination" name="destination" placeholder="e.g. Dortmund Hauptbahnhof">
|
||||
{% if settings.destination_label %}
|
||||
<p class="hint">Currently: {{ settings.destination_label }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="hint">Leave either field empty to keep what's already saved and just change the options below.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-label">Display</div>
|
||||
<div class="field">
|
||||
<label for="results">Connections to show</label>
|
||||
<select id="results" name="results">
|
||||
<option value="1" {{ "selected" if settings.results == 1 }}>1</option>
|
||||
<option value="2" {{ "selected" if settings.results == 2 }}>2</option>
|
||||
<option value="3" {{ "selected" if settings.results == 3 or not settings.results }}>3</option>
|
||||
<option value="5" {{ "selected" if settings.results == 5 }}>5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="update_minutes">Update every (minutes)</label>
|
||||
<input type="number" id="update_minutes" name="update_minutes" min="1" value="{{ settings.update_minutes or 2 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user