291 lines
10 KiB
Python
291 lines
10 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
# Autor: Marcel Weschke - 2025
|
||
|
||
# Linienfahrpläne-Quelle:
|
||
#
|
||
# Linie 15: https://www.rmv.de/c/fileadmin/import/timetable/traffiQ_2022_AHF_Linie_15_ab_2021_12_12.pdf
|
||
# Linie 16: https://www.rmv.de/c/fileadmin/import/timetable/traffiQ_2022_Buch_Linie_16_ab_2021_12_12.pdf
|
||
#
|
||
|
||
# =============================================================================
|
||
# RMV Live-Abfahrtstafel für Buchrainplatz
|
||
#
|
||
# Beispiel-Programmausgabe im Terminal:
|
||
#
|
||
# ➜ Desktop python RMV_DigiAnzeige_Buchreinplatz.py
|
||
#
|
||
# Buchrainplatz – Live-Abfahrtstafel – 11:57:10
|
||
#
|
||
# ← Richtung Frankfurt
|
||
# (Westen)
|
||
# ┏━━━━━━━┳━━━━━━┳━━━━┓
|
||
# ┃ Linie ┃ Ziel ┃ In ┃
|
||
# ┡━━━━━━━╇━━━━━━╇━━━━┩
|
||
# └───────┴──────┴────┘
|
||
#
|
||
# → Richtung Oberrad /
|
||
# Offenbach (Osten)
|
||
# ┏━━━━━━━┳━━━━━━┳━━━━┓
|
||
# ┃ Linie ┃ Ziel ┃ In ┃
|
||
# ┡━━━━━━━╇━━━━━━╇━━━━┩
|
||
# └───────┴──────┴────┘
|
||
#
|
||
# =============================================================================
|
||
|
||
|
||
# %% Load libraries
|
||
import argparse
|
||
import requests
|
||
import time
|
||
import json
|
||
from datetime import datetime
|
||
from rich.console import Console, Group
|
||
from rich.table import Table
|
||
from rich.live import Live
|
||
from rich.align import Align
|
||
|
||
# Neu für Webversion
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Container
|
||
from textual.widgets import Header, Footer, Input, DataTable, Static
|
||
from textual.color import Color
|
||
|
||
# %% Main
|
||
# Option 1: Manuell API Key laden:
|
||
#API_KEY = "" #"DEIN_API_KEY_HIER"
|
||
|
||
# Option 2: Automatisch API Key aus einer Textdatei laden:
|
||
try:
|
||
with open("apikey.txt", "r", encoding="utf-8") as f:
|
||
API_KEY = f.read().strip()
|
||
except FileNotFoundError:
|
||
raise FileNotFoundError("Fehler: Datei 'apikey.txt' nicht gefunden. Bitte API-Key in dieser Datei hinterlegen.")
|
||
except Exception as e:
|
||
raise RuntimeError(f"Fehler beim Laden des API-Key: {e}")
|
||
|
||
|
||
START_ID = "3001605" # Buchrainplatz
|
||
#START_ID = "3000906" # Lokalbahnhof/Textorstraße
|
||
MAX_PER_DIRECTION = 12
|
||
URL = "https://www.rmv.de/hapi/departureBoard"
|
||
|
||
# Linienfarben
|
||
# Colorcode-Quelle: https://commons.wikimedia.org/wiki/Template:Frankfurt_transit_icons
|
||
line_colors = {
|
||
"S1": "#0096da", # S-Bahn-lines
|
||
"S2": "#ee161f",
|
||
"S3": "#00aa96",
|
||
"S4": "#fac800",
|
||
"S5": "#965b33",
|
||
"S6": "#f27718",
|
||
"S7": "#1b533d",
|
||
"S8": "#8dc63c",
|
||
"S9": "#91208f",
|
||
"11": "#fbbd02", # Tram-lines
|
||
"12": "#c83710",
|
||
"14": "#f14517",
|
||
"16": "#f4730e",
|
||
"15": "#fbbd02",
|
||
"17": "#f14517",
|
||
"18": "#f89d06",
|
||
"19": "#ef3200",
|
||
"20": "#f79700",
|
||
"21": "#f89d06",
|
||
"U1": "#a50010", # U-Bahn-lines
|
||
"U2": "#00a850",
|
||
"U3": "#333f8d",
|
||
"U4": "#e12d82",
|
||
"U5": "#015c1b",
|
||
"U6": "#007ec6",
|
||
"U7": "#df9100",
|
||
"U8": "#c066a3",
|
||
"U9": "#ffd700"
|
||
}
|
||
|
||
console = Console()
|
||
|
||
def fetch_departures(start_id, max_journeys=12):
|
||
params = {
|
||
"id": start_id,
|
||
"format": "json",
|
||
"accessId": API_KEY,
|
||
"limit": max_journeys
|
||
}
|
||
resp = requests.get(URL, params=params, timeout=10)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
departures = []
|
||
if "DepartureBoard" in data and isinstance(data["DepartureBoard"], dict):
|
||
dep = data["DepartureBoard"].get("Departure", [])
|
||
departures = dep if isinstance(dep, list) else [dep]
|
||
elif "Departure" in data:
|
||
departures = data["Departure"] if isinstance(data["Departure"], list) else [data["Departure"]]
|
||
return departures
|
||
|
||
|
||
def build_table_data(departures):
|
||
"""Erzeugt Datenlisten für West- und Ost-Richtung."""
|
||
now = datetime.now().strftime("%H:%M:%S")
|
||
|
||
west_keywords = ["Frankfurt", "Ginnheim", "Lokalbahnhof", "Haardtwaldplatz"]
|
||
east_keywords = ["Oberrad", "Offenbach", "Stadtgrenze", "Louisa", "Hugo-Junkers", "Balduinstraße"]
|
||
excluded_lines = {"81", "M36"}
|
||
|
||
seen = set()
|
||
westbound, eastbound = [], []
|
||
|
||
for dep in departures:
|
||
line = dep.get("Product", [{}])[0].get("line", "?") if isinstance(dep.get("Product"), list) else dep.get("Product", {}).get("line", "?")
|
||
if line in excluded_lines:
|
||
continue
|
||
dest = dep.get("direction", "").strip()
|
||
if not dest:
|
||
continue
|
||
|
||
date = dep.get("rtDate") or dep.get("date")
|
||
time_ = dep.get("rtTime") or dep.get("time")
|
||
if not date or not time_:
|
||
continue
|
||
|
||
try:
|
||
dep_time = datetime.fromisoformat(f"{date}T{time_}")
|
||
except ValueError:
|
||
continue
|
||
|
||
mins = int((dep_time - datetime.now()).total_seconds() / 60)
|
||
mins_str = f"{mins} Min" if mins >= 0 else "Jetzt"
|
||
|
||
key = (line, dest, dep_time)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
|
||
if any(k in dest for k in west_keywords):
|
||
westbound.append((line, dest, mins_str))
|
||
elif any(k in dest for k in east_keywords):
|
||
eastbound.append((line, dest, mins_str))
|
||
|
||
westbound = sorted(westbound, key=lambda x: x[2])[:MAX_PER_DIRECTION]
|
||
eastbound = sorted(eastbound, key=lambda x: x[2])[:MAX_PER_DIRECTION]
|
||
|
||
return now, westbound, eastbound
|
||
|
||
|
||
# -------------------
|
||
# Terminalmodus
|
||
# -------------------
|
||
def run_terminal_mode():
|
||
with Live(console=console, refresh_per_second=1) as live:
|
||
while True:
|
||
try:
|
||
departures = fetch_departures(START_ID)
|
||
now, west, east = build_table_data(departures)
|
||
|
||
table_west = Table(title=f"← Richtung Frankfurt (Westen) – {now}")
|
||
table_west.add_column("Linie", justify="center", style="cyan", no_wrap=True)
|
||
table_west.add_column("Ziel", justify="left", style="magenta")
|
||
table_west.add_column("In", justify="right", style="green")
|
||
for line, dest, mins in west:
|
||
style = f"[{line_colors[line]}]{line}[/]" if line in line_colors else line
|
||
table_west.add_row(style, dest, mins)
|
||
|
||
table_east = Table(title="→ Richtung Oberrad / Offenbach (Osten)")
|
||
table_east.add_column("Linie", justify="center", style="cyan", no_wrap=True)
|
||
table_east.add_column("Ziel", justify="left", style="magenta")
|
||
table_east.add_column("In", justify="right", style="green")
|
||
for line, dest, mins in east:
|
||
style = f"[{line_colors[line]}]{line}[/]" if line in line_colors else line
|
||
table_east.add_row(style, dest, mins)
|
||
|
||
live.update(Align.center(Group(Align.center(table_west), Align.center(table_east))))
|
||
except Exception as e:
|
||
console.print(f"[red]Fehler bei API-Aufruf:[/red] {e}")
|
||
time.sleep(30)
|
||
|
||
|
||
# -------------------
|
||
# Webmodus mit Textual
|
||
# -------------------
|
||
class RMVApp(App):
|
||
CSS_PATH = None
|
||
CSS = """
|
||
Screen {
|
||
align: center middle;
|
||
}
|
||
#input_id {
|
||
width: 40;
|
||
margin: 1;
|
||
}
|
||
"""
|
||
|
||
BINDINGS = [("q", "quit", "Beenden")]
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.current_id = START_ID
|
||
self.table_west = DataTable()
|
||
self.table_east = DataTable()
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
yield Input(
|
||
value=self.current_id,
|
||
placeholder="START_ID eingeben und Enter drücken",
|
||
id="input_id"
|
||
)
|
||
yield Container(
|
||
Static("← Richtung Frankfurt (Westen)"),
|
||
self.table_west,
|
||
Static(""), # Leerzeile als Abstand
|
||
Static("→ Richtung Oberrad / Offenbach (Osten)"),
|
||
self.table_east
|
||
)
|
||
yield Footer()
|
||
|
||
async def on_mount(self) -> None:
|
||
# Spaltenüberschriften einmalig setzen
|
||
self.table_west.add_columns("Linie", "Ziel", "In")
|
||
self.table_east.add_columns("Linie", "Ziel", "In")
|
||
|
||
await self.refresh_tables()
|
||
self.set_interval(30, self.refresh_tables)
|
||
|
||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||
"""START_ID ändern, wenn Enter gedrückt wird"""
|
||
self.current_id = event.value.strip()
|
||
await self.refresh_tables()
|
||
|
||
async def refresh_tables(self) -> None:
|
||
try:
|
||
departures = fetch_departures(self.current_id)
|
||
_, west, east = build_table_data(departures)
|
||
|
||
self.update_table(self.table_west, west)
|
||
self.update_table(self.table_east, east)
|
||
except Exception as e:
|
||
self.console.print(f"[red]Fehler:[/red] {e}")
|
||
|
||
def update_table(self, table: DataTable, rows):
|
||
"""Befüllt eine DataTable mit neuen Daten und Farbcodes"""
|
||
table.clear() # Nur Zeilen löschen, Spalten bleiben
|
||
|
||
for line, dest, mins in rows:
|
||
color_hex = line_colors.get(line)
|
||
if color_hex:
|
||
table.add_row(f"[{color_hex}]{line}[/]", dest, mins)
|
||
else:
|
||
table.add_row(line, dest, mins)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--light", action="store_true", help="Terminalmodus")
|
||
args = parser.parse_args()
|
||
|
||
if args.light:
|
||
run_terminal_mode()
|
||
else:
|
||
RMVApp().run()
|