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 |
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 |
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. 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.
Er ist ereignisgetrieben: TrailBridge sendet das Profil eines Anstiegs,
sobald dessen Fuß höchstens 500 m voraus liegt -- von der Position des
Fahrers bis zum Gipfel, in einem Frame -- und schickt PROFILE_NONE, wenn
der Gipfel erreicht ist (und kein weiterer Anstieg direkt folgt), die Route
endet oder der Fahrer die Route verlässt. Kein Heartbeat -- der zuletzt
gesendete Frame bleibt per Read abrufbar und geht an neu abonnierende Geräte.
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.
Die Firmware (ClimbProfile.cpp) findet Fuß und Gipfel im gesendeten Profil
mit denselben Kriterien; das Profilende gilt ihr als Gipfel. Die Standardwerte
beider Seiten müssen zusammenpassen.
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 |
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); 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):
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
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)
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".
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.
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.