TrailBridge BLE-Protokoll v1¶
BLE-Rollen¶
- Companion-App (Handy) = Peripheral / GATT-Server
- TRGB-BikeComputer (ESP32) = Central / GATT-Client (wie bisher bei Komoot
-- das bestehende Verbindungsmanagement in
BLEDevices.cppbleibt also in seiner Grundstruktur erhalten, nur der Parser ändert sich, siehe Meilenstein 3)
UUIDs¶
- Service:
f7ac2b76-986b-45fd-8e44-f116a61f319d - Characteristic:
7473da02-2de8-4f48-9e46-21b36380c176Properties:INDICATE,READ
Frisch generiert, keine Wiederverwendung von Komoots alten UUIDs -- neues Protokoll, neue Kennung.
Übertragungsart¶
- Primär: Indicate (mit Bestätigung) auf obiger Characteristic. Der ESP32
aktiviert das durch Schreiben von
0x0200auf den Standard-CCCD-Descriptor (00002902-0000-1000-8000-00805f9b34fb) -- genau die Stelle, die im alten Code auskommentiert war (pRemoteCharacteristic->getDescriptor(...)). - Fallback: Read. Liefert den zuletzt gesendeten Frame, z.B. direkt nach Verbindungsaufbau, bevor die erste Indicate eintrifft, oder falls Indicate auf einem ESP32-BLE-Stack mal zickt.
- Heartbeat: Die Companion-App sendet den zuletzt berechneten Frame auch ohne inhaltliche Änderung alle 5s erneut, damit die Firmware eine tote Verbindung von "keine Änderung, weil gerade an der Ampel" unterscheiden kann.
Frame-Format¶
Byte 0: Protokoll-Version (aktuell 1)
Byte 1: Message-Type
Byte 2..: TLV-Einträge (nur bei NAV_UPDATE)
Message-Types:
| Wert | Name | Bedeutung |
|---|---|---|
| 0x00 | HELLO | Lebenszeichen/Versions-Check, keine weiteren Daten |
| 0x01 | NAV_UPDATE | aktive Navigation, gefolgt von TLV-Einträgen |
| 0x02 | NAV_NONE | keine aktive Route, keine weiteren Daten |
TLV-Eintrag: Tag (1 Byte) | Länge N (1 Byte) | Wert (N Byte)
| Tag | Name | Länge | Format | Bedingung |
|---|---|---|---|---|
| 0x01 | MANEUVER | 1 | Manöver-Code (Tabelle unten) | immer |
| 0x02 | MANEUVER_DISTANCE_M | 4 | uint32 LE, Meter | immer |
| 0x03 | ROUNDABOUT_EXIT | 1 | Ausfahrtsnummer, 1-basiert | nur wenn MANEUVER == ROUNDABOUT |
| 0x04 | STREET_NAME | ≤48 | UTF-8, kein Nullterminator | immer (kann leer sein) |
| 0x05 | NEXT_MANEUVER | 1 | Manöver-Code | nur wenn übernächstes Manöver bekannt |
| 0x06 | NEXT_MANEUVER_DISTANCE_M | 4 | uint32 LE, Meter (ab dem nächsten Manöver gerechnet) | wie 0x05 |
| 0x07 | NEXT_STREET_NAME | ≤48 | UTF-8 | wie 0x05 |
| 0x08 | REMAINING_DISTANCE_M | 4 | uint32 LE, Meter bis Ziel | immer |
| 0x09 | REMAINING_TIME_S | 4 | uint32 LE, Sekunden bis Ziel | immer |
| 0x0A | LANES | N (Vielfaches von 4) | Fahrspurliste, siehe unten | nur wenn OsmAnd Fahrspurdaten liefert (turn_lanes, selten auf reinen Radwegen) |
| 0x0B | NEXT_LANES | N (Vielfaches von 4) | wie 0x0A | wie 0x0A, aber für die übernächste Abbiegung (wie 0x05/0x06/0x07) |
| 0x0C | LANE_DISTANCE_M | 4 | uint32 LE, Meter bis zu dem Punkt, an dem die LANES-Angabe gilt | nur wenn Tag 0x0A vorhanden |
| 0x0D | NEXT_LANE_DISTANCE_M | 4 | uint32 LE, Meter | nur wenn Tag 0x0B vorhanden |
| 0x0E | OVERVIEW_REVISION | 1 | uint8, 1..255 | nur bei Navigation aus einer GPX-Route: Revision der Streckenübersicht, zu der dieser Frame gehört, siehe "Streckenübersicht-Service" |
Unbekannte Tags muss die Firmware überspringen (Länge respektieren, Wert ignorieren) -- so bleibt Raum für spätere Erweiterungen, ohne dass ältere Firmware daran zerbricht. Das ist der ganze Sinn von TLV statt eines starren Byte-Layouts wie bei Komoot.
Manöver-Codes¶
Eigene, feste Codes -- unabhängig von Komoots altem 32-Slot-Icon-Index und
von OsmAnds internen TurnType-Konstanten. Referenz-Implementierung:
Maneuver.java.
| Code | Name |
|---|---|
| 0 | NONE |
| 1 | DEPART |
| 2 | ARRIVE |
| 3 | STRAIGHT |
| 4 | TURN_SLIGHT_LEFT |
| 5 | TURN_LEFT |
| 6 | TURN_SHARP_LEFT |
| 7 | TURN_SLIGHT_RIGHT |
| 8 | TURN_RIGHT |
| 9 | TURN_SHARP_RIGHT |
| 10 | KEEP_LEFT |
| 11 | KEEP_RIGHT |
| 12 | UTURN_LEFT |
| 13 | UTURN_RIGHT |
| 14 | ROUNDABOUT (Ausfahrt in Tag 0x03) |
| 255 | UNKNOWN |
Fahrspur-Informationen (LANES / NEXT_LANES)¶
Bildet OsmAnds Fahrspur-Führung ab (AIDL-turnInfo-Bundle-Keys
next_turn_lanes / after_nextturn_lanes, gespeist aus dem OSM-Tag
turn:lanes; verifiziert gegen ExternalApiHelper#updateRouteDirectionInfo
und net.osmand.router.TurnType im OsmAnd-Quellcode, Stand 2026-09-18).
OsmAnd liefert das als int[], ein bit-gepacktes Int pro Fahrspur (Bit 0 =
aktiv/empfohlen, Bits 1-4 = primäre Richtung, weitere Bits sekundär/
tertiär). Wie bei den Manöver-Codes übernehmen wir dieses Bit-Layout nicht
1:1, sondern übersetzen jede Richtung in unsere eigene Manöver-Code-Tabelle
(oben) -- damit hängt die Firmware nicht an OsmAnds internen
TurnType-Konstanten.
Wert von Tag 0x0A/0x0B: eine Liste von Fahrspuren, von links nach rechts (wie im OSM-Tagging und in OsmAnds Array-Reihenfolge), je 4 Byte:
| Byte | Bedeutung |
|---|---|
| 0 | primärer Manöver-Code -- Haupt-Pfeilrichtung dieser Spur |
| 1 | sekundärer Manöver-Code, 0 (NONE) = keine zweite Richtung |
| 2 | tertiärer Manöver-Code, 0 (NONE) = keine dritte Richtung |
| 3 | Flags -- Bit 0 = ACTIVE (von OsmAnds Router für die berechnete Route empfohlen), Bits 1-7 reserviert (aktuell 0, von der Firmware zu ignorieren) |
Byte 0 ist nie 0: Eine Spur ohne explizit gesetzte primäre Richtung
behandelt OsmAnd intern bereits als "geradeaus" (siehe
TurnType.lanesToString()), die App bildet diesen Fallback schon auf
STRAIGHT (Code 3) ab, bevor sie den Frame baut -- die Firmware muss 0 in
Byte 0 also nicht gesondert behandeln.
Länge N = Anzahl Fahrspuren × 4 (das TLV-Längenbyte begrenzt das auf maximal 63 Fahrspuren -- reale Straßen haben selten mehr als 6-8).
Die reservierten Flag-Bits lassen Raum für eine mögliche spätere Erweiterung um fahrradspezifisches Spur-Tagging oder weitere Spureigenschaften, ohne die Byte-Breite pro Spur nachträglich ändern zu müssen.
Distanz (LANE_DISTANCE_M / NEXT_LANE_DISTANCE_M)¶
Der Punkt, an dem eine Fahrspurwahl relevant wird (z.B. wo sich eine Spur auffächert), muss NICHT mit dem Punkt der eigentlichen Abbiege-Anweisung zusammenfallen -- auf mehrspurigen Straßen liegt er typischerweise deutlich davor. LANES/NEXT_LANES tragen deshalb eine eigene, von MANEUVER_DISTANCE_M/NEXT_MANEUVER_DISTANCE_M unabhängige Distanz (Tag 0x0C/0x0D) -- die Firmware darf die beiden Distanzen nicht gleichsetzen.
Aktueller Stand auf der App-Seite (Stand 2026-09-19): OsmAnds AIDL
hängt next_turn_lanes/after_nextturn_lanes an dieselbe
RouteDirectionInfo/NextDirectionInfo wie die zugehörige
Abbiege-Distanz -- über die AIDL-API sind beide Distanzen also aktuell
tatsächlich identisch. ExternalApiHelper#getRouteDirectionsInfo hat
zwar einen zweiten Mechanismus (no_speak_next_-Bundle-Präfix), der dem
Namen nach genau für einen früheren, nicht angesagten Fahrspur-Punkt
gedacht ist -- der Rückgabewert des zugehörigen
getNextRouteDirectionInfo(..., false)-Aufrufs wird dort aber verworfen
und stattdessen das schon belegte (veraltete) ni von after_next
wiederverwendet:
routingHelper.getNextRouteDirectionInfo(new NextDirectionInfo(), false);
if (ni.distanceTo > 0) {
updateTurnInfo("no_speak_next_", bundle, ni);
}
no_speak_next_* ist damit im aktuellen OsmAnd-Master (verifiziert
2026-09-19, OsmAnd/src/net/osmand/plus/helpers/ExternalApiHelper.java)
faktisch ein Duplikat von after_next* statt eines eigenen früheren
Punkts -- nutzbar ist es so nicht. Bis das upstream behoben ist (oder ein
anderer Weg gefunden wird), sendet die App für
LANE_DISTANCE_M/NEXT_LANE_DISTANCE_M denselben Wert wie
MANEUVER_DISTANCE_M/NEXT_MANEUVER_DISTANCE_M. Die eigenständigen Tags
bleiben trotzdem bestehen, damit sich das später nachrüsten lässt, ohne
den Wire-Vertrag zu brechen.
Beispiel¶
Zwei Fahrspuren, 45 m voraus (die Spurwahl wird hier schon relevant, obwohl die eigentliche Abbiegung laut MANEUVER_DISTANCE_M erst in 300 m kommt): linke Spur nur geradeaus, rechte Spur geradeaus-oder-rechts und vom Router für die Route empfohlen:
0A 08 LANES, Länge 8 (2 Spuren)
03 00 00 00 Spur 1: STRAIGHT, --, --, Flags=0
03 08 00 01 Spur 2: STRAIGHT, TURN_RIGHT, --, Flags=ACTIVE
0C 04 2D 00 00 00 LANE_DISTANCE_M = 45
Beispiel¶
"In 120 m links abbiegen auf die Bahnhofstraße, danach in 300 m rechts halten, noch 4200 m / 600 s bis Ziel":
01 01 version=1, type=NAV_UPDATE
01 01 05 MANEUVER = TURN_LEFT (5)
02 04 78 00 00 00 MANEUVER_DISTANCE_M = 120
04 0E 42 61 68 6E 68 6F 66 73 74 72 61 C3 9F 65 STREET_NAME = "Bahnhofstraße"
05 01 0B NEXT_MANEUVER = KEEP_RIGHT (11)
06 04 2C 01 00 00 NEXT_MANEUVER_DISTANCE_M = 300
08 04 68 10 00 00 REMAINING_DISTANCE_M = 4200
09 04 58 02 00 00 REMAINING_TIME_S = 600
37 Bytes gesamt -- passt mit großem Puffer in die per setMTU(256)
verhandelte ATT-MTU von 256 Byte (253 Byte nutzbar).
GPS-Positions-Service¶
Eigener BLE-Service, unabhängig vom Navigations-Service oben. Die Positionsdaten
kommen direkt vom GPS-Chip des Handys (LocationManager.GPS_PROVIDER), nicht
von OsmAnd -- funktioniert also auch, wenn OsmAnd gar nicht läuft oder nicht
installiert ist.
UUIDs¶
- Service:
66b5835c-9be6-43d1-b24a-f9337c0fcb7f - Characteristic:
10c49e7b-4808-4d63-9b68-9ba6c385db0dProperties:INDICATE,READ
Wird bewusst NICHT im Advertising-Paket beworben (31-Byte-Legacy-Limit --
siehe Kommentar in BikeComputerGattServer, der Nav-Service allein füllt das
Hauptpaket schon aus). Der BikeComputer findet ihn nach dem Verbindungsaufbau
über normale GATT-Service-Discovery, genau wie er auch ohne Advertising-Eintrag
z.B. das Battery-Service der anderen Sensoren findet.
Übertragungsart¶
Wie beim Navigations-Service: Indicate primär, Read als Fallback, Heartbeat alle 5s -- unabhängig vom Navigations-Heartbeat/-State getaktet.
Wichtig für eine Firmware-Neuimplementierung: Beide Services teilen sich
einen einzigen BluetoothGattServer-Prozess auf App-Seite. Die Android- (und
allgemein BLE-)Plattformvorgabe erlaubt pro verbundenem Gerät nur eine
Indicate/Notify gleichzeitig "in flight", auch über Characteristics/Services
hinweg -- onNotificationSent() sagt nicht, welche Characteristic gerade
fertig wurde. BikeComputerGattServer serialisiert Nav- und
Positions-Frames deshalb über ein gemeinsames In-Flight-Gate. Für den ESP32
als Central spielt das keine Rolle (der empfängt einfach zwei unabhängige
Indicate-Streams), nur relevant, falls die App-Seite mal neu gebaut wird.
Frame-Format¶
Byte 0: Protokoll-Version (aktuell 1)
Byte 1: Message-Type
Byte 2..: TLV-Einträge (nur bei POSITION_UPDATE)
Message-Types:
| Wert | Name | Bedeutung |
|---|---|---|
| 0x00 | HELLO | Lebenszeichen/Versions-Check, keine weiteren Daten |
| 0x01 | POSITION_UPDATE | gültiger GPS-Fix, gefolgt von TLV-Einträgen |
| 0x02 | POSITION_NONE | (noch) kein Fix -- GPS aus, Berechtigung fehlt, oder noch kein Satellitenempfang |
TLV-Eintrag: Tag (1 Byte) | Länge N (1 Byte) | Wert (N Byte)
| Tag | Name | Länge | Format | Bedingung |
|---|---|---|---|---|
| 0x01 | LATITUDE_E7 | 4 | int32 LE, Breitengrad × 1e7 | immer |
| 0x02 | LONGITUDE_E7 | 4 | int32 LE, Längengrad × 1e7 | immer |
| 0x03 | ALTITUDE_M | 4 | int32 LE, Meter über Ellipsoid | nur wenn der Fix eine Höhe liefert |
| 0x04 | SPEED_CMS | 4 | uint32 LE, cm/s | nur wenn der Fix eine Geschwindigkeit liefert |
| 0x05 | BEARING_DEG_X100 | 2 | uint16 LE, Grad × 100 (0..35999) | nur wenn der Fix einen Kurs liefert |
| 0x06 | ACCURACY_M_X10 | 2 | uint16 LE, Meter × 10 (horizontale Genauigkeit) | nur wenn der Fix eine Genauigkeit liefert |
| 0x07 | FIX_AGE_MS | 4 | uint32 LE, Millisekunden seit diesem Fix | immer |
| 0x08 | UTC_TIME_MS | 8 | uint64 LE, UTC-Zeit des Fixes in ms seit 1970-01-01 (Location.getTime()) |
wenn der Fix eine Zeit hat (> 0) |
| 0x09 | HEART_RATE_BPM | 1 | uint8, Schläge/min | nur wenn TrailBridge einen Puls kennt, siehe "Sensorwerte und Simulationsmodus" |
| 0x0A | CADENCE_RPM | 1 | uint8, Umdrehungen/min, 0 = rollen | wie 0x09, für die Trittfrequenz |
| 0x0B | SIM_FLAGS | 1 | uint8-Bitfeld, siehe unten | nur wenn etwas an diesem Frame simuliert ist (sonst fehlt der Tag) |
| 0x0C | BARO_HEIGHT_DM | 4 | int32 LE, Höhe in Dezimetern über NHN | nur wenn TrailBridge eine Barometer-Höhe kennt, siehe unten |
| 0x0D | POWER_W | 2 | uint16 LE, Watt (0 = rollen) | nur wenn TrailBridge eine Leistung kennt, siehe unten |
| 0x0E | MSL_ALTITUDE_DM | 4 | int32 LE, Höhe in Dezimetern über dem Meeresspiegel (Geoid, NHN) | nur bei einem echten Fix, dessen Höhe das Handy über NHN kennt (Android 14+), siehe unten |
E7-Fixpunkt für Lat/Lon (statt Gleitkomma) aus demselben Grund wie überall sonst im TLV-Format: festes, plattformunabhängiges Byte-Layout, kein Float-Endianness-/NaN-Ärger. ±90°/±180° × 1e7 passen beide bequem in ein signed int32 (max. ±2.147 Mrd.).
FIX_AGE_MS erlaubt der Firmware zu erkennen, ob der letzte Fix frisch ist oder
nur der Heartbeat einen alten Stand wiederholt (Tunnel, Satellitenausfall
o.ä.) -- wird bei jedem Senden aus SystemClock.elapsedRealtime() -
location.getElapsedRealtimeNanos()/1_000_000 neu berechnet, ist also auch
bei Heartbeat-Resends korrekt und nicht auf den ursprünglichen Fix-Zeitpunkt
eingefroren.
UTC_TIME_MS ist die Zeitquelle für die Uhr des BikeComputers, wenn kein WLAN
(NTP) da ist. Bei GPS_PROVIDER ist das die Satellitenzeit, also unabhängig
von der Handy-Uhr. Die aktuelle Zeit beim Senden ist
UTC_TIME_MS + FIX_AGE_MS -- beide Werte kommen aus demselben Frame, der
Empfänger muss also nicht wissen, wann der Fix entstand. Die BLE-Latenz
(typisch < 100 ms) bleibt unberücksichtigt. TrailBridge sendet die GPS-Zeit nur, wenn sie
zur Uhr des Handys passt (±5 s, GpsTime), sonst die Handy-Zeit (als Zeit des Fixes: Handy-Jetzt
minus FIX_AGE_MS): der GNSS-Chip kann gleich nach dem Einschalten eine Zeit liefern, die
Minuten bis Stunden danebenliegt (Testfahrt 2026-10-04: 14,8 h zurück, bei richtiger Position).
Die Firmware prüft das Ergebnis noch einmal gegen ihre eigene Uhr (ClockSync). Nachträglich ergänzt, ohne
Versionssprung: Firmware, die den Tag nicht kennt, überspringt ihn über die
Länge; eine App ohne den Tag stellt eben keine Uhr.
Beispiel¶
Fix bei 52.5163° N, 13.3777° O, 34 m Höhe, 4,2 m/s, Kurs 87,5°, ±5 m Genauigkeit, vor 320 ms:
01 01 version=1, type=POSITION_UPDATE
01 04 F8 59 4D 1F LATITUDE_E7 = 525163000
02 04 68 46 F9 07 LONGITUDE_E7 = 133777000
03 04 22 00 00 00 ALTITUDE_M = 34
04 04 A4 01 00 00 SPEED_CMS = 420
05 02 2E 22 BEARING_DEG_X100 = 8750
06 02 32 00 ACCURACY_M_X10 = 50
07 04 40 01 00 00 FIX_AGE_MS = 320
40 Bytes gesamt (mit UTC_TIME_MS 50) -- passt locker in die ausgehandelte ATT-MTU.
Höhenprofil-Service¶
Dritter, unabhängiger BLE-Service, nur aktiv, solange TrailBridge eine
importierte GPX-Route abspielt (siehe README) und die Route Höhendaten hat.
Das Profil wird immer gesendet -- auch auf flacher Strecke und bergab, es ist
immer von Interesse (Testfahrt 2026-10-04) --, als rollendes Fenster der
Strecke voraus, von der Position des Fahrers an. Ein Anstieg darin wird
nicht von der Firmware aus den Punkten erraten, sondern von TrailBridge
angesagt, mit seiner ganzen Ausdehnung (Tags 0x07..0x0A, siehe unten): so
endet er nie vor dem Gipfel, und Kategorie und Länge bleiben die vom Fuß,
auch wenn der Fahrer schon mittendrin ist und der Fuß nicht mehr im Frame liegt.
Das Flag ROLLING (Tag 0x06) kennzeichnet solche Frames.
Ein neuer Frame geht raus,
- zu Beginn und nachdem das Profil zurückgenommen wurde (Fahrer abseits der Route),
- wenn sich der anzusagende Anstieg ändert: sein Fuß kommt auf höchstens 500 m heran (das Fenster reicht dann vom Fahrer bis zum Gipfel), oder sein Gipfel ist erreicht (das Fenster ohne Ansage, die Strecke hinter dem Gipfel),
- wenn der Fahrer die Mitte eines Frames passiert hat, der nicht so weit reicht, wie er sollte: das Fenster wandert weiter (Fenster ohne Anstieg: 200 Schritte à 25 m = 5 km; ein Anstieg, der selbst auf dem gröbsten Raster nicht in einen Frame passt).
Dazwischen kommt nichts. PROFILE_NONE nur noch, wenn die Route endet oder der
Fahrer sie verlässt -- nicht mehr am Gipfel: dort fehlt einfach die Ansage im nächsten
Frame, und die Firmware weiß, dass der Anstieg vorbei ist. Kein Heartbeat -- der
zuletzt gesendete Frame bleibt per Read abrufbar und geht an neu abonnierende Geräte.
Ältere TrailBridge-Versionen (ohne Flag) senden nur das Profil eines Anstiegs, von der
Position bis zum Gipfel, und PROFILE_NONE am Gipfel; die Firmware findet den Anstieg
dann selbst im Profil (siehe unten) und bleibt damit kompatibel.
Was ein Anstieg ist¶
TrailBridge sucht die Anstiege beim Laden der Route (25-m-Raster, über ca.
125 m geglättet; ElevationProfile.findClimbs):
- Fuß: der erste Punkt, ab dem die Route auf den nächsten 100 m um mindestens 3 m steigt. Ein sanfter Beginn gehört also schon zum Anstieg.
- Gipfel: der höchste Punkt, bevor die Route entweder um mehr als 30 m abfällt oder über 2 km nicht weiter steigt (im Mittel weniger als 0,5 % ab dem bisher höchsten Punkt) -- oder endet. Eine kürzere Senke oder ein kürzeres Flachstück gehört zum Anstieg.
- Unter 10 m Höhenunterschied zwischen Fuß und Gipfel ist es kein Anstieg.
Beispiel Galibier: Von Süden (36 km über den Lautaret, mit Flachstücken) ist es ein Anstieg. Von Norden sind es zwei -- nach dem Col du Télégraphe geht es knapp 5 km und rund 165 m bergab nach Valloire.
Nur für Frames ohne ROLLING (ältere TrailBridge-Versionen) findet die Firmware
(ClimbProfile.cpp) Fuß und Gipfel im gesendeten Profil mit denselben Kriterien; das
Profilende gilt ihr dann als Gipfel. Bei ROLLING gilt allein die Ansage; die
Kriterien dieses Abschnitts wendet TrailBridge an.
UUIDs¶
- Service:
3c1f6a90-5b2e-4d7a-9c48-e0a1b7d25f63 - Characteristic:
a84e0d17-6f3b-4c52-8e9d-1b70c2f4a596Properties:INDICATE,READ
Wie der Position-Service nicht im Advertising, Discovery nach dem Verbindungsaufbau. Teilt sich das In-Flight-Gate mit den anderen Services (siehe oben).
Frame-Format¶
Byte 0: Protokoll-Version (aktuell 1)
Byte 1: Message-Type
Byte 2..: TLV-Einträge (nur bei PROFILE_UPDATE)
| Wert | Name | Bedeutung |
|---|---|---|
| 0x00 | HELLO | Zustand nach Verbindungsaufbau, kein Profil |
| 0x01 | PROFILE_UPDATE | Profil, gefolgt von TLV-Einträgen; ersetzt ein früheres |
| 0x02 | PROFILE_NONE | kein (Steigungs-)Profil mehr anzeigen |
| Tag | Name | Länge | Format |
|---|---|---|---|
| 0x01 | START_REMAINING_DISTANCE_M | 4 | uint32 LE, Restdistanz der Route bis zum Ziel am ersten Profilpunkt |
| 0x02 | STEP_M | 1 | Abstand der Profilpunkte in Metern: 25, 50, ... 250 (siehe "Raster") |
| 0x03 | BASE_ALT_DM | 2 | int16 LE, Höhe des ersten Punkts in Dezimetern |
| 0x04 | DELTAS_DM | N | N × int8: Höhenänderung von Punkt k zu Punkt k+1, in Einheiten von DELTA_SCALE_DM dm |
| 0x05 | DELTA_SCALE_DM | 1 | uint8, optional: Einheit der Deltas in Dezimetern. Fehlt der Tag, gilt 1 |
| 0x06 | FLAGS | 1 | uint8, optional: Bit 0 ROLLING = rollendes Fenster der Strecke voraus; ein Anstieg ist nur der in 0x07..0x0A angesagte. Fehlt der Tag: Frame eines älteren TrailBridge (Profil bis zum Gipfel) |
| 0x07 | CLIMB_FOOT_REMAINING_M | 4 | uint32 LE, Restdistanz der Route bis zum Ziel am Fuß des angesagten Anstiegs (kann hinter dem ersten Punkt liegen, also größer als START_REMAINING_DISTANCE_M) |
| 0x08 | CLIMB_SUMMIT_REMAINING_M | 4 | uint32 LE, Restdistanz am Gipfel (kann hinter dem letzten Punkt liegen) |
| 0x09 | CLIMB_FOOT_ALT_DM | 2 | int16 LE, Höhe des Fußes in Dezimetern |
| 0x0A | CLIMB_SUMMIT_ALT_DM | 2 | int16 LE, Höhe des Gipfels in Dezimetern |
Die vier Anstiegs-Tags kommen alle zusammen oder gar nicht; fehlen sie bei gesetztem
ROLLING, liegt kein Anstieg an (weder unter dem Fahrer noch höchstens 500 m voraus).
Angesagt wird der Anstieg, auf dem der Fahrer ist oder dessen Fuß höchstens 500 m voraus
liegt, bis der Gipfel erreicht ist (auf eine halbe Rasterstufe genau). Wiederholte Frames
desselben Anstiegs tragen dieselben Werte -- die Firmware erkennt ihn daran wieder und
hält Kategorie und Anzeige stabil. Kategorie = Länge × mittlere Steigung des ganzen
Anstiegs (Fuß bis Gipfel), nicht des Rests.
Punkt k liegt bei START_REMAINING_DISTANCE_M - k × STEP_M Restdistanz,
seine Höhe ist BASE_ALT_DM + DELTA_SCALE_DM × Σ DELTAS_DM[0..k-1] (in dm).
Es gibt N+1 Punkte. Die Höhen sind über ca. 125 m geglättet (GPX-Höhen sind
verrauscht).
Raster: Der ganze Rest des Anstiegs soll in einen Frame passen. Bis
N × 25 m (5 km bei N = 200) ist STEP_M 25; für längere Anstiege wird es das
kleinste Vielfache von 25 m, mit dem es passt, höchstens 250 m (50 km bei
N = 200; ein 36-km-Anstieg kommt mit 200 m). Das Raster wird vom Gipfel aus
rückwärts gelegt: Der letzte Punkt ist der Gipfel, der erste liegt an der
Position des Fahrers oder weniger als einen Schritt dahinter (nur direkt am
Routenanfang kann er knapp vor dem Fahrer liegen).
Auf einem groben Raster passt ein steiler Schritt nicht mehr als dm in ein
int8 (±12,7 m; bei 200 m wären das 6,35 %). Dann sendet TrailBridge
DELTA_SCALE_DM mit dem kleinsten Wert, bei dem alle Deltas passen (z. B. 2 =
Deltas in 0,2 m). Bei STEP_M = 25 fehlt der Tag immer; Frames für Anstiege
bis 5 km sehen also aus wie bisher.
Passt ein Anstieg selbst mit 250 m nicht in einen Frame (kleine MTU, oder
über 50 km), endet das Profil nach N Schritten vor dem Gipfel. Das nächste
Stück folgt, wenn der Fahrer die Mitte des gesendeten erreicht hat -- auf
demselben Raster (gleiches STEP_M, Abstand der Startpunkte ein Vielfaches
davon), damit die Firmware die Stücke zusammenfügen kann.
Position im Profil ohne Wegstreckenzähler: Die Firmware kennt
REMAINING_DISTANCE_M aus dem Nav-Frame (0x08); die Position des Fahrers im
Profil ist START_REMAINING_DISTANCE_M - REMAINING_DISTANCE_M Meter ab dem
ersten Punkt. Das gilt entlang der Route, nicht der Luftlinie, und übersteht
Reconnects. Liegt der Wert außerhalb 0..N × STEP_M, ist das Profil
veraltet bzw. noch nicht erreicht.
Länge und MTU: N richtet sich nach der ausgehandelten MTU (Payload
ATT_MTU - 3): N = min(200, Payload - 20) (bei ROLLING zusätzlich 23 Byte für Flag und Anstiegs-Tags: Payload - 43); unter 8 Punkten (200 m) sendet
TrailBridge kein Profil. Die App nimmt das Minimum über alle verbundenen
Geräte und geht ohne Angabe von den 256 aus, die die Firmware anfordert.
Unbekannte Tags überspringt die Firmware wie überall (Länge respektieren).
Beispiel¶
Profil ab 4200 m Restdistanz, Start auf 34,5 m, drei Schritte (+1,2 m,
+1,3 m, −0,2 m) -- ältere Form, ohne ROLLING:
01 01 version=1, type=PROFILE_UPDATE
01 04 68 10 00 00 START_REMAINING_DISTANCE_M = 4200
02 01 19 STEP_M = 25
03 02 59 01 BASE_ALT_DM = 345
04 03 0C 0D FE DELTAS_DM = +12, +13, -2
Rollendes Fenster mit angesagtem Anstieg: Fuß bei 5000 m Restdistanz auf 100,0 m, Gipfel bei 3500 m auf 136,0 m (die Punkte dazwischen wie oben):
01 01 version=1, type=PROFILE_UPDATE
01 04 68 10 00 00 START_REMAINING_DISTANCE_M = 4200
02 01 19 STEP_M = 25
03 02 59 01 BASE_ALT_DM = 345
06 01 01 FLAGS = ROLLING
07 04 88 13 00 00 CLIMB_FOOT_REMAINING_M = 5000 (hinter dem ersten Punkt)
08 04 AC 0D 00 00 CLIMB_SUMMIT_REMAINING_M = 3500
09 02 E8 03 CLIMB_FOOT_ALT_DM = 1000
0A 02 50 05 CLIMB_SUMMIT_ALT_DM = 1360
04 03 0C 0D FE DELTAS_DM = +12, +13, -2
Ohne Anstieg: derselbe Frame ohne die vier Tags 0x07..0x0A.
Langer Anstieg auf grobem Raster: ab 36 000 m Restdistanz, Start auf 1200 m, 200-m-Schritte, Deltas in 0,2 m (+12,0 m, +13,8 m, −0,6 m):
01 01 version=1, type=PROFILE_UPDATE
01 04 A0 8C 00 00 START_REMAINING_DISTANCE_M = 36000
02 01 C8 STEP_M = 200
03 02 E0 2E BASE_ALT_DM = 12000
05 01 02 DELTA_SCALE_DM = 2
04 03 3C 45 FD DELTAS_DM = +60, +69, -3 (× 0,2 m)
Streckenübersicht-Service¶
Vierter BLE-Service, nur aktiv, solange TrailBridge eine importierte GPX-Route
abspielt. Er sagt, was auf der Route noch vor dem Fahrer liegt: das Ziel, die
Wegpunkte der GPX-Datei (wpt) und die Anstiege. Anders als die anderen Services
wird er nur gelesen -- keine Indicate, kein CCCD, kein Heartbeat.
Warum Read statt Indicate¶
- Der Inhalt ändert sich selten: beim Start der Route und immer dann, wenn ein Wegpunkt oder ein Gipfel erreicht ist. Entfernung und Zeit zu jedem Eintrag rechnet die Firmware aus dem Nav-Frame und den Ankern der Übersicht (siehe "Entfernung und Zeit") -- dafür muss die Übersicht nicht neu übertragen werden.
- Die Länge: Eine Indicate fasst höchstens
ATT_MTU - 3Byte (253). Ein gelesener Wert darf bis 512 Byte lang sein; der Client holt ihn in Stücken (Read Blob, "Long Read").readValue()der ESP32-BLE-Bibliothek macht das von selbst, mit NimBLE wie mit Bluedroid. Zehn Wegpunkte mit Namen passen nicht in eine Indicate, aber in einen Read. - Das In-Flight-Gate: Über alle Services ist immer nur eine Indicate unterwegs. Eine große Übersicht würde Abbiegehinweise aufhalten; ein Read läuft daran vorbei.
Einem Read fehlt das Signal "es gibt etwas Neues". Das liefert OVERVIEW_REVISION
(Tag 0x0E) im Nav-Frame, der ohnehin jede Sekunde bzw. alle 5 s kommt.
UUIDs¶
- Service:
7ee954a6-7a19-4b48-b052-f00d00e01e58 - Characteristic:
f031faa3-083d-4818-9eca-38420be835d0Properties:READ
Wie die anderen Zusatz-Services nicht im Advertising, Discovery nach dem Verbindungsaufbau.
Ablauf¶
- Ein Nav-Frame trägt
OVERVIEW_REVISION, und der Wert ist ein anderer als dieREVISIONder gespeicherten Übersicht (oder es ist noch keine gespeichert): die Characteristic lesen. Nicht im Indicate-Callback --readValue()blockiert. - Den gelesenen Frame samt seiner
REVISIONspeichern. Zeigt der nächste Nav-Frame eine andere Revision, noch einmal lesen. - Ein Nav-Frame ohne
OVERVIEW_REVISION(Navigation aus OsmAnd),NAV_NONEoder das Ende der Verbindung: die Übersicht verwerfen.
TrailBridge stellt immer zuerst den neuen Wert bereit und sendet danach den Nav-Frame mit der neuen Revision. Die Revision ist 1..255 und zählt mit jeder neuen Übersicht weiter, auch über Routen hinweg; nach 255 folgt 1, 0 kommt nicht vor.
Frame-Format¶
Byte 0: Protokoll-Version (aktuell 1)
Byte 1: Message-Type
Byte 2..: TLV-Einträge (nur bei OVERVIEW)
| Wert | Name | Bedeutung |
|---|---|---|
| 0x00 | HELLO | Zustand nach dem Start der App, keine Übersicht |
| 0x01 | OVERVIEW | Übersicht, gefolgt von TLV-Einträgen |
| 0x02 | OVERVIEW_NONE | keine Route aktiv, keine Übersicht |
| Tag | Name | Länge | Format |
|---|---|---|---|
| 0x01 | REVISION | 1 | uint8, 1..255 -- die OVERVIEW_REVISION der Nav-Frames, zu denen diese Übersicht gehört |
| 0x02 | WAYPOINTS_AHEAD | 1 | uint8, Zahl der Wegpunkte voraus, ohne das Ziel (255 = 255 oder mehr) |
| 0x03 | CLIMBS_TOTAL | 1 | uint8, Zahl der Anstiege der ganzen Route (0 = keine, oder die Route hat keine Höhendaten) |
| 0x04 | WAYPOINT | 9 + N | ein Wegpunkt oder das Ziel, siehe unten; kommt mehrfach vor |
| 0x05 | CLIMB | 11 | ein Anstieg, siehe unten; kommt mehrfach vor |
Wert von WAYPOINT:
| Byte | Bedeutung |
|---|---|
| 0 | Flags -- Bit 0 = DESTINATION (das Ziel der Route), Bits 1-7 reserviert (0, zu ignorieren) |
| 1-4 | REMAINING_AT_M, uint32 LE: Restdistanz der Route bis zum Ziel an diesem Wegpunkt (beim Ziel 0) |
| 5-8 | REMAINING_TIME_AT_S, uint32 LE: Restzeit bis zum Ziel an diesem Wegpunkt, Sekunden (beim Ziel 0); 0xFFFFFFFF = unbekannt, die GPX hat keine Zeitstempel |
| 9.. | Name, UTF-8, höchstens 32 Byte, kein Nullterminator |
Wert von CLIMB:
| Byte | Bedeutung |
|---|---|
| 0 | Nummer des Anstiegs, 1-basiert in Fahrtrichtung über die ganze Route |
| 1-2 | Höhenmeter vom Fuß bis zum Gipfel, uint16 LE, Meter |
| 3-6 | Länge vom Fuß bis zum Gipfel, uint32 LE, Meter |
| 7-10 | FOOT_REMAINING_AT_M, uint32 LE: Restdistanz der Route bis zum Ziel am Fuß des Anstiegs |
Ist ein CLIMB-Wert länger als 11 Byte, ignoriert die Firmware den Rest; unbekannte
Tags überspringt sie wie überall.
Reihenfolge und Länge: Nach den drei Kopf-Tags steht das Ziel, danach Wegpunkte
und Anstiege gemischt in der Reihenfolge, in der der Fahrer sie erreicht (ein Anstieg
zählt an seinem Fuß). Jede der beiden Listen ist also "der nächste zuerst". Der Frame
ist höchstens 512 Byte lang. Was nicht passt, fehlt am fernen Ende und rückt nach,
sobald vorn etwas erreicht ist. WAYPOINTS_AHEAD und CLIMBS_TOTAL sagen, wie viele
es wirklich sind -- sind es mehr als im Frame stehen, ist die Liste gekürzt.
Wegpunkte¶
- Quelle sind die
wpt-Elemente der GPX-Datei. Ein Wegpunkt zählt, wenn er höchstens 100 m neben der Route liegt; er sitzt dann an der nächstgelegenen Stelle der Route. Führt die Route zweimal an ihm vorbei, zählt das erste Mal. - Ein Wegpunkt ohne Namen heißt "Wegpunkt N" -- N ist seine Nummer unter allen Wegpunkten der Route, in Fahrtrichtung ab 1. Das Ziel heißt "Ziel".
- Erreicht der Fahrer einen Wegpunkt, fällt er aus der Übersicht (neue Revision). Er kommt erst wieder, wenn der Fahrer mehr als 100 m vor ihn zurückfällt.
Entfernung und Zeit¶
Die Übersicht trägt nur Anker. Entfernung und Zeit ergeben sich mit dem letzten Nav-Frame -- wie die Position im Höhenprofil, ohne Wegstreckenzähler und über Reconnects hinweg:
Entfernung = REMAINING_DISTANCE_M - REMAINING_AT_M
Zeit = REMAINING_TIME_S - REMAINING_TIME_AT_S (Zeitanker bekannt)
Zeit = REMAINING_TIME_S × Entfernung / REMAINING_DISTANCE_M (Zeitanker 0xFFFFFFFF)
Für das Ziel sind das in beiden Fällen genau REMAINING_DISTANCE_M und
REMAINING_TIME_S. Eine negative Entfernung heißt: der Wegpunkt ist gerade erreicht,
die neue Übersicht ist unterwegs -- nicht mehr anzeigen. Eine negative Zeit ist 0. In
der dritten Zeile passt das Produkt nicht immer in 32 Bit -- in 64 Bit rechnen; bei
REMAINING_DISTANCE_M = 0 ist die Zeit 0.
Woher die Zeit kommt: Hat die GPX-Datei Zeitstempel an ihren Punkten, nimmt
TrailBridge die Restzeit daraus -- für REMAINING_TIME_S im Nav-Frame wie für die
Zeitanker. Das ist bei einer aufgezeichneten Fahrt deren Zeit (jeder Halt
zählt höchstens 30 s) und bei einer geplanten Route die Schätzung des Routers, sofern er sie
in die Datei schreibt; sie kennt die Anstiege. BRouter (bikerouter.de) schreibt sie
nur, wenn das Profil assign showtime = true setzt, sonst steht nur die Gesamtzeit in
einem Kommentar. Wie schnell der Fahrer wirklich ist, geht in diese Zeit nicht ein.
Ohne Zeitstempel ist REMAINING_TIME_S die Reststrecke durch die geglättete aktuelle
Geschwindigkeit, die Zeitanker sind 0xFFFFFFFF, und die dritte Zeile verteilt die
Restzeit nach der Strecke. Diese Schätzung weiß nichts von den Anstiegen dazwischen.
Abseits der Route bleiben REMAINING_DISTANCE_M und REMAINING_TIME_S stehen,
Entfernungen und Zeiten also auch.
Anstiege¶
Ein Anstieg ist, was "Was ein Anstieg ist" oben beschreibt; Höhenmeter und Länge
gelten für den ganzen Anstieg vom Fuß bis zum Gipfel, auch wenn der Fahrer schon
darin ist. Die Liste beginnt mit dem Anstieg, in dem der Fahrer gerade fährt, sonst
mit dem nächsten. Am Gipfel fällt der Anstieg aus der Übersicht (neue Revision). Aus
Nummer und CLIMBS_TOTAL ergibt sich "Anstieg 3 von 7".
Die Entfernung bis zum Fuß ist REMAINING_DISTANCE_M - FOOT_REMAINING_AT_M. Ist
sie 0 oder negativ, fährt der Fahrer schon im Anstieg; bis zum Gipfel sind es dann
noch Länge + Entfernung Meter (die Entfernung ist dort negativ). Einen Zeitanker
haben Anstiege nicht. Das Profil eines Anstiegs kommt wie bisher über den
Höhenprofil-Service, 500 m vor seinem Fuß.
Beispiel¶
Revision 3, zwei Wegpunkte voraus, der zweite von zwei Anstiegen noch nicht gefahren, die GPX hat Zeitstempel: Ziel, "Bäcker" 30,5 km und 6100 s vor dem Ziel, Anstieg 2 (450 Hm auf 8,2 km, Fuß 22 km vor dem Ziel), ein namenloser Wegpunkt 12 km und 2400 s vor dem Ziel:
01 01 version=1, type=OVERVIEW
01 01 03 REVISION = 3
02 01 02 WAYPOINTS_AHEAD = 2
03 01 02 CLIMBS_TOTAL = 2
04 0D 01 00 00 00 00 00 00 00 00 5A 69 65 6C WAYPOINT: DESTINATION, 0 m, 0 s, "Ziel"
04 10 00 24 77 00 00 D4 17 00 00 WAYPOINT: 30500 m, 6100 s,
42 C3 A4 63 6B 65 72 "Bäcker"
05 0B 02 C2 01 08 20 00 00 F0 55 00 00 CLIMB: Nr. 2, 450 Hm, 8200 m, Fuß bei 22000 m
04 13 00 E0 2E 00 00 60 09 00 00 WAYPOINT: 12000 m, 2400 s,
57 65 67 70 75 6E 6B 74 20 32 "Wegpunkt 2"
78 Bytes. Meldet der Nav-Frame dazu REMAINING_DISTANCE_M = 36000 und
REMAINING_TIME_S = 7200, ist der Bäcker 5,5 km und 1100 s entfernt, der Fuß von
Anstieg 2 14 km, der namenlose Wegpunkt 24 km und 4800 s, das Ziel 36 km und 7200 s.
Ohne Zeitstempel in der GPX stünde in den drei Wegpunkten FF FF FF FF als Zeitanker.
Navigation aus einer GPX-Route¶
Wenn TrailBridge eine GPX-Route selbst abspielt, sendet es ganz normale
NAV_UPDATE-Frames im Nav-Service (Format oben, unverändert), berechnet
aus der GPS-Position. Besonderheiten gegenüber OsmAnd: keine Fahrspuren
(0x0A-0x0D), Straßennamen nur, wenn die GPX sie enthält. Abseits der Route
(> 50 m) steht MANEUVER = UNKNOWN (255), MANEUVER_DISTANCE_M ist dann die
Entfernung zur Route und STREET_NAME "Abseits der Route". Jeder Frame trägt
außerdem OVERVIEW_REVISION (0x0E), auch abseits der Route. REMAINING_TIME_S kommt
aus den Zeitstempeln der GPX, wenn sie welche hat, siehe "Entfernung und Zeit".
Sensorwerte und Simulationsmodus¶
Der Positions-Frame kann neben der Position auch Sensorwerte tragen, die
TrailBridge kennt: Puls (0x09), Trittfrequenz (0x0A), Barometer-Höhe (0x0C) und
Leistung (0x0D); die Geschwindigkeit steckt in SPEED_CMS (0x04). Ob die Werte
echt oder erfunden sind, sagt ein eigener Tag, damit beides nebeneinander
möglich ist:
BARO_HEIGHT_DM ist die Höhe, die der BikeComputer sonst von seinem Barometer
hat, in Dezimetern (ALTITUDE_M hat nur ganze Meter -- zu grob, um daraus über
ein paar Dutzend Meter Strecke eine Steigung zu rechnen). Die Steigung wird
nicht übertragen: der BikeComputer rechnet sie wie immer selbst aus Höhe und
zurückgelegter Strecke. POWER_W ist die Tretleistung eines Leistungsmessers.
MSL_ALTITUDE_DM ist die Höhe eines echten GPS-Fixes über dem Meeresspiegel, in
Dezimetern (Location.getMslAltitudeMeters(), ab Android 14). ALTITUDE_M dagegen ist die
Höhe über dem Ellipsoid und liegt in Deutschland rund 45-50 m daneben. Der BikeComputer
kalibriert damit sein Barometer (Taste "GPS" bei der Höhenkalibrierung). Der Tag fehlt, wenn
das Handy die Höhe nicht über NHN kennt, und bei erfundenen Positionen (SIM_POSITION).
Eine Höhe von GPS ist nur auf etwa 10 m genau.
SIM_FLAGS (0x0B), uint8-Bitfeld. Fehlt der Tag (oder ist er 0), ist alles im Frame echt.
| Bit | Name | Bedeutung, wenn gesetzt |
|---|---|---|
| 0 (0x01) | SIM_POSITION | Lat/Lon/Höhe/Kurs sind erfunden, nicht vom GPS-Chip (z.B. GPX-Testfahrt) |
| 1 (0x02) | SIM_SENSORS | SPEED_CMS ist die Geschwindigkeit eines simulierten Speed-Sensors; Puls, Trittfrequenz, Barometer-Höhe und Leistung sind simuliert -- und das Fehlen von 0x09/0x0A/0x0C/0x0D heißt "diesen Sensor gibt es nicht" |
| 2-7 | -- | reserviert, 0, von der Firmware zu ignorieren |
Konsequenzen für die Firmware:
- Sim-Daten dürfen nie als echte Messung behandelt werden. Fahrdaten, die
mit gesetztem SIM_POSITION oder SIM_SENSORS entstehen, gehören im Log als
simuliert markiert (
LOG_SIMULATED). - SIM_SENSORS: nur ein Simulator-Build (
BC_SIM) speist Geschwindigkeit, Puls, Trittfrequenz, Höhe und Leistung in seinen Sensor-Simulator ein (SimSensors, wiesim <km/h> <rpm> <bpm>; die Höhe ersetzt das Barometer, daraus entsteht die Steigung); die normale Firmware ignoriert die Werte, damit keine Fake-Kilometer in Odometer und Statistik landen. Ende der Simulation = der nächste Frame ohne SIM_SENSORS,POSITION_NONE, ein veralteter Fix (FIX_AGE_MS> 5 s) oder das Ende der Verbindung -> "Sensoren aus". - Ohne SIM_SENSORS sind 0x09/0x0A/0x0C/0x0D echte, von TrailBridge weitergereichte Sensorwerte (z.B. ein Pulsgurt am Handy). Noch nicht gebaut -- die App sendet so etwas bisher nicht, die Firmware wertet es nicht aus. Der Tag und die Semantik stehen hier schon fest, damit sich das ohne Vertragsbruch nachrüsten lässt.
- Die Zeit (
UTC_TIME_MS) bleibt auch in der Simulation die echte Uhrzeit des Handys -- die Uhr des BikeComputers soll richtig bleiben.
GPX-Testfahrt¶
TrailBridge kann die geladene GPX-Route "abfahren" statt der echten
GPS-Position zu senden: einmal pro Sekunde ein Fix entlang der Route, mit
SIM_FLAGS = 0x03 (SIM_POSITION | SIM_SENSORS). SPEED_CMS, HEART_RATE_BPM,
CADENCE_RPM, POWER_W kommen aus der GPX (Zeitstempel, TrackPointExtension
hr/cad, Leistung), wo sie fehlen, aus der Berechnung bzw. -- auf Wunsch --
zufällig variierenden, zur Strecke passenden Werten (Puls folgt der Belastung,
Trittfrequenz sinkt am Berg, 0 beim Rollen/Stehen; Leistung ergibt sich aus
Geschwindigkeit, Steigung und Beschleunigung, 0 beim Rollen); ohne Emulation
fehlt der Tag, der Sensor "ist nicht da". BARO_HEIGHT_DM ist die geglättete
Höhe der Route (fehlt, wenn die GPX keine Höhendaten hat). Pausiert die Testfahrt, kommen weiter Frames: Geschwindigkeit 0,
Trittfrequenz 0. Nav- und Höhenprofil-Service laufen wie bei einer echten Fahrt
aus der simulierten Position. Das Ende der Testfahrt ist der erste Frame ohne
SIM_FLAGS bzw. POSITION_NONE.
Beispiel, Testfahrt bei 21,6 km/h, Puls 132, Trittfrequenz 85 (Auszug, nur die hinteren Tags des Frames):
09 01 84 HEART_RATE_BPM = 132
0A 01 55 CADENCE_RPM = 85
0B 01 03 SIM_FLAGS = SIM_POSITION | SIM_SENSORS
0C 04 80 0D 00 00 BARO_HEIGHT_DM = 3456 (345,6 m)
0D 02 FA 00 POWER_W = 250
Warum Indicate statt Notify¶
Bei Indicate bestätigt der Empfänger (ESP32) jedes Paket auf ATT-Ebene, bei Notify nicht. Eine verpasste Abbiegeanweisung ist ärgerlicher als ein paar Millisekunden zusätzliche Latenz -- deshalb Indicate.
Kompatibilität¶
Byte 0 (Versionsnummer) erlaubt es der Firmware, ein zukünftiges Protokoll v2 zu erkennen und notfalls auf ein einfacheres Rendering zurückzufallen, statt falsch interpretierte Daten anzuzeigen.