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:
1 parent
5889422c31
commit
684d706afc
8 files changed
+6
-302
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user