Files
Paperwall/screens/journey.py
T
Honey 06b90ffe6c Journey: add regional-only filter and service notices
- connection_type setting (all/regional/long_distance) restricts EFA
  trip requests via inclMOT_N params, so ICE/IC/EC can be filtered out
  for commutes that only care about regional/local transit.
- Surface leg.infos[].infoLinks titles (real disruption/outage notices
  from EFA) on each journey row, marked with a red warning icon.
2026-07-23 09:51:52 +02:00

356 lines
15 KiB
Python

"""Next connections for a fixed daily commute (origin -> destination), via
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
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
integration), which queries these same EFA instances plus bahn.de's API in
parallel and scores the results -- dropped the bahn.de source and the
parallel/scoring machinery for a simpler, more DB-independent first version.
"""
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
# Each EFA instance exposes both endpoints under the same base path --
# XML_TRIP_REQUEST2 for routing, XML_STOPFINDER_REQUEST for resolving a
# place name to a stop + coordinates. Using the stopfinder from the same
# system that will answer the trip request (rather than a generic
# city-level geocoder) is what makes searching for e.g. "Münster
# Hauptbahnhof" -- a specific station, not just a city -- work at all.
EFA_SOURCES = [
{
"name": "vrr",
"base": "https://www.vrr.de/vrr-efa/",
# vrr.de hangs until timeout for trips outside its home region
# instead of returning a fast error, so it's only queried when both
# ends of the trip roughly fall within North Rhine-Westphalia.
"bounds": {"lat_min": 50.3, "lat_max": 52.6, "lon_min": 5.7, "lon_max": 9.5},
},
{"name": "bahnland-bayern", "base": "https://bahnland-bayern.de/efa/", "bounds": None},
{"name": "efa", "base": "https://www.efa.de/hit-efa/", "bounds": None},
{"name": "efa-bw", "base": "http://www.efa-bw.de/nvbw/", "bounds": None},
]
# Some EFA instances reject the default requests/python User-Agent as bot
# traffic and answer with a 403.
REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept": "application/json",
}
RED = (200, 0, 0)
BLACK = (0, 0, 0)
MUTED = (110, 110, 110)
# EFA's inclMOT_N flags select which transport modes a trip request is
# allowed to use -- N is an index into EFA's internal mode table, not the
# "class" value seen in responses. Long-distance rail (IC/EC/ICE) sits at
# 14-16; everything else usable for a regional/local commute (regional
# trains, S-Bahn, buses, trams, etc.) is the rest. Ported from db_info's
# _build_mot_params()/_build_produktgattungen().
_MOT_LONG_DISTANCE = {f"inclMOT_{i}": "true" for i in (14, 15, 16)}
_MOT_REGIONAL = {f"inclMOT_{i}": "true" for i in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 17, 20)}
def _mot_params(connection_type: str) -> dict:
if connection_type == "regional":
return {"includedMeans": "checkbox", **_MOT_REGIONAL}
if connection_type == "long_distance":
return {"includedMeans": "checkbox", **_MOT_LONG_DISTANCE}
return {}
def _in_bounds(lat, lon, bounds) -> bool:
if bounds is None:
return True
return bounds["lat_min"] <= lat <= bounds["lat_max"] and bounds["lon_min"] <= lon <= bounds["lon_max"]
def _parse_time(iso_str):
if not iso_str:
return None
try:
return datetime.fromisoformat(iso_str)
except ValueError:
return None
class JourneyScreen(Screen):
id = "journey"
display_name = "Journey"
poll_interval = 120 # fallback if update_minutes isn't in settings yet
def default_settings(self) -> dict:
return {
"origin_query": "", "origin_label": "", "origin_lat": None, "origin_lon": None,
"destination_query": "", "destination_label": "", "destination_lat": None, "destination_lon": None,
"results": 3,
"update_minutes": 2,
"connection_type": "all",
}
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 geocode(self, query: str):
"""Resolve a place/station name to coordinates via the same EFA
instances used for trip requests (XML_STOPFINDER_REQUEST) -- not a
generic city-level geocoder, which has no idea what "Hauptbahnhof"
means. Tries each source in order (no bounds pre-filter here, since
we don't have coordinates yet to check bounds against); returns the
first usable match."""
params = {
"outputFormat": "rapidJSON",
"language": "de",
"type_sf": "any",
"name_sf": query,
"anyMaxSizeHitList": 5,
"coordOutputFormat": "WGS84[dd.ddddd]",
}
for source in EFA_SOURCES:
try:
response = requests.get(
source["base"] + "XML_STOPFINDER_REQUEST",
params=params, headers=REQUEST_HEADERS, timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
data = response.json()
except (requests.exceptions.RequestException, ValueError) as exc:
logger.warning("Stopfinder source '%s' failed, trying next: %s", source["name"], exc)
continue
match = self._best_location(data)
if match:
return match
return None
def _best_location(self, data: dict):
locations = data.get("locations") or []
if not locations:
return None
best = next((loc for loc in locations if loc.get("isBest")), locations[0])
coord = best.get("coord")
if not coord or len(coord) < 2:
return None
return {"latitude": coord[0], "longitude": coord[1], "label": best.get("name") or ""}
def check(self, settings: dict):
o_lat, o_lon = settings.get("origin_lat"), settings.get("origin_lon")
d_lat, d_lon = settings.get("destination_lat"), settings.get("destination_lon")
if o_lat is None or o_lon is None or d_lat is None or d_lon is None:
return None
try:
results = max(1, min(5, int(settings.get("results") or 3)))
except (TypeError, ValueError):
results = 3
connection_type = settings.get("connection_type") or "all"
params = self._build_params(o_lat, o_lon, d_lat, d_lon, results, connection_type)
for source in EFA_SOURCES:
if not (_in_bounds(o_lat, o_lon, source["bounds"]) and _in_bounds(d_lat, d_lon, source["bounds"])):
continue
try:
response = requests.get(
source["base"] + "XML_TRIP_REQUEST2", params=params, headers=REQUEST_HEADERS, timeout=HTTP_TIMEOUT
)
response.raise_for_status()
data = response.json()
except (requests.exceptions.RequestException, ValueError) as exc:
logger.warning("Journey source '%s' failed, trying next: %s", source["name"], exc)
continue
journeys = self._parse_journeys(data, results)
if journeys:
logger.debug("Journey data from '%s'", source["name"])
return journeys
return None
def _build_params(self, o_lat, o_lon, d_lat, d_lon, results, connection_type: str = "all") -> dict:
now = datetime.now().astimezone()
params = {
"outputFormat": "rapidJSON",
"language": "de",
"type_origin": "coord",
"name_origin": f"{o_lon}:{o_lat}:WGS84[dd.ddddd]",
"type_destination": "coord",
"name_destination": f"{d_lon}:{d_lat}:WGS84[dd.ddddd]",
"itdDateDayMonthYear": now.strftime("%d.%m.%Y"),
"itdTime": now.strftime("%H:%M"),
"itdTripDateTimeDepArr": "dep",
"coordOutputFormat": "WGS84[dd.ddddd]",
"useRealtime": "1",
"calcOneDirection": "1",
"calcNumberOfTrips": str(results),
"ptOptionsActive": "1",
"lineRestriction": "400",
"serverInfo": "0",
}
params.update(_mot_params(connection_type))
return params
def _parse_journeys(self, data: dict, results: int):
journeys = []
for raw_journey in (data.get("journeys") or [])[:results]:
legs = []
for leg in raw_journey.get("legs", []):
transportation = leg.get("transportation") or {}
product = transportation.get("product") or {}
is_walk = (
product.get("class") == 100
or product.get("name") == "footpath"
or not transportation.get("name")
)
if is_walk:
continue
legs.append({
"line": transportation.get("name") or "?",
"origin": leg.get("origin") or {},
"destination": leg.get("destination") or {},
"infos": leg.get("infos") or [],
})
if not legs:
continue
first, last = legs[0], legs[-1]
dep_planned = _parse_time(first["origin"].get("departureTimePlanned"))
dep_estimated = _parse_time(first["origin"].get("departureTimeEstimated"))
arr_planned = _parse_time(last["destination"].get("arrivalTimePlanned"))
arr_estimated = _parse_time(last["destination"].get("arrivalTimeEstimated"))
if dep_planned is None:
continue
dep_actual = dep_estimated or dep_planned
delay_min = int((dep_estimated - dep_planned).total_seconds() // 60) if dep_estimated else 0
arr_actual = arr_estimated or arr_planned
# Service notices (disruptions, elevator outages, etc.) come as
# leg.infos[].infoLinks[].title -- collect titles across all legs
# of this journey, deduped, since the same notice often repeats
# on every leg it affects.
notice_titles = []
for leg in legs:
for info in leg["infos"]:
for link in info.get("infoLinks") or []:
title = link.get("title") or link.get("subtitle")
if title and title not in notice_titles:
notice_titles.append(title)
# EFA returns UTC timestamps (Z suffix) -- astimezone() with no
# args converts to the system's local timezone before display.
# Without this, times showed 2h behind (CEST is UTC+2), making
# "next" departures look like they'd already left.
journeys.append({
"departure": dep_actual.astimezone().strftime("%H:%M"),
"delay_min": max(0, delay_min),
"arrival": arr_actual.astimezone().strftime("%H:%M") if arr_actual else "--:--",
"lines": [leg["line"] for leg in legs],
"transfers": len(legs) - 1,
"platform": (first["origin"].get("properties") or {}).get("platformName") or "",
"notice": "; ".join(notice_titles),
})
return journeys or None
# --------------------------------------------------------------- render
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)
have_route = settings.get("origin_lat") is not None and settings.get("destination_lat") is not None
if not have_route:
self._render_message(draw, width, height, scale, "Keine Strecke konfiguriert")
return image
if not state:
self._render_message(draw, width, height, scale, "Keine Verbindungen verfügbar")
return image
pad = width * 0.04
origin = settings.get("origin_label") or settings.get("origin_query") or "?"
destination = settings.get("destination_label") or settings.get("destination_query") or "?"
header_font = load_font(size=int(scale * 0.05), bold=True)
header = truncate_to_width(draw, f"{origin} → {destination}", header_font, width - 2 * pad)
draw.text((pad, height * 0.045), header, 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)
time_font = load_font(size=int(scale * 0.07), bold=True)
arrow_font = load_font(size=int(scale * 0.05))
detail_font = load_font(size=int(scale * 0.038))
for i, journey in enumerate(state):
row_top_y = row_top + i * row_h
if i > 0:
draw.line((pad, row_top_y, width - pad, row_top_y), fill=(228, 228, 228), width=1)
top_line_y = row_top_y + row_h * 0.14
dep_color = RED if journey["delay_min"] > 0 else BLACK
dep_text = journey["departure"]
if journey["delay_min"] > 0:
dep_text += f" +{journey['delay_min']}"
draw.text((pad, top_line_y), dep_text, font=time_font, fill=dep_color)
dep_w = text_width(draw, dep_text, time_font)
arrow_x = pad + dep_w + scale * 0.02
draw.text((arrow_x, top_line_y + (time_font.size - arrow_font.size) / 2), "→", font=arrow_font, fill=MUTED)
arrow_w = text_width(draw, "→", arrow_font)
draw.text((arrow_x + arrow_w + scale * 0.02, top_line_y), journey["arrival"], font=time_font, fill=BLACK)
detail_parts = [" → ".join(journey["lines"])]
if journey["transfers"] > 0:
detail_parts.append(f"{journey['transfers']}x umsteigen")
if journey["platform"]:
detail_parts.append(f"Gl. {journey['platform']}")
if journey["notice"]:
detail_parts.append(journey["notice"])
detail_y = top_line_y + time_font.size * 1.25
detail_x = pad
if journey["notice"]:
draw.text((detail_x, detail_y), "⚠", font=detail_font, fill=RED)
detail_x += text_width(draw, "⚠", detail_font) + scale * 0.015
detail_line = truncate_to_width(draw, " · ".join(detail_parts), detail_font, width - pad - detail_x)
draw.text((detail_x, detail_y), detail_line, font=detail_font, fill=MUTED)
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=MUTED)