initial commit

This commit is contained in:
Stefan Koelle
2026-08-01 20:20:27 +02:00
parent 09bf95368c
commit 5b89dd047b
10 changed files with 425 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__
*.pyc
.git
.github
.env
*.md
+64
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
.env
+19
View File
@@ -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"]
View File
+42
View File
@@ -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,
)
+129
View File
@@ -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"}
+135
View File
@@ -0,0 +1,135 @@
<!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 }}">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #111417;
color: #eaeaea;
font-size: 15px;
}
h1 {
font-size: 16px;
margin: 4px 0 10px 4px;
color: #9aa5b1;
font-weight: 500;
}
.updated {
font-size: 11px;
color: #6b7280;
margin-left: 4px;
}
table {
width: 100%;
border-collapse: collapse;
}
tr {
border-bottom: 1px solid #23272d;
}
td {
padding: 8px 4px;
vertical-align: middle;
}
.icon {
display: inline-block;
width: 26px;
height: 26px;
line-height: 26px;
text-align: center;
border-radius: 6px;
font-weight: 700;
font-size: 13px;
color: #111417;
}
.icon-U { background: #005ca9; color: #fff; }
.icon-S { background: #00933b; color: #fff; }
.icon-T { background: #e2001a; color: #fff; }
.icon-B { background: #55545a; color: #fff; }
.line {
font-weight: 700;
font-size: 14px;
display: block;
}
.station {
font-size: 11px;
color: #6b7280;
}
.destination {
font-size: 14px;
}
.time {
text-align: right;
white-space: nowrap;
font-weight: 700;
font-size: 15px;
}
.delay {
font-size: 12px;
font-weight: 700;
color: #f59e0b;
display: block;
text-align: right;
}
.delay.high { color: #ef4444; }
.cancelled {
color: #ef4444;
font-weight: 700;
font-size: 12px;
display: block;
text-align: right;
}
.messages {
font-size: 11px;
color: #f59e0b;
padding: 0 4px 8px 42px;
border-bottom: 1px solid #23272d;
}
.empty {
color: #6b7280;
padding: 20px 4px;
text-align: center;
}
</style>
</head>
<body>
<h1>Abfahrten <span class="updated">Stand: {{ generated_at }}</span></h1>
{% if departures %}
<table>
{% for d in departures %}
<tr>
<td style="width:34px;"><span class="icon icon-{{ d.icon }}">{{ d.icon }}</span></td>
<td>
<span class="line">{{ d.line }}</span>
<span class="destination">{{ d.destination }}</span>
<span class="station">{{ d.station }}</span>
</td>
<td class="time">
{{ d.time_str }}
{% if d.cancelled %}
<span class="cancelled">entfällt</span>
{% elif d.delay_min and d.delay_min > 0 %}
<span class="delay {% if d.delay_min >= 5 %}high{% endif %}">+{{ d.delay_min }} min</span>
{% endif %}
</td>
</tr>
{% if d.messages %}
<tr>
<td colspan="3" class="messages">
{% for m in d.messages %}{{ m }}{% if not loop.last %} · {% endif %}{% endfor %}
</td>
</tr>
{% endif %}
{% endfor %}
</table>
{% else %}
<div class="empty">Keine Abfahrten verfügbar.</div>
{% endif %}
</body>
</html>
+21
View File
@@ -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"
+5
View File
@@ -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