From 5b89dd047bc5a70b47d3f798f014dc344f2b50fc Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Sat, 1 Aug 2026 20:20:27 +0200 Subject: [PATCH] initial commit --- .dockerignore | 6 ++ .github/workflows/build.yml | 64 +++++++++++++++++ .gitignore | 4 ++ Dockerfile | 19 +++++ app/__init__.py | 0 app/config.py | 42 +++++++++++ app/main.py | 129 ++++++++++++++++++++++++++++++++++ app/templates/index.html | 135 ++++++++++++++++++++++++++++++++++++ config.yaml | 21 ++++++ requirements.txt | 5 ++ 10 files changed, 425 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/templates/index.html create mode 100644 config.yaml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd8b186 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +*.pyc +.git +.github +.env +*.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..220df60 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,64 @@ +name: Build and Push Docker Image + +on: + push: + branches: [ "main" ] + workflow_dispatch: {} + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + cleanup-old-images: + needs: build-and-push + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Delete old container image versions (keep last 4) + uses: actions/delete-package-versions@v5 + with: + package-name: ${{ github.event.repository.name }} + package-type: container + min-versions-to-keep: 4 + delete-only-untagged-versions: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc4f72a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7cfddb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY config.yaml ./config.yaml + +ENV CONFIG_PATH=/app/config.yaml +ENV TZ=Europe/Berlin + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..44a06ad --- /dev/null +++ b/app/config.py @@ -0,0 +1,42 @@ +"""Config-Loader fuer die Stationsliste aus config.yaml.""" +import os +import yaml +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class StationConfig: + name: str + type: str # UBAHN | SBAHN | TRAM | BUS + exclude_destinations: List[str] = field(default_factory=list) + + +@dataclass +class AppConfig: + refresh_seconds: int + cache_seconds: int + departures_limit: int + stations: List[StationConfig] + + +def load_config(path: str = None) -> AppConfig: + path = path or os.environ.get("CONFIG_PATH", "config.yaml") + with open(path, "r", encoding="utf-8") as f: + raw = yaml.safe_load(f) + + stations = [ + StationConfig( + name=s["name"], + type=s["type"].upper(), + exclude_destinations=s.get("exclude_destinations", []) or [], + ) + for s in raw.get("stations", []) + ] + + return AppConfig( + refresh_seconds=int(raw.get("refresh_seconds", 60)), + cache_seconds=int(raw.get("cache_seconds", 20)), + departures_limit=int(raw.get("departures_limit", 10)), + stations=stations, + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4d4a08a --- /dev/null +++ b/app/main.py @@ -0,0 +1,129 @@ +"""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 + +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_all_departures() -> List[Dict[str, Any]]: + all_deps: List[Dict[str, Any]] = [] + for station_cfg in config.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(): + return JSONResponse(content={"departures": get_all_departures()}) + + +@app.get("/") +def index(request: Request): + departures = get_all_departures() + return templates.TemplateResponse( + "index.html", + { + "request": request, + "departures": departures, + "refresh_seconds": config.refresh_seconds, + "generated_at": datetime.now().strftime("%H:%M:%S"), + }, + ) + + +@app.get("/healthz") +def healthz(): + return {"status": "ok"} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..37feb6a --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,135 @@ + + + + + +Abfahrten + + + + +

Abfahrten Stand: {{ generated_at }}

+ {% if departures %} + + {% for d in departures %} + + + + + + {% if d.messages %} + + + + {% endif %} + {% endfor %} +
{{ d.icon }} + {{ d.line }} + {{ d.destination }} + {{ d.station }} + + {{ d.time_str }} + {% if d.cancelled %} + entfällt + {% elif d.delay_min and d.delay_min > 0 %} + +{{ d.delay_min }} min + {% endif %} +
+ {% for m in d.messages %}{{ m }}{% if not loop.last %} · {% endif %}{% endfor %} +
+ {% else %} +
Keine Abfahrten verfügbar.
+ {% endif %} + + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..5e4ef1b --- /dev/null +++ b/config.yaml @@ -0,0 +1,21 @@ +# Konfiguration der ueberwachten Stationen +# type: UBAHN | SBAHN | TRAM | BUS +# exclude_destinations: Liste von Zielstationen (destination), die NICHT angezeigt werden sollen. +# Alles was hier NICHT aufgefuehrt ist, wird angezeigt (auch neue/unbekannte Ziele). + +refresh_seconds: 60 +cache_seconds: 20 +departures_limit: 10 + +stations: + - name: "Josephsburg, München" + type: "UBAHN" + exclude_destinations: + - "Messestadt Ost" + - "Messestadt West" + + - name: "Berg am Laim, München" + type: "SBAHN" + exclude_destinations: + - "Erding" + - "Markt Schwaben" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c6c14f2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pyyaml==6.0.2 +jinja2==3.1.4 +mvg==1.4.1