“Diese Daten sind nicht die, die ihr sucht.” — Bosch eBike Connect GPX-Export, 2026

Das Problem

Du fährst. Brustgurt am Start, Nyon-Display zeigt live deine Herzfrequenz. Die App rechnet brav Kalorien. Du siehst: 434 kcal verbrannt.

Dann lädst du die GPX-Datei herunter — für Home Assistant, Garmin Connect, was auch immer. Und was kommt raus?

{
  "calories": 0.0,
  "avg_heart_rate": 0.0,
  "heart_rate": [null, null, null]
}

Nichts. Bosch hat die Daten. Bosch zeigt die Daten. Bosch gibt die Daten nicht raus.

Die Ursache

Bosch speichert Herzfrequenz und Kalorien in der Cloud, aber der GPX-Export ist absichtlich kastriert:

  • Standard-GPX hat keine HR-Felder
  • Garmin TrackPoint Extensions würden HR erlauben — Bosch fügt sie nicht ein
  • Ergebnis: Lock-in zur eigenen App

Der Todesstern hortet deine Gesundheitsdaten. Zeit für einen Rebellenangriff.

Die Lösung

Statt GPX: Die interne API direkt nutzen — dieselbe, die auch die Bosch-App verwendet.

ebike-dl → Bosch API → JSON (mit allem) → Python → HA REST-Sensoren

Schritt 1: Vorbereitung in der Bosch-App

Kritisch: Standard ist AUS.

App → Einstellungen → Datenschutz → Gesundheitsdaten speichern ✅

Ohne diesen Toggle liefert die API calories: 0.0 und avg_heart_rate: 0.0 — obwohl das Nyon die Werte live angezeigt hat. Die Daten wurden einfach nicht gespeichert.

Schritt 2: ebike-dl installieren

ebike-dl ist das Tool, das die Bosch-API anzapft.

pip install ebike-dl
# oder mit pipx (empfohlen für CLI-Tools):
pipx install ebike-dl

Schritt 3: Fahrten abrufen

Credentials am besten als Umgebungsvariablen — nicht im Script:

export EBIKE_DL_LOGIN="luke.skywalker@rebellion.local"
export EBIKE_DL_PASSWORD="<DEIN-PASSWORT>"
#!/usr/bin/env python3
import subprocess
from datetime import datetime, timedelta

since_date = (datetime.now() - timedelta(days=30)).strftime("%Y/%m/%d")

subprocess.run([
    "ebike-dl", "fetch",
    "--since", since_date,
    "--out-dir", "./data",
], check=True)

Ergebnis: JSON-Dateien mit allen Daten:

{
  "id": "66660001",
  "distance": 37890,
  "calories": 427.0,
  "avg_heart_rate": 127.0,
  "max_heart_rate": 146,
  "avg_speed": 27.49,
  "driving_time": 4980,
  "start_time": "2026-02-24T15:17:04+01:00"
}

(Hinweis: JSON hat keine Kommentare — distance ist in Metern, driving_time in Sekunden.)

Schritt 4: Aggregations-Script für Home Assistant

HA REST-Sensoren brauchen eine einzige JSON-Datei, keine einzelnen Rides:

#!/usr/bin/env python3
# Benötigt Python 3.11+ (fromisoformat mit Timezone-Support)
import json
from pathlib import Path
from datetime import datetime, timezone, timedelta

data_dir = Path("./data")
rides = []

for f in data_dir.glob("ride_*.json"):
    with open(f) as fp:
        rides.append(json.load(fp))

if not rides:
    print("Keine Fahrten gefunden.")
    exit(0)

rides.sort(key=lambda x: x["start_time"], reverse=True)
last_ride = rides[0]

cutoff = datetime.now(tz=timezone.utc) - timedelta(days=30)
recent = [
    r for r in rides
    if datetime.fromisoformat(r["start_time"]) > cutoff
]

def avg_distance(lst):
    return round(sum(r["distance"] for r in lst) / len(lst) / 1000, 2) if lst else 0

stats_30d = {
    "total_km": round(sum(r["distance"] for r in recent) / 1000, 2),
    "total_kcal": int(sum(r["calories"] for r in recent)),
    "rides": len(recent),
    "avg_distance_km": avg_distance(recent),
}

stats_all = {
    "total_km": round(sum(r["distance"] for r in rides) / 1000, 2),
    "total_kcal": int(sum(r["calories"] for r in rides)),
    "rides": len(rides),
    "avg_distance_km": avg_distance(rides),
}

summary = {
    "last_ride": {
        "distance_km": round(last_ride["distance"] / 1000, 2),
        "calories": int(last_ride.get("calories", 0)),
        "avg_speed_kmh": last_ride.get("avg_speed", 0),
        "avg_heart_rate": int(last_ride.get("avg_heart_rate", 0)),
        "duration_min": round(last_ride.get("driving_time", 0) / 60, 1),
        "date": last_ride["start_time"],
    },
    "stats_30d": stats_30d,
    "stats_all_time": stats_all,
    "last_updated": datetime.now().isoformat(),
}

with open("/config/www/ebike_summary.json", "w") as f:
    json.dump(summary, f, indent=2)

print("✅ ebike_summary.json aktualisiert")

Die JSON landet in /config/www/ — damit stellt Home Assistant sie unter /local/ebike_summary.json bereit. Das Script muss auf dem HA-Host laufen (oder im Container, falls HA in Docker läuft).

Schritt 5: REST-Sensoren in Home Assistant

rest:
  - resource: http://localhost:8123/local/ebike_summary.json
    scan_interval: 300
    sensor:
      - name: "eBike Letzte Fahrt Distanz"
        unique_id: ebike_last_ride_distance
        value_template: "{{ value_json.last_ride.distance_km | float(0) }}"
        unit_of_measurement: "km"
        icon: mdi:bike
        device_class: distance
        state_class: measurement

      - name: "eBike Letzte Fahrt Kalorien"
        unique_id: ebike_last_ride_calories
        value_template: "{{ value_json.last_ride.calories | int(0) }}"
        unit_of_measurement: "kcal"
        icon: mdi:fire
        state_class: measurement

      - name: "eBike Letzte Fahrt Herzfrequenz"
        unique_id: ebike_last_ride_heart_rate
        value_template: "{{ value_json.last_ride.avg_heart_rate | int(0) }}"
        unit_of_measurement: "bpm"
        icon: mdi:heart-pulse
        state_class: measurement

      - name: "eBike 30d Total km"
        unique_id: ebike_30d_total_km
        value_template: "{{ value_json.stats_30d.total_km | float(0) }}"
        unit_of_measurement: "km"
        icon: mdi:bike
        state_class: total_increasing

      - name: "eBike 30d Kalorien"
        unique_id: ebike_30d_total_kcal
        value_template: "{{ value_json.stats_30d.total_kcal | int(0) }}"
        unit_of_measurement: "kcal"
        icon: mdi:fire
        state_class: total_increasing

      - name: "eBike All-Time km"
        unique_id: ebike_alltime_total_km
        value_template: "{{ value_json.stats_all_time.total_km | float(0) }}"
        unit_of_measurement: "km"
        icon: mdi:bike
        state_class: total_increasing

      - name: "eBike All-Time Kalorien"
        unique_id: ebike_alltime_total_kcal
        value_template: "{{ value_json.stats_all_time.total_kcal | int(0) }}"
        unit_of_measurement: "kcal"
        icon: mdi:fire
        state_class: total_increasing

Wichtig: Die Entity-IDs, die HA tatsächlich erstellt, basieren auf dem name-Feld, nicht auf unique_id. Nachschlagen unter Developer Tools → States, nicht raten.

sensor.ebike_letzte_fahrt_distanz   ← deutsch, nicht unique_id!
sensor.ebike_letzte_fahrt_kalorien

Schritt 6: Dashboard

type: entities
title: eBike Stats
entities:
  - entity: sensor.ebike_letzte_fahrt_distanz
    name: Letzte Fahrt
  - entity: sensor.ebike_letzte_fahrt_kalorien
    name: Kalorien
  - entity: sensor.ebike_letzte_fahrt_herzfrequenz
    name: Ø Herzfrequenz
  - type: divider
  - entity: sensor.ebike_30d_total_km
    name: 30 Tage km
  - entity: sensor.ebike_30d_kalorien
    name: 30 Tage Kalorien

HR als Gauge:

type: gauge
entity: sensor.ebike_letzte_fahrt_herzfrequenz
name: Herzfrequenz
min: 0
max: 200
severity:
  green: 60
  yellow: 120
  red: 160

Schritt 7: Automatisches Update per Cron

# /usr/local/bin/ebike-update.sh
#!/bin/bash
set -e
cd /pfad/zum/script
python3 fetch_rides.py      # holt neue Rides von Bosch API
python3 build_summary.py    # schreibt /config/www/ebike_summary.json

Crontab (crontab -e), zweimal täglich nach typischen Fahrtzeiten:

0 9  * * *  /usr/local/bin/ebike-update.sh
0 18 * * *  /usr/local/bin/ebike-update.sh

Lessons Learned

Bosch blockt Datacenter-IPs

Falls ebike-dl mit “Invalid credentials” fehlschlägt obwohl Login stimmt: Bosch blockt manchmal Server-IPs. Workaround: Cookie-basierter Login — im Browser einloggen, REMEMBER-Cookie aus DevTools → Application → Cookies kopieren und im Script nutzen statt Username/Password.

“Gesundheitsdaten speichern” ist der entscheidende Toggle

Standard: AUS. Das Nyon zeigt die HR live, die App zeigt Kalorien — aber ohne diesen Toggle speichert Bosch nichts davon. Die API liefert dann sauber Nullen. Erster Schritt bei jedem Setup.

HA Entity-IDs: Deutsch schlägt unique_id

Home Assistant leitet den Entity-Identifier aus dem name-Feld ab. Wer englische unique_ids und deutsche name-Felder mixt, sucht im Dashboard dann nach sensor.ebike_letzte_fahrt_distanz — nicht nach sensor.ebike_last_ride_distance. Developer Tools → States ist dein Freund.

Verifikation

# JSON prüfen
curl -s http://localhost:8123/local/ebike_summary.json | python3 -m json.tool | head -20

# Spezifisch letzte Fahrt
curl -s http://localhost:8123/local/ebike_summary.json | python3 -c "
import json,sys; d=json.load(sys.stdin)
r=d['last_ride']
print(f\"{r['date'][:10]}: {r['distance_km']} km, {r['calories']} kcal, {r['avg_heart_rate']} bpm\")
"

# HA Logs bei REST-Problemen
grep -i 'rest\|ebike' /config/home-assistant.log | tail -20

Referenzen