Files
mvg-departures/app/main.py
T
2026-08-07 21:18:42 +02:00

144 lines
4.5 KiB
Python

# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) - MIT License
"""MVG Departures Monitor - FastAPI App."""
import time
import logging
from datetime import datetime
from typing import List, Dict, Any
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from mvg import MvgApi, TransportType
from app.config import load_config, StationConfig, ProfileConfig
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mvg-departures")
app = FastAPI(title="MVG/S-Bahn Departures Monitor")
templates = Jinja2Templates(directory="app/templates")
config = load_config()
TYPE_MAP = {
"UBAHN": TransportType.UBAHN,
"SBAHN": TransportType.SBAHN,
"TRAM": TransportType.TRAM,
"BUS": TransportType.BUS,
}
ICON_MAP = {
"UBAHN": "U",
"SBAHN": "S",
"TRAM": "T",
"BUS": "B",
}
_station_id_cache: Dict[str, str] = {}
_departures_cache: Dict[str, Dict[str, Any]] = {}
def resolve_station_id(name: str) -> str:
if name in _station_id_cache:
return _station_id_cache[name]
station = MvgApi.station(name)
if not station:
raise ValueError(f"Station nicht gefunden: {name}")
_station_id_cache[name] = station["id"]
return station["id"]
def fetch_departures_for_station(station_cfg: StationConfig) -> List[Dict[str, Any]]:
cache_key = f"{station_cfg.name}:{station_cfg.type}"
now = time.time()
cached = _departures_cache.get(cache_key)
if cached and (now - cached["ts"] < config.cache_seconds):
return cached["data"]
try:
station_id = resolve_station_id(station_cfg.name)
transport_type = TYPE_MAP.get(station_cfg.type)
mvgapi = MvgApi(station_id)
raw_departures = mvgapi.departures(
limit=config.departures_limit,
transport_types=[transport_type] if transport_type else None,
)
except Exception as exc:
logger.exception("Fehler beim Abruf fuer Station %s", station_cfg.name)
return []
result = []
for dep in raw_departures:
destination = dep.get("destination", "")
if destination in station_cfg.exclude_destinations:
continue
planned = dep.get("planned")
actual = dep.get("time")
delay_min = 0
if planned and actual:
delay_min = round((actual - planned) / 60)
result.append({
"station": station_cfg.name,
"type": station_cfg.type,
"icon": ICON_MAP.get(station_cfg.type, "?"),
"line": dep.get("line", ""),
"destination": destination,
"time_epoch": actual,
"time_str": datetime.fromtimestamp(actual).strftime("%H:%M") if actual else "",
"delay_min": delay_min,
"cancelled": dep.get("cancelled", False),
"messages": dep.get("messages", []) or [],
})
_departures_cache[cache_key] = {"ts": now, "data": result}
return result
def get_profile_by_name(profile_name: str) -> ProfileConfig:
for profile in config.profiles:
if profile.name == profile_name:
return profile
return config.profiles[0] if config.profiles else None
def get_departures_for_profile(profile: ProfileConfig) -> List[Dict[str, Any]]:
all_deps: List[Dict[str, Any]] = []
for station_cfg in profile.stations:
all_deps.extend(fetch_departures_for_station(station_cfg))
all_deps.sort(key=lambda d: d["time_epoch"] or 0)
return all_deps
@app.get("/api/departures")
def api_departures(profile: str = None):
active_profile = get_profile_by_name(profile) if profile else config.profiles[0] if config.profiles else None
if not active_profile:
return JSONResponse(content={"departures": []})
return JSONResponse(content={"departures": get_departures_for_profile(active_profile)})
@app.get("/")
def index(request: Request, profile: str = None):
active_profile = get_profile_by_name(profile) if profile else config.profiles[0] if config.profiles else None
departures = get_departures_for_profile(active_profile) if active_profile else []
return templates.TemplateResponse(
request,
"index.html",
{
"departures": departures,
"profiles": config.profiles,
"active_profile": active_profile,
"refresh_seconds": config.refresh_seconds,
"generated_at": datetime.now().strftime("%H:%M:%S"),
},
)
@app.get("/healthz")
def healthz():
return {"status": "ok"}