Server liefert zusätzlich die Position in 10 s, der Browser interpoliert dazwischen (~15 fps). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
143 lines
4.6 KiB
Python
143 lines
4.6 KiB
Python
"""Webserver: liefert die Karte aus und berechnet laufend die Zugpositionen.
|
|
|
|
uvicorn trainpulse.server:app --host 0.0.0.0 --port 8080
|
|
"""
|
|
import datetime as dt
|
|
import gzip
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException, Request, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from . import config as C
|
|
from . import realtime
|
|
from .feed import TZ, Feed
|
|
|
|
log = logging.getLogger("trainpulse")
|
|
AHEAD = 10
|
|
WEB = Path(__file__).resolve().parent.parent / "web"
|
|
|
|
|
|
class State:
|
|
feed: Feed | None = None
|
|
rt: dict = {}
|
|
rt_version: str | None = None
|
|
rt_at: float | None = None
|
|
rt_error: str | None = None
|
|
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
|
|
vehicles_gz: bytes = gzip.compress(vehicles)
|
|
count = 0
|
|
computed_at: float | None = None
|
|
|
|
|
|
S = State()
|
|
|
|
|
|
def loop(every: float, fn):
|
|
def run():
|
|
while True:
|
|
t = time.time()
|
|
try:
|
|
fn()
|
|
except Exception:
|
|
log.exception("Fehler in %s", fn.__name__)
|
|
time.sleep(max(1.0, every - (time.time() - t)))
|
|
threading.Thread(target=run, daemon=True, name=fn.__name__).start()
|
|
|
|
|
|
def watch_feeds():
|
|
"""Lädt den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (die schalten
|
|
erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
|
|
ready = sorted(m.parent.name for m in C.FEEDS_DIR.glob("*/meta.json"))
|
|
if not ready:
|
|
return
|
|
want = S.rt_version if S.rt_version in ready else ready[-1]
|
|
today = dt.datetime.now(TZ).date()
|
|
if S.feed and S.feed.version == want and S.feed.covers(today + dt.timedelta(days=1)):
|
|
return
|
|
log.info("Lade Fahrplan %s …", want)
|
|
t = time.time()
|
|
S.feed = Feed(C.FEEDS_DIR / want)
|
|
log.info("Fahrplan %s geladen in %.0f s (%d Fahrten)", want, time.time() - t, len(S.feed.trip_ids))
|
|
|
|
|
|
def poll_realtime():
|
|
if not C.RT_KEY or not S.feed:
|
|
return
|
|
try:
|
|
S.rt_version, S.rt = realtime.parse(realtime.fetch(), S.feed.trip_index)
|
|
S.rt_at, S.rt_error = time.time(), None
|
|
except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen
|
|
S.rt_error = str(e)
|
|
if S.rt_at and time.time() - S.rt_at > 600:
|
|
S.rt = {}
|
|
raise
|
|
|
|
|
|
def compute_positions():
|
|
if not S.feed:
|
|
return
|
|
now = time.time()
|
|
feats = S.feed.vehicles(now, S.rt)
|
|
# Position in AHEAD s mitliefern: der Browser fährt die Züge damit flüssig dazwischen
|
|
ahead = {f["properties"]["id"]: f["geometry"]["coordinates"] for f in S.feed.vehicles(now + AHEAD, S.rt)}
|
|
for f in feats:
|
|
f["next"] = ahead.get(f["properties"]["id"])
|
|
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "ahead": AHEAD, "features": feats},
|
|
separators=(",", ":")).encode()
|
|
S.vehicles_gz = gzip.compress(S.vehicles, 5)
|
|
S.count, S.computed_at = len(feats), time.time()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
loop(60, watch_feeds)
|
|
loop(C.RT_INTERVAL, poll_realtime)
|
|
loop(3, compute_positions)
|
|
yield
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
|
|
|
|
|
|
@app.get("/api/vehicles")
|
|
def vehicles(request: Request):
|
|
headers = {"Cache-Control": "no-store", "X-Now": str(time.time())} # für den Uhrenabgleich im Browser
|
|
if "gzip" in request.headers.get("accept-encoding", ""):
|
|
return Response(S.vehicles_gz, media_type="application/json", headers={**headers, "Content-Encoding": "gzip"})
|
|
return Response(S.vehicles, media_type="application/json", headers=headers)
|
|
|
|
|
|
@app.get("/api/trip/{date}/{ti}")
|
|
def trip(date: str, ti: int):
|
|
f = S.feed
|
|
if not f or not (0 <= ti < len(f.trip_ids)) or not (len(date) == 8 and date.isdigit()):
|
|
raise HTTPException(404)
|
|
return f.trip_detail(date, ti, time.time(), S.rt)
|
|
|
|
|
|
@app.get("/api/status")
|
|
def status():
|
|
f = S.feed
|
|
return {
|
|
"feed_version": f.version if f else None,
|
|
"shapes": f.meta.get("shapes") if f else None,
|
|
"realtime": bool(C.RT_KEY),
|
|
"rt_version": S.rt_version,
|
|
"rt_age": round(time.time() - S.rt_at) if S.rt_at else None,
|
|
"rt_error": S.rt_error,
|
|
"rt_trips": len(S.rt),
|
|
"vehicles": S.count,
|
|
}
|
|
|
|
|
|
app.mount("/tiles", StaticFiles(directory=C.DATA_DIR / "tiles", check_dir=False), name="tiles") # PMTiles, Range-Requests
|
|
app.mount("/", StaticFiles(directory=WEB, html=True), name="web")
|