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. -

- -
-
- -
- - -

Works for bus/tram/subway stops too, not just train stations -- search by name the same way either way.

- {% if settings.station_label %} -

Currently: {{ settings.station_label }}

- {% endif %} -
-
- -
- -
- - -
-
- - -

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.

-
-
- - -
-{% 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.