Add Departures screen (train departure board)

New screens/departures.py using the free, unofficial db-vendo-client REST
API (v6.db.transport.rest) -- no key needed, same "no API key" philosophy
as Weather's Open-Meteo integration. Community-run infra though, not
official DB, and it was actually down (503, then timing out entirely) for
the whole time I was building this -- couldn't live-verify against real
data the way Open-Meteo was earlier, so I built the parsing against the
project's documented example response and stress-tested render() with
mocked requests.get() covering on-time/delayed/cancelled/missing-platform/
very-long-direction cases instead. check() fails soft (logs a warning,
returns None) on any request error, matching the "service might be down"
reality of this API -- the screen shows a plain "not available" message
rather than erroring out.

Settings: station name (resolved to a station ID via /locations, same
pattern as Weather's city->coordinates geocoding), how many departures to
show (3/5/8), and its own configurable update interval via the
get_poll_interval() mechanism built for Weather (defaults to 2 min --
departure boards go stale much faster than weather does).

Caught two real layout bugs while stress-testing render() before shipping:
line names ("ICE 1234") and delayed/cancelled time text ("Fällt aus") were
both wider than the columns I'd budgeted for them, with the second one
overlapping the direction text when right-aligned past its column's own
left edge. Fixed by measuring actual worst-case widths at the chosen font
size instead of guessing, and truncating every field to its column as a
defensive backstop regardless.
This commit is contained in:
Honey committed 2026-07-22 21:38:20 +02:00
1 parent 889c062212
commit 8c9b3e1bfa
6 files changed
+287 -2

No files matched your search

+32
View File
@@ -98,6 +98,38 @@ def create_app(config, screens: dict, scheduler) -> Flask:
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"), error=error)
@app.route("/settings/departures", methods=["GET", "POST"])
def settings_departures():
error = None
if request.method == "POST":
station_query = request.form.get("station", "").strip()
results = int(request.form.get("results", 5) or 5)
if results not in (3, 5, 8):
results = 5
update_minutes = max(1, int(request.form.get("update_minutes", 2) or 2))
if station_query:
try:
found = screens["departures"].find_station(station_query)
except requests.exceptions.RequestException:
found = None
if found is None:
error = f'Could not find a station matching "{station_query}" -- try a different spelling, or the service might be temporarily down.'
else:
config.update_screen_settings("departures", {
"station": station_query,
"station_id": found["id"],
"station_label": found["name"],
"results": results,
"update_minutes": update_minutes,
})
return redirect(url_for("settings_departures", saved=1))
else:
config.update_screen_settings("departures", {"results": results, "update_minutes": update_minutes})
return redirect(url_for("settings_departures", saved=1))
return render_template("settings_departures.html", settings=config.get_screen_settings("departures"), error=error)
@app.route("/settings/picture", methods=["GET", "POST"])
def settings_picture():
error = None
+1
View File
@@ -185,6 +185,7 @@
<a href="{{ url_for('index') }}" class="{{ 'active' if request.endpoint == 'index' }}">Status</a>
<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_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>
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<h1>Departures</h1>
{% if error %}
<div class="toast error">{{ error }}</div>
{% endif %}
<p class="hint" style="margin: -0.5rem 0 1.25rem;">
Uses the free, unofficial db-vendo-client API (v6.db.transport.rest) --
no key needed, but it's a community-run service, not official Deutsche
Bahn infrastructure. Expect occasional downtime; the screen shows a plain
"not available" message rather than breaking when that happens.
</p>
<form method="post">
<div class="card">
<div class="section-label">Station</div>
<div class="field">
<label for="station">Station name</label>
<input type="text" id="station" name="station" placeholder="e.g. Berlin Hauptbahnhof">
{% if settings.station_label %}
<p class="hint">Currently: {{ settings.station_label }}</p>
{% endif %}
</div>
</div>
<div class="card">
<div class="section-label">Display</div>
<div class="field">
<label for="results">Departures to show</label>
<select id="results" name="results">
<option value="3" {{ "selected" if settings.results == 3 }}>3</option>
<option value="5" {{ "selected" if settings.results == 5 or not settings.results }}>5</option>
<option value="8" {{ "selected" if settings.results == 8 }}>8</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 }}">
<p class="hint">Departure boards change often, so this defaults much shorter than Weather's -- but same rule applies: the panel only actually redraws when something in the list changed.</p>
</div>
</div>
<button type="submit">Save</button>
</form>
{% endblock %}