mirror of
https://github.com/skoelle/mvg-departures.git
synced 2026-09-18 02:40:25 +00:00
Compare commits
2
Commits
1c3ea8fb87
...
3a2d803788
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a2d803788 | ||
|
|
b90b2f9ba1 |
@@ -11,7 +11,7 @@ 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/main.py` | FastAPI-App, Endpunkte, Caching, MVG-API-Aufrufe |
|
||||||
| `app/config.py` | Config-Loader: YAML → Dataclasses |
|
| `app/config.py` | Config-Loader: YAML → Dataclasses |
|
||||||
| `app/templates/index.html` | Jinja2-HTML-Template (Mobile-UI) |
|
| `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 |
|
| `requirements.txt` | Python-Dependencies |
|
||||||
| `Dockerfile` | Container-Build |
|
| `Dockerfile` | Container-Build |
|
||||||
| `.github/workflows/build.yml` | CI/CD |
|
| `.github/workflows/build.yml` | CI/CD |
|
||||||
@@ -60,6 +60,9 @@ Kein Linter konfiguriert. Bei Bedarf: `ruff check app/` oder `black app/`
|
|||||||
### Neue Station hinzufügen
|
### Neue Station hinzufügen
|
||||||
→ Nur `config.yaml` editieren, kein Code nötig
|
→ 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
|
### Neuen Transporttyp hinzufügen
|
||||||
→ `TYPE_MAP` und `ICON_MAP` in `main.py` erweitern, CSS-Klasse `.icon-X` in `index.html` hinzufügen
|
→ `TYPE_MAP` und `ICON_MAP` in `main.py` erweitern, CSS-Klasse `.icon-X` in `index.html` hinzufügen
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ Kompakter Abfahrtsmonitor fuer MVG U-Bahn und S-Bahn (Muenchen), primaer fuer mo
|
|||||||
- Konfigurierbare Stationsliste (`config.yaml`) mit Typ (UBAHN/SBAHN/TRAM/BUS)
|
- Konfigurierbare Stationsliste (`config.yaml`) mit Typ (UBAHN/SBAHN/TRAM/BUS)
|
||||||
- Ausschluss-Filter pro Station: nur unerwuenschte Zielrichtungen werden ausgeblendet,
|
- Ausschluss-Filter pro Station: nur unerwuenschte Zielrichtungen werden ausgeblendet,
|
||||||
neue/unbekannte Ziele werden automatisch weiter angezeigt
|
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:
|
- Zwei Endpunkte:
|
||||||
- `/` — mobile-optimierte HTML-Ansicht mit Auto-Refresh (alle 60s per Meta-Refresh)
|
- `/` — mobile-optimierte HTML-Ansicht mit Auto-Refresh (alle 60s per Meta-Refresh)
|
||||||
- `/api/departures` — JSON-Ausgabe derselben Daten
|
- `/api/departures` — JSON-Ausgabe derselben Daten
|
||||||
@@ -24,18 +27,24 @@ refresh_seconds: 60
|
|||||||
cache_seconds: 20
|
cache_seconds: 20
|
||||||
departures_limit: 10
|
departures_limit: 10
|
||||||
|
|
||||||
stations:
|
profiles:
|
||||||
- name: "Josephsburg, München"
|
- name: "Hinfahrt"
|
||||||
type: "UBAHN"
|
stations:
|
||||||
exclude_destinations:
|
- name: "Josephsburg, München"
|
||||||
- "Messestadt Ost"
|
type: "UBAHN"
|
||||||
- "Messestadt West"
|
exclude_destinations:
|
||||||
|
- "Messestadt Ost"
|
||||||
- name: "Berg am Laim, München"
|
- "Messestadt West"
|
||||||
type: "SBAHN"
|
- name: "Berg am Laim, München"
|
||||||
exclude_destinations:
|
type: "SBAHN"
|
||||||
- "Erding"
|
exclude_destinations:
|
||||||
- "Markt Schwaben"
|
- "Erding"
|
||||||
|
- "Markt Schwaben"
|
||||||
|
- name: "Rückfahrt"
|
||||||
|
stations:
|
||||||
|
- name: "Grosshadern, München"
|
||||||
|
type: "UBAHN"
|
||||||
|
exclude_destinations: []
|
||||||
```
|
```
|
||||||
|
|
||||||
`exclude_destinations` filtert per exaktem `destination`-Stringvergleich. Alles was
|
`exclude_destinations` filtert per exaktem `destination`-Stringvergleich. Alles was
|
||||||
@@ -102,7 +111,9 @@ https://www.mvg.de/api/bgw-pt/v3/departures?globalId=de:09162:910&limit=10&trans
|
|||||||
| Route | Beschreibung |
|
| Route | Beschreibung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/` | HTML-Ansicht, mobile-optimiert, Auto-Refresh alle 60s |
|
| `/` | 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 |
|
| `/healthz` | Healthcheck fuer Docker/Watchtower |
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ config.yaml
|
|||||||
↓
|
↓
|
||||||
load_config() → AppConfig (Dataclass)
|
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)
|
resolve_station_id(name) → station_id (mit Memory-Cache)
|
||||||
↓
|
↓
|
||||||
MvgApi(station_id).departures(limit, transport_types)
|
MvgApi(station_id).departures(limit, transport_types)
|
||||||
@@ -61,7 +63,9 @@ Response: JSON oder HTML-Template
|
|||||||
| Route | Methode | Beschreibung |
|
| Route | Methode | Beschreibung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/` | GET | Mobile HTML-Ansicht, Auto-Refresh alle 60s |
|
| `/` | 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"}`) |
|
| `/healthz` | GET | Healthcheck (`{"status": "ok"}`) |
|
||||||
|
|
||||||
## 6. Konfiguration (`config.yaml`)
|
## 6. Konfiguration (`config.yaml`)
|
||||||
@@ -71,11 +75,18 @@ refresh_seconds: 60 # Meta-Refresh-Intervall (HTML)
|
|||||||
cache_seconds: 20 # API-Cache pro Station
|
cache_seconds: 20 # API-Cache pro Station
|
||||||
departures_limit: 10 # Max. Abfahrten pro Station
|
departures_limit: 10 # Max. Abfahrten pro Station
|
||||||
|
|
||||||
stations:
|
profiles:
|
||||||
- name: "Station Name, München"
|
- name: "Hinfahrt" # Profilname (einzigartig)
|
||||||
type: "UBAHN" # UBAHN | SBAHN | TRAM | BUS
|
stations:
|
||||||
exclude_destinations: # Exakter Stringvergleich
|
- name: "Station Name, München"
|
||||||
- "Zielstation"
|
type: "UBAHN" # UBAHN | SBAHN | TRAM | BUS
|
||||||
|
exclude_destinations: # Exakter Stringvergleich
|
||||||
|
- "Zielstation"
|
||||||
|
- name: "Rückfahrt"
|
||||||
|
stations:
|
||||||
|
- name: "Andere Station"
|
||||||
|
type: "SBAHN"
|
||||||
|
exclude_destinations: []
|
||||||
```
|
```
|
||||||
|
|
||||||
### Filter-Logik
|
### Filter-Logik
|
||||||
@@ -93,6 +104,7 @@ stations:
|
|||||||
## 8. UI
|
## 8. UI
|
||||||
|
|
||||||
- Dunkles Theme (`#111417` Background)
|
- 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`)
|
- 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
|
- Anzeige: Linie, Ziel, Abfahrtszeit, Verspätung (gelb/rot), Entfall, Störungsmeldungen
|
||||||
- Responsive, für Smartphone-First optimiert
|
- Responsive, für Smartphone-First optimiert
|
||||||
|
|||||||
+19
-10
@@ -13,12 +13,18 @@ class StationConfig:
|
|||||||
exclude_destinations: List[str] = field(default_factory=list)
|
exclude_destinations: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProfileConfig:
|
||||||
|
name: str
|
||||||
|
stations: List[StationConfig] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
refresh_seconds: int
|
refresh_seconds: int
|
||||||
cache_seconds: int
|
cache_seconds: int
|
||||||
departures_limit: int
|
departures_limit: int
|
||||||
stations: List[StationConfig]
|
profiles: List[ProfileConfig]
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str = None) -> AppConfig:
|
def load_config(path: str = None) -> AppConfig:
|
||||||
@@ -26,18 +32,21 @@ def load_config(path: str = None) -> AppConfig:
|
|||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
raw = yaml.safe_load(f)
|
raw = yaml.safe_load(f)
|
||||||
|
|
||||||
stations = [
|
profiles = []
|
||||||
StationConfig(
|
for p in raw.get("profiles", []):
|
||||||
name=s["name"],
|
stations = [
|
||||||
type=s["type"].upper(),
|
StationConfig(
|
||||||
exclude_destinations=s.get("exclude_destinations", []) or [],
|
name=s["name"],
|
||||||
)
|
type=s["type"].upper(),
|
||||||
for s in raw.get("stations", [])
|
exclude_destinations=s.get("exclude_destinations", []) or [],
|
||||||
]
|
)
|
||||||
|
for s in p.get("stations", [])
|
||||||
|
]
|
||||||
|
profiles.append(ProfileConfig(name=p["name"], stations=stations))
|
||||||
|
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
refresh_seconds=int(raw.get("refresh_seconds", 60)),
|
refresh_seconds=int(raw.get("refresh_seconds", 60)),
|
||||||
cache_seconds=int(raw.get("cache_seconds", 20)),
|
cache_seconds=int(raw.get("cache_seconds", 20)),
|
||||||
departures_limit=int(raw.get("departures_limit", 10)),
|
departures_limit=int(raw.get("departures_limit", 10)),
|
||||||
stations=stations,
|
profiles=profiles,
|
||||||
)
|
)
|
||||||
|
|||||||
+20
-7
@@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from mvg import MvgApi, TransportType
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("mvg-departures")
|
logger = logging.getLogger("mvg-departures")
|
||||||
@@ -98,27 +98,40 @@ def fetch_departures_for_station(station_cfg: StationConfig) -> List[Dict[str, A
|
|||||||
return result
|
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]] = []
|
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.extend(fetch_departures_for_station(station_cfg))
|
||||||
all_deps.sort(key=lambda d: d["time_epoch"] or 0)
|
all_deps.sort(key=lambda d: d["time_epoch"] or 0)
|
||||||
return all_deps
|
return all_deps
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/departures")
|
@app.get("/api/departures")
|
||||||
def api_departures():
|
def api_departures(profile: str = None):
|
||||||
return JSONResponse(content={"departures": get_all_departures()})
|
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("/")
|
@app.get("/")
|
||||||
def index(request: Request):
|
def index(request: Request, profile: str = None):
|
||||||
departures = get_all_departures()
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
{
|
{
|
||||||
"departures": departures,
|
"departures": departures,
|
||||||
|
"profiles": config.profiles,
|
||||||
|
"active_profile": active_profile,
|
||||||
"refresh_seconds": config.refresh_seconds,
|
"refresh_seconds": config.refresh_seconds,
|
||||||
"generated_at": datetime.now().strftime("%H:%M:%S"),
|
"generated_at": datetime.now().strftime("%H:%M:%S"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,11 +5,23 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Abfahrten</title>
|
<title>Abfahrten</title>
|
||||||
<meta http-equiv="refresh" content="{{ refresh_seconds }}">
|
<meta http-equiv="refresh" content="{{ refresh_seconds }};url=/?profile={{ active_profile.name }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div style="max-width: 375px; margin: 0 auto; width: 100%;">
|
<div style="max-width: 375px; margin: 0 auto; width: 100%;">
|
||||||
<h1>Abfahrten <span class="updated">{{ generated_at }}</span></h1>
|
<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 %}
|
{% if departures %}
|
||||||
<table>
|
<table>
|
||||||
{% for d in departures %}
|
{% for d in departures %}
|
||||||
|
|||||||
+15
-12
@@ -7,15 +7,18 @@ refresh_seconds: 60
|
|||||||
cache_seconds: 20
|
cache_seconds: 20
|
||||||
departures_limit: 10
|
departures_limit: 10
|
||||||
|
|
||||||
stations:
|
profiles:
|
||||||
- name: "Josephsburg, München"
|
- name: "Hinfahrt"
|
||||||
type: "UBAHN"
|
stations:
|
||||||
exclude_destinations:
|
- name: "Josephsburg, München"
|
||||||
- "Messestadt Ost"
|
type: "UBAHN"
|
||||||
- "Messestadt West"
|
exclude_destinations:
|
||||||
|
- "Messestadt Ost"
|
||||||
- name: "Berg am Laim, München"
|
- "Messestadt West"
|
||||||
type: "SBAHN"
|
- name: "Berg am Laim, München"
|
||||||
exclude_destinations:
|
type: "SBAHN"
|
||||||
- "Erding"
|
exclude_destinations:
|
||||||
- "Markt Schwaben"
|
- "Erding"
|
||||||
|
- "Markt Schwaben"
|
||||||
|
- name: "Rückfahrt"
|
||||||
|
stations: []
|
||||||
|
|||||||
Reference in New Issue
Block a user