mirror of
https://github.com/skoelle/mvg-departures.git
synced 2026-09-17 18:40:23 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
+19
@@ -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"]
|
||||||
@@ -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
@@ -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"}
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user