Compare commits

..
10 Commits
Author SHA1 Message Date
stefankoelle 9789eedc9e README.md 2026-08-14 23:37:05 +02:00
Stefan Koelle 8472475a2c Merge pull request #13 from skoelle/renovate/python-3.14-slim
docker Update python:3.14-slim Docker digest to a7fb1e6
2026-08-10 20:19:08 +02:00
renovate[bot] 10d1a71ab7 docker Update python:3.14-slim Docker digest to a7fb1e6 2026-08-10 18:17:40 +00:00
stefankoelle 8260d1a098 renovate no docker-compoes 2026-08-10 20:15:59 +02:00
stefankoelle 523ee24b0b renovate 2026-08-10 19:56:34 +02:00
stefankoelle 3a2d803788 documentation: profiles feature 2026-08-07 21:36:08 +02:00
stefankoelle b90b2f9ba1 new feature: profiles 2026-08-07 21:18:42 +02:00
stefankoelle 1c3ea8fb87 LICENSE 2026-08-04 19:27:51 +02:00
stefankoelle 85a95a1aa4 LICENSE 2026-08-04 19:16:37 +02:00
stefankoelle c8b8f4031d added LICENSE 2026-08-04 19:05:31 +02:00
13 changed files with 231 additions and 103 deletions
+6
View File
@@ -0,0 +1,6 @@
title: "MVG Departures"
emoji: "🚇"
category: code
subcategory: "Smart Home Apps"
status: active
stack: [Python, FastAPI, Uvicorn, Jinja2]
+6 -1
View File
@@ -11,10 +11,11 @@ MVG Departures Monitor: FastAPI-App für Münchner U-/S-Bahn-Abfahrtsanzeige. Mo
| `app/main.py` | FastAPI-App, Endpunkte, Caching, MVG-API-Aufrufe |
| `app/config.py` | Config-Loader: YAML → Dataclasses |
| `app/templates/index.html` | Jinja2-HTML-Template (Mobile-UI) |
| `config.yaml` | Stationskonfiguration (User-Input) |
| `config.yaml` | Profil- und Stationskonfiguration (User-Input) |
| `requirements.txt` | Python-Dependencies |
| `Dockerfile` | Container-Build |
| `.github/workflows/build.yml` | CI/CD |
| `LICENSE` | MIT-Lizenz |
## Code-Conventions
@@ -24,6 +25,7 @@ MVG Departures Monitor: FastAPI-App für Münchner U-/S-Bahn-Abfahrtsanzeige. Mo
- **Logging**: `logging.getLogger("mvg-departures")`
- **Naming**: snake_case (Python), camelCase (HTML/CSS-Klassen)
- **Keine Comments**: Nur wenn explizit angefordert
- **Lizenz**: MIT — Copyright-Header in allen Quelldateien (`# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) - MIT License`), HTML-Kommentar im Template
## Entwicklung
@@ -58,6 +60,9 @@ Kein Linter konfiguriert. Bei Bedarf: `ruff check app/` oder `black app/`
### Neue Station hinzufügen
→ Nur `config.yaml` editieren, kein Code nötig
### Neues Profil hinzufügen
`config.yaml` unter `profiles:` neuen Eintrag mit `name:` und `stations:` ergänzen
### Neuen Transporttyp hinzufügen
`TYPE_MAP` und `ICON_MAP` in `main.py` erweitern, CSS-Klasse `.icon-X` in `index.html` hinzufügen
+2 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6
FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc
WORKDIR /app
@@ -7,6 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY config.yaml ./config.yaml
COPY LICENSE ./LICENSE
ENV CONFIG_PATH=/app/config.yaml
ENV TZ=Europe/Berlin
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+56 -41
View File
@@ -1,21 +1,24 @@
# mvg-departures
# 🚇 mvg-departures
Kompakter Abfahrtsmonitor fuer MVG U-Bahn und S-Bahn (Muenchen), primaer fuer mobile Nutzung optimiert.
## Features
- Konfigurierbare Stationsliste (`config.yaml`) mit Typ (UBAHN/SBAHN/TRAM/BUS)
- Ausschluss-Filter pro Station: nur unerwuenschte Zielrichtungen werden ausgeblendet,
neue/unbekannte Ziele werden automatisch weiter angezeigt
- Zwei Endpunkte:
- `/` — mobile-optimierte HTML-Ansicht mit Auto-Refresh (alle 60s per Meta-Refresh)
- `/api/departures` — JSON-Ausgabe derselben Daten
- Anzeige von Linie, Ziel, Abfahrtszeit, Verspaetung und Stoerungsmeldungen
- Visuelle Unterscheidung von U-Bahn (blau) und S-Bahn (gruen) per Icon
![Screenshot der Webseite](docs/screenshot.png)
## Konfiguration
## ✨ Features
- 🗺️ Konfigurierbare Stationsliste (`config.yaml`) mit Typ (UBAHN/SBAHN/TRAM/BUS)
- 🚫 Ausschluss-Filter pro Station: nur unerwuenschte Zielrichtungen werden ausgeblendet,
neue/unbekannte Ziele werden automatisch weiter angezeigt
- 👤 Profile zur Gruppierung von Stationen (z.B. "Hinfahrt" und "Rückfahrt")
- 🖱️ Klickbare Kacheln zum Profilwechsel (nur bei >1 Profil)
- 🔗 URL-Param `?profile=<name>` + Auto-Refresh
- 🌐 Zwei Endpunkte:
- `/` — mobile-optimierte HTML-Ansicht mit Auto-Refresh (alle 60s per Meta-Refresh)
- `/api/departures` — JSON-Ausgabe derselben Daten
- 📊 Anzeige von Linie, Ziel, Abfahrtszeit, Verspaetung und Stoerungsmeldungen
- 🎨 Visuelle Unterscheidung von U-Bahn (blau) und S-Bahn (gruen) per Icon
## ⚙️ Konfiguration
Siehe `config.yaml`:
@@ -24,39 +27,45 @@ refresh_seconds: 60
cache_seconds: 20
departures_limit: 10
profiles:
- name: "Hinfahrt"
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"
- name: "Rückfahrt"
stations:
- name: "Grosshadern, München"
type: "UBAHN"
exclude_destinations: []
```
`exclude_destinations` filtert per exaktem `destination`-Stringvergleich. Alles was
NICHT in der Liste steht, wird angezeigt — so fallen Linienaenderungen/Umleitungen
sofort auf, statt lautlos verschwunden zu sein.
## Lokal starten
## 🏃 Lokal starten
```bash
pip install -r requirements.txt
uvicorn app.main:app --reload
```
## Docker
## 🐳 Docker
```bash
docker build -t mvg-departures .
docker run -p 8000:8000 -e TZ=Europe/Berlin -v $(pwd)/config.yaml:/app/config.yaml mvg-departures
```
## Deployment
## 🚀 Deployment
Siehe `docker-compose.yml` im Repo als Referenz. Beispiel (Owner anpassen):
@@ -76,36 +85,38 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
```
## CI/CD
## 🔄 CI/CD
`.github/workflows/build.yml`:
- Baut bei jedem Push auf `main` das Docker-Image und pusht es nach
- 🏗️ Baut bei jedem Push auf `main` das Docker-Image und pusht es nach
`ghcr.io/<owner>/<repo>:latest` sowie mit Short-SHA-Tag
- Anschliessender Cleanup-Job loescht alte Image-Versionen in der GHCR-Package-Registry
- 🧹 Anschliessender Cleanup-Job loescht alte Image-Versionen in der GHCR-Package-Registry
und behaelt nur die letzten 4 Versionen (`min-versions-to-keep: 4`)
### Voraussetzung
### 📋 Voraussetzung
Repo-Settings → Actions → General → Workflow permissions auf
"Read and write permissions" stellen, sonst schlaegt der Push nach GHCR fehl.
## mvg api
## 📡 mvg api
https://www.mvg.de/api/bgw-pt/v3/locations?query=Josephsburg \
https://www.mvg.de/api/bgw-pt/v3/locations?query=Berg%20am \
https://www.mvg.de/api/bgw-pt/v3/departures?globalId=de:09162:1220&limit=10&transportTypes=UBAHN \
https://www.mvg.de/api/bgw-pt/v3/departures?globalId=de:09162:910&limit=10&transportTypes=SBAHN
## Endpunkte
## 🔌 Endpunkte
| Route | Beschreibung |
|---|---|
| `/` | HTML-Ansicht, mobile-optimiert, Auto-Refresh alle 60s |
| `/api/departures` | JSON-Liste aller gefilterten Abfahrten |
| `/?profile=<name>` | HTML-Ansicht für ein bestimmtes Profil |
| `/api/departures` | JSON-Liste aller gefilterten Abfahrten (erstes Profil als Fallback) |
| `/api/departures?profile=<name>` | JSON-Liste für ein bestimmtes Profil |
| `/healthz` | Healthcheck fuer Docker/Watchtower |
## Projektstruktur
## 📁 Projektstruktur
```
mvg-departures/
@@ -128,24 +139,28 @@ mvg-departures/
└── build.yml # CI/CD: Docker-Build + GHCR-Push
```
## Tech-Stack
## 🛠️ Tech-Stack
- **Runtime**: Python 3.14
- **Framework**: FastAPI + Uvicorn
- **Templating**: Jinja2 (server-seitig)
- **MVG-API**: `mvg` Python-Client (oeffentlich, kein Auth noetig)
- **Config**: YAML via `pyyaml`
- **Container**: Docker (python:3.14-slim)
- **Runtime**: Python 3.14
- 🚀 **Framework**: FastAPI + Uvicorn
- 🎨 **Templating**: Jinja2 (server-seitig)
- 🚇 **MVG-API**: `mvg` Python-Client (oeffentlich, kein Auth noetig)
- ⚙️ **Config**: YAML via `pyyaml`
- 🐳 **Container**: Docker (python:3.14-slim)
## Caching
## 💾 Caching
- **Station-ID**: In-memory Dict, lifetime=Session (loest Station-Namen auf)
- **Abfahrten**: In-memory Dict, TTL=`cache_seconds` (Default: 20s) pro Station+Typ
- Keine Persistenz → Neustart = Cache-Verlust
- 🆔 **Station-ID**: In-memory Dict, lifetime=Session (loest Station-Namen auf)
- ⏱️ **Abfahrten**: In-memory Dict, TTL=`cache_seconds` (Default: 20s) pro Station+Typ
- 🔄 Keine Persistenz → Neustart = Cache-Verlust
## Einschraenkungen
## ⚠️ Einschraenkungen
- Kein WebSocket/Live-Updates (nur periodischer Meta-Refresh)
- Keine Datenbank (nur In-Memory)
- Kein Test-Framework vorhanden
- Ein `config.yaml` pro Deployment (kein Multi-Tenancy)
- 📡 Kein WebSocket/Live-Updates (nur periodischer Meta-Refresh)
- 💽 Keine Datenbank (nur In-Memory)
- 🧪 Kein Test-Framework vorhanden
- 📄 Ein `config.yaml` pro Deployment (kein Multi-Tenancy)
## 📜 Lizenz
MIT License - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
+14 -2
View File
@@ -42,7 +42,9 @@ config.yaml
load_config() → AppConfig (Dataclass)
Für jede Station:
Profile auswählen (?profile= param oder erstes Profil)
Für jede Station im aktiven Profil:
resolve_station_id(name) → station_id (mit Memory-Cache)
MvgApi(station_id).departures(limit, transport_types)
@@ -61,7 +63,9 @@ Response: JSON oder HTML-Template
| Route | Methode | Beschreibung |
|---|---|---|
| `/` | GET | Mobile HTML-Ansicht, Auto-Refresh alle 60s |
| `/api/departures` | GET | JSON-Liste aller gefilterten Abfahrten |
| `/?profile=<name>` | GET | HTML-Ansicht für ein bestimmtes Profil |
| `/api/departures` | GET | JSON-Liste aller gefilterten Abfahrten (erstes Profil als Fallback) |
| `/api/departures?profile=<name>` | GET | JSON-Liste für ein bestimmtes Profil |
| `/healthz` | GET | Healthcheck (`{"status": "ok"}`) |
## 6. Konfiguration (`config.yaml`)
@@ -71,11 +75,18 @@ refresh_seconds: 60 # Meta-Refresh-Intervall (HTML)
cache_seconds: 20 # API-Cache pro Station
departures_limit: 10 # Max. Abfahrten pro Station
profiles:
- name: "Hinfahrt" # Profilname (einzigartig)
stations:
- name: "Station Name, München"
type: "UBAHN" # UBAHN | SBAHN | TRAM | BUS
exclude_destinations: # Exakter Stringvergleich
- "Zielstation"
- name: "Rückfahrt"
stations:
- name: "Andere Station"
type: "SBAHN"
exclude_destinations: []
```
### Filter-Logik
@@ -93,6 +104,7 @@ stations:
## 8. UI
- Dunkles Theme (`#111417` Background)
- Profil-Kacheln (nur bei >1 Profil): 50% Breite, Basis `#1e242b`, aktiv `#005ca9` (MVG-Blau)
- Farbliche Unterscheidung: U-Bahn (blau `#005ca9`), S-Bahn (grün `#00933b`), Tram (rot `#e2001a`), Bus (grau `#55545a`)
- Anzeige: Linie, Ziel, Abfahrtszeit, Verspätung (gelb/rot), Entfall, Störungsmeldungen
- Responsive, für Smartphone-First optimiert
+1
View File
@@ -0,0 +1 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) - MIT License
+13 -3
View File
@@ -1,3 +1,4 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) - MIT License
"""Config-Loader für die Stationsliste aus config.yaml."""
import os
import yaml
@@ -12,12 +13,18 @@ class StationConfig:
exclude_destinations: List[str] = field(default_factory=list)
@dataclass
class ProfileConfig:
name: str
stations: List[StationConfig] = field(default_factory=list)
@dataclass
class AppConfig:
refresh_seconds: int
cache_seconds: int
departures_limit: int
stations: List[StationConfig]
profiles: List[ProfileConfig]
def load_config(path: str = None) -> AppConfig:
@@ -25,18 +32,21 @@ def load_config(path: str = None) -> AppConfig:
with open(path, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f)
profiles = []
for p in raw.get("profiles", []):
stations = [
StationConfig(
name=s["name"],
type=s["type"].upper(),
exclude_destinations=s.get("exclude_destinations", []) or [],
)
for s in raw.get("stations", [])
for s in p.get("stations", [])
]
profiles.append(ProfileConfig(name=p["name"], stations=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,
profiles=profiles,
)
+21 -7
View File
@@ -1,3 +1,4 @@
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) - MIT License
"""MVG Departures Monitor - FastAPI App."""
import time
import logging
@@ -11,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
from mvg import MvgApi, TransportType
from app.config import load_config, StationConfig
from app.config import load_config, StationConfig, ProfileConfig
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mvg-departures")
@@ -97,27 +98,40 @@ def fetch_departures_for_station(station_cfg: StationConfig) -> List[Dict[str, A
return result
def get_all_departures() -> List[Dict[str, Any]]:
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 config.stations:
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():
return JSONResponse(content={"departures": get_all_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):
departures = get_all_departures()
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"),
},
+14 -1
View File
@@ -1,14 +1,27 @@
<!-- Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) - MIT License -->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Abfahrten</title>
<meta http-equiv="refresh" content="{{ refresh_seconds }}">
<meta http-equiv="refresh" content="{{ refresh_seconds }};url=/?profile={{ active_profile.name }}">
</head>
<body>
<div style="max-width: 375px; margin: 0 auto; width: 100%;">
<h1>Abfahrten <span class="updated">{{ generated_at }}</span></h1>
{% if profiles|length > 1 %}
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
{% for p in profiles %}
<a href="/?profile={{ p.name }}"
style="flex: 1; padding: 12px; text-align: center; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 18px; color: {{ 'white' if p.name == active_profile.name else '#9aa5b1' }}; background: {{ '#005ca9' if p.name == active_profile.name else '#1e242b' }};">
{{ p.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% if departures %}
<table>
{% for d in departures %}
+4 -1
View File
@@ -7,15 +7,18 @@ refresh_seconds: 60
cache_seconds: 20
departures_limit: 10
profiles:
- name: "Hinfahrt"
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"
- name: "Rückfahrt"
stations: []
+1 -1
View File
@@ -1,6 +1,6 @@
services:
mvg-departures:
image: ghcr.io/skoelle/mvg-departures:latest@sha256:c93ca797aa5dea84611a44d9463dd557816b664d30cdc7e956335f3e7628dcb0
image: ghcr.io/skoelle/mvg-departures:latest
container_name: mvg-departures
ports:
- "8000:8000"
+38 -11
View File
@@ -3,23 +3,50 @@
"extends": [
"config:recommended",
"docker:pinDigests",
"helpers:pinGitHubActionDigests",
":configMigration",
":pinDevDependencies",
"abandonments:recommended",
"security:minimumReleaseAgeNpm",
":dependencyDashboard"
"helpers:pinGitHubActionDigests"
],
"schedule": ["before 6am on monday"],
"timezone": "Europe/Berlin",
"automergeStrategy": "squash",
"ignorePaths": ["docker-compose.yml"],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"groupName": "{{depName}}",
"commitMessagePrefix": "fix(deps)"
"description": "GitHub Actions - grouped automerge",
"matchManagers": ["github-actions"],
"groupName": "github-actions",
"commitMessagePrefix": "ci",
"automerge": true
},
{
"description": "Docker Digests - automerge (security patches)",
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["digest"],
"commitMessagePrefix": "docker",
"automerge": true
},
{
"description": "Docker Major/Minor - manual review",
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["major", "minor"],
"groupName": "docker-base-images",
"commitMessagePrefix": "docker",
"automerge": false
},
{
"description": "Python minor/patch - automerge",
"matchManagers": ["pip_requirements"],
"matchUpdateTypes": ["minor", "patch"],
"commitMessagePrefix": "fix(deps)",
"automerge": true
},
{
"description": "Python major - no automerge, label",
"matchManagers": ["pip_requirements"],
"matchUpdateTypes": ["major"],
"groupName": "major-{{depName}}"
"commitMessagePrefix": "chore(deps)",
"automerge": false,
"labels": ["major-update"]
}
]
],
"ignoreDeps": ["node", "express", "node-cron", "nodemailer", "actions/setup-node"]
}