From 684d706afc5743741913ffebea6db82d5305f0e5 Mon Sep 17 00:00:00 2001
From: Honey
Date: Sat, 25 Jul 2026 23:33:48 +0200
Subject: [PATCH] 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.
---
README.md | 4 +-
screens/__init__.py | 2 -
screens/departures.py | 207 -------------------------
screens/journey.py | 5 +-
web/app.py | 34 ----
web/templates/base.html | 1 -
web/templates/settings_departures.html | 51 ------
web/templates/settings_journey.html | 4 +-
8 files changed, 6 insertions(+), 302 deletions(-)
delete mode 100644 screens/departures.py
delete mode 100644 web/templates/settings_departures.html
diff --git a/README.md b/README.md
index e49820e..c83fad1 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Paperwall
A standalone dashboard for one Waveshare `epd4in0e` (4", 6-color) e-ink panel:
-Navidrome now-playing, clock, weather, departure boards, a fixed-route
-journey planner, and pictures (upload or URL), switchable/controllable from
+Navidrome now-playing, clock, weather, 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.
diff --git a/screens/__init__.py b/screens/__init__.py
index d9145a1..a218d19 100644
--- a/screens/__init__.py
+++ b/screens/__init__.py
@@ -1,5 +1,4 @@
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
@@ -8,7 +7,6 @@ from screens.weather import WeatherScreen
SCREEN_CLASSES = {
ClockScreen.id: ClockScreen,
WeatherScreen.id: WeatherScreen,
- DeparturesScreen.id: DeparturesScreen,
JourneyScreen.id: JourneyScreen,
PictureScreen.id: PictureScreen,
NavidromeScreen.id: NavidromeScreen,
diff --git a/screens/departures.py b/screens/departures.py
deleted file mode 100644
index 8482666..0000000
--- a/screens/departures.py
+++ /dev/null
@@ -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 "--:--"
diff --git a/screens/journey.py b/screens/journey.py
index 5305dcc..76f2166 100644
--- a/screens/journey.py
+++ b/screens/journey.py
@@ -3,9 +3,8 @@ 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
+been actively blocking third-party access to its own backends recently --
+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
diff --git a/web/app.py b/web/app.py
index 6fe0f83..8fa8bdc 100644
--- a/web/app.py
+++ b/web/app.py
@@ -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)
- @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"])
def settings_journey():
error = None
diff --git a/web/templates/base.html b/web/templates/base.html
index 770bcc9..91fc3ea 100644
--- a/web/templates/base.html
+++ b/web/templates/base.html
@@ -189,7 +189,6 @@
Status
Navidrome
Weather
- Departures
Journey
Picture
Rotation
diff --git a/web/templates/settings_departures.html b/web/templates/settings_departures.html
deleted file mode 100644
index 6988bad..0000000
--- a/web/templates/settings_departures.html
+++ /dev/null
@@ -1,51 +0,0 @@
-{% extends "base.html" %}
-{% block content %}
-Departures
-
-{% if error %}
-{{ error }}
-{% endif %}
-{% if request.args.get('found') %}
-Found: {{ request.args.get('found') }}
-{% endif %}
-
-
- 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.
-
-
-
-{% endblock %}
diff --git a/web/templates/settings_journey.html b/web/templates/settings_journey.html
index 81b3377..44a2ccf 100644
--- a/web/templates/settings_journey.html
+++ b/web/templates/settings_journey.html
@@ -13,8 +13,8 @@
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.
+ lately) -- several are tried in order, so this stays reliable even when
+ one regional backend is down.