Add Journey screen: next connections for a fixed daily commute
New screens/journey.py, ported and simplified from github.com/EiS94/db_info (a Home Assistant integration). Deliberately uses only the regional EFA (Elektronische Fahrplanauskunft) backends the reference project queries (VRR prioritized for NRW routes, falling back to Bavaria/BW instances) and drops its bahn.de-internal-API source entirely -- that's DB's own infrastructure, exactly the kind of thing DB has been actively blocking (see the Departures screen). EFA instances are run by regional transit authorities, not DB, so they aren't part of that clampdown. Also dropped the reference project's parallel async fetch-and-score-all-sources machinery for simple sequential fallback, matching this project's fully synchronous style. Verified against live VRR data for a real NRW route (Münster -> Dortmund): real trains, platforms, transfer counts, even a night bus connection. Caught a real bug during that live test: EFA returns UTC timestamps (Z suffix), and datetime.fromisoformat() parses that correctly into an aware datetime, but strftime() on an aware datetime prints its own stored hour/minute rather than converting to local time first -- everything displayed 2 hours behind (CEST is UTC+2), which looked like "next" departures had already left. Fixed by converting with astimezone() before formatting; re-verified live afterward that departures now correctly show after the query time. Settings: origin/destination resolved via Open-Meteo's free geocoding (same pattern as Weather's city field), how many connections to show (1/2/3/5), and its own configurable update interval.
This commit is contained in:
1 parent
60acdc3756
commit
c182fb2f19
6 files changed
+404
-4
No files matched your search
+58
@@ -130,6 +130,64 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
||||
|
||||
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
|
||||
if request.method == "POST":
|
||||
origin_query = request.form.get("origin", "").strip()
|
||||
destination_query = request.form.get("destination", "").strip()
|
||||
try:
|
||||
results = max(1, min(5, int(request.form.get("results", 3) or 3)))
|
||||
except (TypeError, ValueError):
|
||||
results = 3
|
||||
update_minutes = max(1, int(request.form.get("update_minutes", 2) or 2))
|
||||
|
||||
updates = {"results": results, "update_minutes": update_minutes}
|
||||
journey_screen = screens["journey"]
|
||||
|
||||
if origin_query:
|
||||
try:
|
||||
found = journey_screen.geocode(origin_query)
|
||||
except requests.exceptions.RequestException:
|
||||
found = None
|
||||
if found is None:
|
||||
error = f'Could not find "{origin_query}" as a starting point -- try a different spelling.'
|
||||
else:
|
||||
updates.update({
|
||||
"origin_query": origin_query,
|
||||
"origin_label": found["label"],
|
||||
"origin_lat": found["latitude"],
|
||||
"origin_lon": found["longitude"],
|
||||
})
|
||||
|
||||
if not error and destination_query:
|
||||
try:
|
||||
found = journey_screen.geocode(destination_query)
|
||||
except requests.exceptions.RequestException:
|
||||
found = None
|
||||
if found is None:
|
||||
error = f'Could not find "{destination_query}" as a destination -- try a different spelling.'
|
||||
else:
|
||||
updates.update({
|
||||
"destination_query": destination_query,
|
||||
"destination_label": found["label"],
|
||||
"destination_lat": found["latitude"],
|
||||
"destination_lon": found["longitude"],
|
||||
})
|
||||
|
||||
if not error:
|
||||
config.update_screen_settings("journey", updates)
|
||||
found_parts = [v for k, v in (("origin", origin_query), ("destination", destination_query)) if v]
|
||||
if found_parts:
|
||||
current = config.get_screen_settings("journey")
|
||||
return redirect(url_for(
|
||||
"settings_journey",
|
||||
found=f'{current["origin_label"]} → {current["destination_label"]}',
|
||||
))
|
||||
return redirect(url_for("settings_journey", saved=1))
|
||||
|
||||
return render_template("settings_journey.html", settings=config.get_screen_settings("journey"), error=error)
|
||||
|
||||
@app.route("/settings/picture", methods=["GET", "POST"])
|
||||
def settings_picture():
|
||||
error = None
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
<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_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_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>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Journey</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;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<form method="post">
|
||||
<div class="card">
|
||||
<div class="section-label">Route</div>
|
||||
<div class="field">
|
||||
<label for="origin">From</label>
|
||||
<input type="text" id="origin" name="origin" placeholder="e.g. Münster Hauptbahnhof">
|
||||
{% if settings.origin_label %}
|
||||
<p class="hint">Currently: {{ settings.origin_label }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="destination">To</label>
|
||||
<input type="text" id="destination" name="destination" placeholder="e.g. Dortmund Hauptbahnhof">
|
||||
{% if settings.destination_label %}
|
||||
<p class="hint">Currently: {{ settings.destination_label }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="hint">Leave either field empty to keep what's already saved and just change the options below.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-label">Display</div>
|
||||
<div class="field">
|
||||
<label for="results">Connections to show</label>
|
||||
<select id="results" name="results">
|
||||
<option value="1" {{ "selected" if settings.results == 1 }}>1</option>
|
||||
<option value="2" {{ "selected" if settings.results == 2 }}>2</option>
|
||||
<option value="3" {{ "selected" if settings.results == 3 or not settings.results }}>3</option>
|
||||
<option value="5" {{ "selected" if settings.results == 5 }}>5</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 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user