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.
257 lines
12 KiB
Python
257 lines
12 KiB
Python
"""Flask app: status/preview page + one settings form per screen + rotation settings."""
|
|
|
|
import requests
|
|
from flask import Flask, redirect, render_template, request, url_for
|
|
from PIL import Image
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from screens.picture import UPLOAD_DIR
|
|
|
|
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"}
|
|
|
|
|
|
def create_app(config, screens: dict, scheduler) -> Flask:
|
|
app = Flask(__name__)
|
|
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB, generous for a photo
|
|
|
|
@app.route("/")
|
|
def index():
|
|
status = scheduler.get_status()
|
|
return render_template(
|
|
"index.html",
|
|
status=status,
|
|
screens=screens,
|
|
rotation=config.get_rotation(),
|
|
navidrome_priority=config.get_navidrome_priority(),
|
|
)
|
|
|
|
@app.route("/actions/refresh", methods=["POST"])
|
|
def refresh_now():
|
|
scheduler.request_manual_refresh()
|
|
return redirect(url_for("index"))
|
|
|
|
@app.route("/actions/switch/<screen_id>", methods=["POST"])
|
|
def switch_now(screen_id):
|
|
if screen_id in screens:
|
|
scheduler.request_switch(screen_id)
|
|
return redirect(url_for("index"))
|
|
|
|
@app.route("/actions/resume", methods=["POST"])
|
|
def resume_rotation():
|
|
scheduler.request_resume_rotation()
|
|
return redirect(url_for("index"))
|
|
|
|
@app.route("/settings/navidrome", methods=["GET", "POST"])
|
|
def settings_navidrome():
|
|
if request.method == "POST":
|
|
config.update_screen_settings("navidrome", {
|
|
"url": request.form.get("url", "").strip(),
|
|
"username": request.form.get("username", "").strip(),
|
|
"password": request.form.get("password", ""),
|
|
"filter_user": request.form.get("filter_user", "").strip(),
|
|
"show_progress": "show_progress" in request.form,
|
|
"show_genre": "show_genre" in request.form,
|
|
"show_source": "show_source" in request.form,
|
|
"boost_cover": "boost_cover" in request.form,
|
|
"grayscale_cover": "grayscale_cover" in request.form,
|
|
})
|
|
return redirect(url_for("settings_navidrome", saved=1))
|
|
return render_template("settings_navidrome.html", settings=config.get_screen_settings("navidrome"))
|
|
|
|
@app.route("/settings/weather", methods=["GET", "POST"])
|
|
def settings_weather():
|
|
error = None
|
|
if request.method == "POST":
|
|
city = request.form.get("city", "").strip()
|
|
lat_raw = request.form.get("latitude", "").strip()
|
|
lon_raw = request.form.get("longitude", "").strip()
|
|
forecast_days = int(request.form.get("forecast_days", 3) or 3)
|
|
if forecast_days not in (3, 5, 7):
|
|
forecast_days = 3
|
|
update_minutes = max(1, int(request.form.get("update_minutes", 30) or 30))
|
|
|
|
if city:
|
|
try:
|
|
result = screens["weather"].geocode(city)
|
|
except requests.exceptions.RequestException:
|
|
result = None
|
|
if result is None:
|
|
error = f'Could not find "{city}" -- try a different spelling, or fill in coordinates manually below instead.'
|
|
else:
|
|
config.update_screen_settings("weather", {
|
|
"city": result["label"],
|
|
"latitude": result["latitude"],
|
|
"longitude": result["longitude"],
|
|
"forecast_days": forecast_days,
|
|
"update_minutes": update_minutes,
|
|
})
|
|
return redirect(url_for("settings_weather", saved=1))
|
|
else:
|
|
config.update_screen_settings("weather", {
|
|
"city": "",
|
|
"latitude": float(lat_raw) if lat_raw else None,
|
|
"longitude": float(lon_raw) if lon_raw else None,
|
|
"forecast_days": forecast_days,
|
|
"update_minutes": update_minutes,
|
|
})
|
|
return redirect(url_for("settings_weather", saved=1))
|
|
|
|
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,
|
|
})
|
|
return redirect(url_for("settings_departures", found=found["name"]))
|
|
else:
|
|
config.update_screen_settings("departures", {"results": results, "update_minutes": update_minutes})
|
|
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
|
|
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
|
|
if request.method == "POST":
|
|
url = request.form.get("image_url", "").strip()
|
|
common_updates = {
|
|
"fit": request.form.get("fit", "cover"),
|
|
"boost_contrast": "boost_contrast" in request.form,
|
|
"grayscale": "grayscale" in request.form,
|
|
}
|
|
uploaded = request.files.get("image")
|
|
|
|
if uploaded and uploaded.filename:
|
|
filename = secure_filename(uploaded.filename)
|
|
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
|
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
|
error = "Unsupported file type -- use PNG, JPG, GIF, BMP, or WebP."
|
|
else:
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
# Only one picture is ever active -- clear any previous
|
|
# upload first so a different extension doesn't leave an
|
|
# orphaned file behind.
|
|
for old in UPLOAD_DIR.glob("current.*"):
|
|
old.unlink()
|
|
dest = UPLOAD_DIR / f"current.{ext}"
|
|
uploaded.save(dest)
|
|
try:
|
|
Image.open(dest).verify()
|
|
except Exception:
|
|
dest.unlink(missing_ok=True)
|
|
error = "That file doesn't look like a valid image."
|
|
else:
|
|
config.update_screen_settings("picture", {
|
|
"mode": "upload",
|
|
"uploaded_filename": dest.name,
|
|
**common_updates,
|
|
})
|
|
return redirect(url_for("settings_picture", saved=1))
|
|
elif url:
|
|
config.update_screen_settings("picture", {"mode": "url", "image_url": url, **common_updates})
|
|
return redirect(url_for("settings_picture", saved=1))
|
|
else:
|
|
# Neither a new upload nor a URL change -- just update fit/boost/grayscale.
|
|
config.update_screen_settings("picture", common_updates)
|
|
return redirect(url_for("settings_picture", saved=1))
|
|
|
|
return render_template("settings_picture.html", settings=config.get_screen_settings("picture"), error=error)
|
|
|
|
@app.route("/settings/rotation", methods=["GET", "POST"])
|
|
def settings_rotation():
|
|
if request.method == "POST":
|
|
raw = request.form.get("screens", "")
|
|
selected = [s.strip() for s in raw.split(",") if s.strip() in screens]
|
|
interval_minutes = int(request.form.get("interval_minutes", 5) or 5)
|
|
enabled = "rotation_enabled" in request.form
|
|
config.set_rotation(selected, interval_minutes, enabled)
|
|
config.set_navidrome_priority("navidrome_priority" in request.form)
|
|
return redirect(url_for("settings_rotation", saved=1))
|
|
return render_template(
|
|
"settings_rotation.html",
|
|
screens=screens,
|
|
rotation=config.get_rotation(),
|
|
navidrome_priority=config.get_navidrome_priority(),
|
|
)
|
|
|
|
return app
|