Compare commits

...
2 Commits
Author SHA1 Message Date
Claude 30d3127b21 Auf Docker umgestellt
Ein Image (pfaedle + pmtiles + Python), zwei Container per compose:
web (Port 8080) und updater (stündlich Fahrplan, beim ersten Start
Kartenkacheln). install.sh installiert Docker und startet alles.
systemd-Dienste entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 19:24:44 +02:00
Claude 88fd24c22b install.sh: Voraussetzungen prüfen (root, apt, systemd, RAM) und vollständig installieren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 15:14:25 +02:00
12 changed files with 136 additions and 119 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv
.git
data
.env
**/__pycache__
+3
View File
@@ -0,0 +1,3 @@
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT").
# Leer lassen = Karte läuft nur nach Fahrplan, ohne Verspätungen.
TRAINPULSE_RT_KEY=
+2
View File
@@ -1,3 +1,5 @@
.venv/
__pycache__/
*.pyc
data/
.env
+28
View File
@@ -0,0 +1,28 @@
# pfaedle: rechnet aus OpenStreetMap die echten Streckenverläufe
FROM debian:trixie AS pfaedle
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git cmake g++ make libzip-dev zlib1g-dev libbz2-dev \
&& git clone --depth 1 --recurse-submodules --shallow-submodules https://github.com/ad-freiburg/pfaedle /src \
&& cmake -S /src -B /build -DCMAKE_BUILD_TYPE=Release \
&& make -C /build -j"$(nproc)" pfaedle \
&& make -C /build install
# pmtiles: schneidet die Kartenkacheln für die Region aus
FROM golang:1.25-trixie AS pmtiles
RUN go install github.com/protomaps/go-pmtiles@v1.31.2
FROM python:3.13-slim-trixie
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/
COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle
COPY --from=pmtiles /go/bin/go-pmtiles /usr/local/bin/pmtiles
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY trainpulse trainpulse
COPY web web
COPY tests tests
ENV TRAINPULSE_DATA=/data PYTHONUNBUFFERED=1
EXPOSE 8080
CMD ["uvicorn", "trainpulse.server:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
+28 -18
View File
@@ -7,11 +7,11 @@ Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen K
## Wie es funktioniert
```
opentransportdata.swiss ──GTFS (Mo+Do)──▶ trainpulse-update (stündlich)
opentransportdata.swiss ──GTFS (Mo+Do)──▶ updater-Container (stündlich)
├─ Filter: nur Bahn (route_type 100–199)
OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe
└─▶ /var/lib/trainpulse/feeds/<version>/
opentransportdata.swiss ──GTFS-RT (35 s)──▶ trainpulse (Webserver, Port 8080)
└─▶ data/feeds/<version>/
opentransportdata.swiss ──GTFS-RT (35 s)──▶ web-Container (Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
```
@@ -23,33 +23,43 @@ Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
sich die Echtzeitdaten gerade beziehen (`feed_version`), und schaltet automatisch mit um.
## Installation auf Proxmox
## Installation auf Proxmox (Docker)
TrainPulse läuft als zwei Docker-Container aus demselben Image:
`web` (Webserver, Port 8080) und `updater` (holt stündlich neue Fahrpläne und beim ersten Start
die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`.
1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*):
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **4 GB RAM** (pfaedle braucht beim Aufbereiten bis ~2 GB, der Server ~0,5 GB), 20 GB Disk, Netz per DHCP.
2. **Projekt in den Container kopieren**, z. B. `scp trainpulse.zip root@<ip>:` und dort
`apt install -y unzip && unzip trainpulse.zip`.
3. **Installieren:** `bash trainpulse/deploy/install.sh`
Baut pfaedle, lädt die Kartenkacheln für die Schweiz, richtet die Dienste ein und startet
die erste Fahrplan-Aufbereitung im Hintergrund (`journalctl -fu trainpulse-update`).
4. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **4 GB RAM** (pfaedle braucht beim
Aufbereiten bis ~2 GB, der Server ~0,5 GB), **20 GB Disk**, Netz per DHCP.
Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
im LXC nicht.
2. **Installieren** (im Container als root):
```bash
apt install -y git
git clone https://git.scarriffle.com/Scarriffle/Trainpulse.git
bash Trainpulse/deploy/install.sh
```
Das Skript installiert Docker, falls es fehlt, baut das Image und startet beide Container.
3. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in
`/etc/trainpulse.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `systemctl restart trainpulse`.
`Trainpulse/.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `docker compose up -d`.
Danach ist die Karte unter `http://<ip>:8080` erreichbar. Für den Zugriff von aussen einfach
hinter deinen bestehenden Reverse Proxy hängen.
Ein Update des Programms: neue Version in den Container kopieren, `install.sh` nochmals ausführen.
**Update:** `git pull && bash deploy/install.sh` – baut das Image neu, Daten bleiben.
## Nützliche Befehle
## Nützliche Befehle (im Ordner `Trainpulse`)
| Was | Befehl |
|---|---|
| Status | `curl localhost:8080/api/status` |
| Logs Webserver | `journalctl -fu trainpulse` |
| Logs Fahrplan-Update | `journalctl -fu trainpulse-update` |
| Fahrplan sofort neu aufbereiten | `sudo -u trainpulse env $(cat /etc/trainpulse.env) /opt/trainpulse/.venv/bin/python -m trainpulse.prepare --force` |
| Selbsttest | `/opt/trainpulse/.venv/bin/python tests/test_core.py` (im Projektordner) |
| Logs Webserver | `docker compose logs -f web` |
| Logs Fahrplan-Update | `docker compose logs -f updater` |
| Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --force` |
| Selbsttest | `docker compose exec web python tests/test_core.py` |
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
## Datenquellen und Lizenzen
+21
View File
@@ -0,0 +1,21 @@
services:
web:
build: .
image: trainpulse
restart: unless-stopped
ports:
- "8080:8080"
env_file: .env
volumes:
- ./data:/data
# holt stündlich neue Fahrpläne (und beim ersten Start die Kartenkacheln)
updater:
build: .
image: trainpulse
depends_on: [web]
restart: unless-stopped
env_file: .env
volumes:
- ./data:/data
command: ["sh", "-c", "while true; do python -m trainpulse.prepare; sleep 3600; done"]
+26 -63
View File
@@ -1,79 +1,42 @@
#!/usr/bin/env bash
# TrainPulse – Installation in einem Debian-LXC (Proxmox). Im Container als root:
# TrainPulse – Installation mit Docker (z. B. in einem Debian-LXC auf Proxmox). Als root:
# bash deploy/install.sh
# Erneut ausführen = Update des Programmcodes (Daten, Karte und Einstellungen bleiben).
# Erneut ausführen = Update (Image neu bauen, Daten und Einstellungen bleiben).
set -euo pipefail
cd "$(dirname "$0")/.."
APP=/opt/trainpulse
DATA=/var/lib/trainpulse
SRC=$(cd "$(dirname "$0")/.." && pwd)
BBOX=5.8,45.7,10.6,47.9 # Schweiz mit etwas Rand
[ "$(id -u)" = 0 ] || { echo "Bitte als root ausführen" >&2; exit 1; }
command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; }
mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
[ "$mem" -ge 3500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~4 GB empfohlen" >&2
echo "==> Pakete"
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
echo "==> Docker installieren (offizielles Skript von get.docker.com)"
apt-get update -qq
apt-get install -y -qq python3-venv git cmake g++ make libzip-dev zlib1g-dev libbz2-dev curl ca-certificates >/dev/null
echo "==> Programm nach $APP"
id trainpulse &>/dev/null || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin trainpulse
mkdir -p "$APP" "$DATA/tiles"
rm -rf "$APP/trainpulse" "$APP/web"
cp -r "$SRC/trainpulse" "$SRC/web" "$SRC/requirements.txt" "$APP/"
[ -d "$APP/.venv" ] || python3 -m venv "$APP/.venv"
"$APP/.venv/bin/pip" install -q --upgrade -r "$APP/requirements.txt"
if ! command -v pfaedle >/dev/null; then
echo "==> pfaedle bauen (Streckenverläufe aus OpenStreetMap, dauert ein paar Minuten)"
tmp=$(mktemp -d)
git clone -q --depth 1 --recurse-submodules --shallow-submodules https://github.com/ad-freiburg/pfaedle "$tmp/src"
cmake -S "$tmp/src" -B "$tmp/build" -DCMAKE_BUILD_TYPE=Release >/dev/null
make -C "$tmp/build" -j"$(nproc)" >/dev/null
make -C "$tmp/build" install >/dev/null
rm -rf "$tmp"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates >/dev/null
curl -fsSL https://get.docker.com | sh
fi
if ! command -v pmtiles >/dev/null; then
echo "==> pmtiles-Werkzeug"
url=$(curl -fsSL https://api.github.com/repos/protomaps/go-pmtiles/releases/latest | python3 -c \
'import json,sys; print(next(a["browser_download_url"] for a in json.load(sys.stdin)["assets"] if a["name"].endswith("Linux_x86_64.tar.gz")))')
curl -fsSL "$url" | tar xz -C /usr/local/bin pmtiles
fi
if [ ! -s "$DATA/tiles/region.pmtiles" ]; then
echo "==> Kartenkacheln (OpenStreetMap via Protomaps) für $BBOX"
for i in 1 2 3 4 5 6 7; do
d=$(date -d "-$i day" +%Y%m%d)
if pmtiles extract "https://build.protomaps.com/$d.pmtiles" "$DATA/tiles/region.tmp.pmtiles" --bbox=$BBOX --maxzoom=15; then
mv "$DATA/tiles/region.tmp.pmtiles" "$DATA/tiles/region.pmtiles"
break
fi
done
[ -s "$DATA/tiles/region.pmtiles" ] || { echo "Kartenkacheln konnten nicht geladen werden" >&2; exit 1; }
fi
if [ ! -f /etc/trainpulse.env ]; then
cat > /etc/trainpulse.env <<EOF
TRAINPULSE_DATA=$DATA
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT")
TRAINPULSE_RT_KEY=
if ! docker run --rm hello-world >/dev/null 2>&1; then
cat >&2 <<'EOF'
Docker ist installiert, kann aber keine Container starten.
In einem Proxmox-LXC: Container herunterfahren, unter Optionen → Features
"Nesting" und "keyctl" aktivieren, Container starten, Skript nochmals ausführen.
EOF
chmod 640 /etc/trainpulse.env
chgrp trainpulse /etc/trainpulse.env
exit 1
fi
chown -R trainpulse:trainpulse "$DATA"
echo "==> Dienste"
cp "$SRC"/deploy/trainpulse.service "$SRC"/deploy/trainpulse-update.service "$SRC"/deploy/trainpulse-update.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now trainpulse-update.timer
systemctl enable trainpulse.service
systemctl restart trainpulse.service
systemctl start --no-block trainpulse-update.service
[ -f .env ] || cp .env.example .env
mkdir -p data
echo "==> Image bauen und starten (das erste Mal dauert der Bau von pfaedle ein paar Minuten)"
docker compose up -d --build
ip=$(hostname -I | awk '{print $1}')
cat <<EOF
Fertig. Die Karte läuft unter http://$ip:8080
Der erste Fahrplan wird jetzt im Hintergrund geladen und aufbereitet (ca. 10–15 min).
Fortschritt: journalctl -fu trainpulse-update
Echtzeit: API-Key in /etc/trainpulse.env eintragen, dann systemctl restart trainpulse
Der Updater lädt jetzt Kartenkacheln (~1,2 GB) und den Fahrplan und berechnet die
Streckenverläufe. Bis die ersten Züge erscheinen, dauert es ca. 15 Minuten.
Fortschritt: docker compose logs -f updater
Echtzeit: API-Key in $(pwd)/.env eintragen, dann docker compose up -d
EOF
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=TrainPulse: neuen Fahrplan holen und Streckenverläufe berechnen
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/python -m trainpulse.prepare
Nice=10
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=TrainPulse: stündlich auf neuen Fahrplan prüfen
[Timer]
OnBootSec=2min
OnCalendar=*-*-* *:20:00
Persistent=true
[Install]
WantedBy=timers.target
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=TrainPulse Webserver
After=network-online.target
Wants=network-online.target
[Service]
User=trainpulse
EnvironmentFile=/etc/trainpulse.env
WorkingDirectory=/opt/trainpulse
ExecStart=/opt/trainpulse/.venv/bin/uvicorn trainpulse.server:app --host 0.0.0.0 --port 8080 --proxy-headers
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+2
View File
@@ -13,4 +13,6 @@ RT_INTERVAL = 35 # Sekunden; Limit sind 2 Abfragen/Minute (Sliding Window)
OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf")
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
TILES_BBOX = "5.8,45.7,10.6,47.9" # Schweiz mit etwas Rand
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+20
View File
@@ -201,6 +201,24 @@ def pack_shapes(d: Path) -> None:
src.unlink()
def ensure_tiles() -> None:
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen."""
dest = C.DATA_DIR / "tiles" / "region.pmtiles"
if dest.exists() and dest.stat().st_size:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".tmp")
for days in range(1, 8): # Protomaps baut täglich, ältere Builds werden gelöscht
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
log.info("Lade Kartenkacheln vom %s …", d)
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
f"--bbox={C.TILES_BBOX}", "--maxzoom=15"])
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
tmp.rename(dest)
return
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
def publish(src: Path, meta: dict) -> None:
(src / "meta.json").write_text(json.dumps(meta))
dest = C.FEEDS_DIR / meta["feed_version"]
@@ -219,6 +237,8 @@ def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
if not args.zip:
ensure_tiles()
state_file = C.DATA_DIR / "state.json"
state = json.loads(state_file.read_text()) if state_file.exists() else {}
work = C.DATA_DIR / "work"