Remove the Departures screen

Not needed any more -- the Journey screen already covers the same
regional-transit need via EFA backends, and Departures depended on
the free db-vendo-client API that DB has been actively blocking third
parties from anyway.

Removed screens/departures.py, its settings template, its route, its
registration in screens/__init__.py, and its nav link. Reworded the
docstring/hint references in journey.py and settings_journey.html
that pointed at the now-gone screen. Existing config.json entries
referencing "departures" (rotation list, pinned screen) are harmless
leftovers -- both scheduler lookups already filter to screens that
still exist, so nothing needs a migration step.

Verified: app boots clean, /settings/departures 404s, every other
settings page still loads fine, no leftover references outside of
journey.py's own unrelated "departure time" wording.
This commit is contained in:
Honey committed 2026-07-25 23:33:48 +02:00
1 parent 5889422c31
commit 684d706afc
8 files changed
+6 -302

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
# Paperwall # Paperwall
A standalone dashboard for one Waveshare `epd4in0e` (4", 6-color) e-ink panel: A standalone dashboard for one Waveshare `epd4in0e` (4", 6-color) e-ink panel:
Navidrome now-playing, clock, weather, departure boards, a fixed-route Navidrome now-playing, clock, weather, a fixed-route journey planner, and
journey planner, and pictures (upload or URL), switchable/controllable from pictures (upload or URL), switchable/controllable from
a local web UI. Started as an InkyPi replacement, but doesn't depend on 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. InkyPi at all -- own venv, own vendored copy of the Waveshare driver files.
-2
View File
@@ -1,5 +1,4 @@
from screens.clock import ClockScreen from screens.clock import ClockScreen
from screens.departures import DeparturesScreen
from screens.journey import JourneyScreen from screens.journey import JourneyScreen
from screens.navidrome import NavidromeScreen from screens.navidrome import NavidromeScreen
from screens.picture import PictureScreen from screens.picture import PictureScreen
@@ -8,7 +7,6 @@ from screens.weather import WeatherScreen
SCREEN_CLASSES = { SCREEN_CLASSES = {
ClockScreen.id: ClockScreen, ClockScreen.id: ClockScreen,
WeatherScreen.id: WeatherScreen, WeatherScreen.id: WeatherScreen,
DeparturesScreen.id: DeparturesScreen,
JourneyScreen.id: JourneyScreen, JourneyScreen.id: JourneyScreen,
PictureScreen.id: PictureScreen, PictureScreen.id: PictureScreen,
NavidromeScreen.id: NavidromeScreen, NavidromeScreen.id: NavidromeScreen,
-207
View File
@@ -1,207 +0,0 @@
"""Departure board via the free, unofficial db-vendo-client REST API
(v6.db.transport.rest) -- no API key needed, wraps Deutsche Bahn's HAFAS
backend the same way a lot of hobby projects/Home Assistant integrations do.
Not train-only -- HAFAS covers regional/long-distance trains, S-Bahn,
U-Bahn/subway, trams, and buses in one system, so a bus stop works exactly
the same way as a train station here: search for it by name like any other
stop, nothing in check()/render() cares which mode a given departure is.
It's community-run on donated infrastructure, not an official DB service --
expect occasional downtime. check() degrades to None on any failure (via
the same try/except pattern as the Navidrome screen), which renders a
clean "not available" state rather than crashing the scheduler tick.
"""
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
API_BASE = "https://v6.db.transport.rest"
RESULTS_CHOICES = (3, 5, 8)
BLACK = (0, 0, 0)
RED = (200, 0, 0)
FAINT_LINE = (228, 228, 228)
class DeparturesScreen(Screen):
id = "departures"
display_name = "Departures"
poll_interval = 120 # fallback if update_minutes isn't in settings yet
def default_settings(self) -> dict:
return {
"station": "",
"station_id": None,
"station_label": "",
"results": 5,
"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 find_station(self, query: str):
"""Resolve a station name to {id, name} via /locations. Prefers an
actual station/stop over addresses/POIs. Returns None if nothing
matched (or the request itself fails -- let that raise, same as
WeatherScreen.geocode(), so the settings route can show an error)."""
response = requests.get(
f"{API_BASE}/locations",
params={"query": query, "results": 5, "poi": "false", "addresses": "false"},
timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
for entry in response.json() or []:
if entry.get("type") in ("station", "stop") and entry.get("id"):
return {"id": entry["id"], "name": entry.get("name") or query}
return None
def check(self, settings: dict):
station_id = settings.get("station_id")
if not station_id:
return None
results = settings.get("results", 5)
if results not in RESULTS_CHOICES:
results = 5
try:
response = requests.get(
f"{API_BASE}/stops/{station_id}/departures",
params={"duration": 90, "results": results},
timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
payload = response.json()
except (requests.exceptions.RequestException, ValueError) as exc:
logger.warning("Departures request failed (service may be down): %s", exc)
return None
# Documented example response is a bare array; be defensive in case
# a future version wraps it in {"departures": [...]} instead.
raw = payload.get("departures", payload) if isinstance(payload, dict) else payload
if not raw:
return None
departures = []
for entry in raw[:results]:
line = (entry.get("line") or {}).get("name") or "?"
planned = entry.get("plannedWhen") or entry.get("when")
delay_sec = entry.get("delay")
delay_min = int(delay_sec // 60) if isinstance(delay_sec, (int, float)) and delay_sec > 0 else 0
departures.append({
"line": line,
"direction": entry.get("direction") or "",
"time": _format_clock(planned),
"delay_min": delay_min,
"cancelled": bool(entry.get("cancelled")),
"platform": entry.get("platform") or entry.get("plannedPlatform") or "",
})
return departures or None
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)
if not settings.get("station_id"):
self._render_message(draw, width, height, scale, "Keine Station konfiguriert")
return image
if not state:
self._render_message(draw, width, height, scale, "Keine Abfahrten verfügbar")
return image
pad = width * 0.04
header_font = load_font(size=int(scale * 0.055), bold=True)
station_label = settings.get("station_label") or settings.get("station") or ""
draw.text((pad, height * 0.045), station_label, 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)
line_font = load_font(size=int(scale * 0.05), bold=True)
dir_font = load_font(size=int(scale * 0.042))
time_font = load_font(size=int(scale * 0.05), bold=True)
platform_font = load_font(size=int(scale * 0.032))
# Sized against actually-measured worst cases at this font size:
# "ICE 1234" ~98px, "21:09 +12" ~115px, "Fällt aus" ~96px. Every
# field is still truncated to its column as a safety net for
# anything longer, but these widths mean that won't normally fire.
line_w = scale * 0.275
time_w = scale * 0.325
platform_w = scale * 0.1
dir_x = pad + line_w + scale * 0.015
dir_w = max(20, (width - pad * 2) - line_w - time_w - platform_w - scale * 0.015)
time_x = dir_x + dir_w
platform_x = time_x + time_w
for i, dep in enumerate(state):
row_top_y = row_top + i * row_h
row_cy = row_top_y + row_h / 2
if i > 0:
draw.line((pad, row_top_y, width - pad, row_top_y), fill=FAINT_LINE, width=1)
line_text = truncate_to_width(draw, dep["line"], line_font, line_w - 6)
self._draw_centered_v(draw, line_text, line_font, pad, row_cy, BLACK)
direction = truncate_to_width(draw, dep["direction"], dir_font, dir_w - 10)
self._draw_centered_v(draw, direction, dir_font, dir_x, row_cy, BLACK)
if dep["cancelled"]:
time_text, time_color = "Fällt aus", RED
elif dep["delay_min"] > 0:
time_text, time_color = f"{dep['time']} +{dep['delay_min']}", RED
else:
time_text, time_color = dep["time"], BLACK
time_text = truncate_to_width(draw, time_text, time_font, time_w - 12)
tw = text_width(draw, time_text, time_font)
self._draw_centered_v(draw, time_text, time_font, time_x + time_w - tw - 8, row_cy, time_color)
if dep["platform"]:
platform_text = truncate_to_width(draw, dep["platform"], platform_font, platform_w - 4)
pw = text_width(draw, platform_text, platform_font)
self._draw_centered_v(
draw, platform_text, platform_font,
platform_x + (platform_w - pw) / 2, row_cy, BLACK,
)
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=BLACK)
def _draw_centered_v(self, draw, text, font, x, cy, fill):
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
draw.text((x, cy - (bottom - top) / 2 - top), text, font=font, fill=fill)
def _format_clock(iso_str) -> str:
if not iso_str:
return "--:--"
try:
return datetime.fromisoformat(iso_str).strftime("%H:%M")
except (TypeError, ValueError):
return "--:--"
+2 -3
View File
@@ -3,9 +3,8 @@ regional EFA (Elektronische Fahrplanauskunft) instances -- transit-planner
backends run by regional transit authorities, not Deutsche Bahn itself. backends run by regional transit authorities, not Deutsche Bahn itself.
This deliberately avoids bahn.de's own internal trip-planner API. DB has 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 been actively blocking third-party access to its own backends recently --
the Departures screen's docstring and the outage that motivated it) -- EFA EFA instances aren't DB's infrastructure, so they aren't part of that
instances aren't DB's infrastructure, so they aren't part of that
clampdown. Several are tried in sequence (not parallel, to keep this clampdown. Several are tried in sequence (not parallel, to keep this
synchronous like the rest of the app); first one to return usable journeys 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 wins. Ported and simplified from github.com/EiS94/db_info (a Home Assistant
-34
View File
@@ -128,40 +128,6 @@ def create_app(config, screens: dict, scheduler) -> Flask:
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"), error=error) 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,
})
scheduler.invalidate_screen("departures")
return redirect(url_for("settings_departures", found=found["name"]))
else:
config.update_screen_settings("departures", {"results": results, "update_minutes": update_minutes})
scheduler.invalidate_screen("departures")
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/journey", methods=["GET", "POST"]) @app.route("/settings/journey", methods=["GET", "POST"])
def settings_journey(): def settings_journey():
error = None error = None
-1
View File
@@ -189,7 +189,6 @@
<a href="{{ url_for('index') }}" class="{{ 'active' if request.endpoint == 'index' }}">Status</a> <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_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_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_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_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> <a href="{{ url_for('settings_rotation') }}" class="{{ 'active' if request.endpoint == 'settings_rotation' }}">Rotation</a>
-51
View File
@@ -1,51 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Departures</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;">
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 or stop name</label>
<input type="text" id="station" name="station" placeholder="e.g. Berlin Hauptbahnhof">
<p class="hint">Works for bus/tram/subway stops too, not just train stations -- search by name the same way either way.</p>
{% 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 %}
+2 -2
View File
@@ -13,8 +13,8 @@
Next connections for a fixed route -- your daily commute, say. Uses Next connections for a fixed route -- your daily commute, say. Uses
regional EFA transit-planner backends (not Deutsche Bahn's own regional EFA transit-planner backends (not Deutsche Bahn's own
infrastructure, which has been actively blocking third-party access infrastructure, which has been actively blocking third-party access
lately) -- several are tried in order, so this can work even when the lately) -- several are tried in order, so this stays reliable even when
Departures screen's DB-based source can't. one regional backend is down.
</p> </p>
<form method="post"> <form method="post">