TrailBridge BLE protocol v1¶
English translation of PROTOCOL.md. The German file is the authoritative version; change both together.
BLE roles¶
- Companion app (phone) = peripheral / GATT server
- TRGB-BikeComputer (ESP32) = central / GATT client (as before with Komoot -- the
existing connection management in
BLEDevices.cpptherefore keeps its basic structure, only the parser changes)
UUIDs¶
- Service:
f7ac2b76-986b-45fd-8e44-f116a61f319d - Characteristic:
7473da02-2de8-4f48-9e46-21b36380c176Properties:INDICATE,READ
Freshly generated, no reuse of Komoot's old UUIDs -- new protocol, new identifier.
Kind of transmission¶
- Primary: indicate (with confirmation) on the characteristic above. The ESP32
enables it by writing
0x0200to the standard CCCD descriptor (00002902-0000-1000-8000-00805f9b34fb). - Fallback: read. Returns the frame sent last, e.g. directly after connecting, before the first indication arrives, or in case indicate misbehaves on an ESP32 BLE stack.
- Heartbeat: the companion app sends the frame computed last again every 5 s even without a change in content, so that the firmware can tell a dead connection from "no change because we are standing at a traffic light".
Frame format¶
Byte 0: protocol version (currently 1)
Byte 1: message type
Byte 2..: TLV entries (only for NAV_UPDATE)
Message types:
| Value | Name | Meaning |
|---|---|---|
| 0x00 | HELLO | sign of life/version check, no further data |
| 0x01 | NAV_UPDATE | active navigation, followed by TLV entries |
| 0x02 | NAV_NONE | no active route, no further data |
TLV entry: tag (1 byte) | length N (1 byte) | value (N bytes)
| Tag | Name | Length | Format | Condition |
|---|---|---|---|---|
| 0x01 | MANEUVER | 1 | maneuver code (table below) | always |
| 0x02 | MANEUVER_DISTANCE_M | 4 | uint32 LE, metres | always |
| 0x03 | ROUNDABOUT_EXIT | 1 | exit number, 1-based | only if MANEUVER == ROUNDABOUT |
| 0x04 | STREET_NAME | ≤48 | UTF-8, no null terminator | always (may be empty) |
| 0x05 | NEXT_MANEUVER | 1 | maneuver code | only if the maneuver after next is known |
| 0x06 | NEXT_MANEUVER_DISTANCE_M | 4 | uint32 LE, metres (counted from the next maneuver) | as 0x05 |
| 0x07 | NEXT_STREET_NAME | ≤48 | UTF-8 | as 0x05 |
| 0x08 | REMAINING_DISTANCE_M | 4 | uint32 LE, metres to the destination | always |
| 0x09 | REMAINING_TIME_S | 4 | uint32 LE, seconds to the destination | always |
| 0x0A | LANES | N (multiple of 4) | list of lanes, see below | only if OsmAnd delivers lane data (turn_lanes, rare on pure cycle paths) |
| 0x0B | NEXT_LANES | N (multiple of 4) | as 0x0A | as 0x0A, but for the turn after next (as 0x05/0x06/0x07) |
| 0x0C | LANE_DISTANCE_M | 4 | uint32 LE, metres to the point where the LANES information applies | only if tag 0x0A is present |
| 0x0D | NEXT_LANE_DISTANCE_M | 4 | uint32 LE, metres | only if tag 0x0B is present |
| 0x0E | OVERVIEW_REVISION | 1 | uint8, 1..255 | only for navigation from a GPX route: revision of the route overview this frame belongs to, see "Route overview service" |
The firmware must skip unknown tags (respect the length, ignore the value) -- this leaves room for later extensions without older firmware breaking on them. That is the whole point of TLV instead of a rigid byte layout as with Komoot.
Maneuver codes¶
Own, fixed codes -- independent of Komoot's old 32-slot icon index and of OsmAnd's
internal TurnType constants. Reference implementation: 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 (exit in tag 0x03) |
| 255 | UNKNOWN |
Lane information (LANES / NEXT_LANES)¶
Maps OsmAnd's lane guidance (AIDL turnInfo bundle keys next_turn_lanes /
after_nextturn_lanes, fed from the OSM tag turn:lanes; verified against
ExternalApiHelper#updateRouteDirectionInfo and net.osmand.router.TurnType in the
OsmAnd source code, as of 2026-09-18). OsmAnd delivers it as an int[], one bit-packed
int per lane (bit 0 = active/recommended, bits 1-4 = primary direction, further bits
secondary/tertiary). As with the maneuver codes we do not adopt this bit layout 1:1 but
translate every direction into our own maneuver code table (above) -- so the firmware
does not depend on OsmAnd's internal TurnType constants.
Value of tag 0x0A/0x0B: a list of lanes, from left to right (as in OSM tagging and in OsmAnd's array order), 4 bytes each:
| Byte | Meaning |
|---|---|
| 0 | primary maneuver code -- main arrow direction of this lane |
| 1 | secondary maneuver code, 0 (NONE) = no second direction |
| 2 | tertiary maneuver code, 0 (NONE) = no third direction |
| 3 | flags -- bit 0 = ACTIVE (recommended by OsmAnd's router for the computed route), bits 1-7 reserved (currently 0, to be ignored by the firmware) |
Byte 0 is never 0: OsmAnd already treats a lane without an explicitly set primary
direction as "straight on" internally (see TurnType.lanesToString()), and the app maps
this fallback to STRAIGHT (code 3) before it builds the frame -- so the firmware does not
have to treat 0 in byte 0 specially.
Length N = number of lanes × 4 (the TLV length byte limits this to at most 63 lanes -- real roads rarely have more than 6-8).
The reserved flag bits leave room for a possible later extension by bicycle-specific lane tagging or further lane properties, without having to change the byte width per lane afterwards.
Distance (LANE_DISTANCE_M / NEXT_LANE_DISTANCE_M)¶
The point at which a choice of lane becomes relevant (e.g. where a lane fans out) does NOT have to coincide with the point of the actual turn instruction -- on multi-lane roads it typically lies clearly before it. LANES/NEXT_LANES therefore carry a distance of their own, independent of MANEUVER_DISTANCE_M/NEXT_MANEUVER_DISTANCE_M (tag 0x0C/0x0D) -- the firmware must not equate the two distances.
Current state on the app side (as of 2026-09-19): OsmAnd's AIDL attaches
next_turn_lanes/after_nextturn_lanes to the same RouteDirectionInfo/
NextDirectionInfo as the corresponding turn distance -- through the AIDL API both
distances are therefore actually identical at the moment.
ExternalApiHelper#getRouteDirectionsInfo does have a second mechanism (bundle prefix
no_speak_next_) which by its name is meant exactly for an earlier, unannounced lane
point -- but the return value of the corresponding
getNextRouteDirectionInfo(..., false) call is discarded there, and the already filled
(stale) ni of after_next is reused instead:
routingHelper.getNextRouteDirectionInfo(new NextDirectionInfo(), false);
if (ni.distanceTo > 0) {
updateTurnInfo("no_speak_next_", bundle, ni);
}
no_speak_next_* is thus, in the current OsmAnd master (verified 2026-09-19,
OsmAnd/src/net/osmand/plus/helpers/ExternalApiHelper.java), in fact a duplicate of
after_next* instead of an earlier point of its own -- it is not usable like this. Until
that is fixed upstream (or another way is found), the app sends the same value for
LANE_DISTANCE_M/NEXT_LANE_DISTANCE_M as for MANEUVER_DISTANCE_M/
NEXT_MANEUVER_DISTANCE_M. The separate tags stay nevertheless, so that this can be added
later without breaking the wire contract.
Example¶
Two lanes, 45 m ahead (the choice of lane already becomes relevant here, although according to MANEUVER_DISTANCE_M the actual turn only comes in 300 m): left lane straight on only, right lane straight-on-or-right and recommended by the router for the route:
0A 08 LANES, length 8 (2 lanes)
03 00 00 00 lane 1: STRAIGHT, --, --, flags=0
03 08 00 01 lane 2: STRAIGHT, TURN_RIGHT, --, flags=ACTIVE
0C 04 2D 00 00 00 LANE_DISTANCE_M = 45
Example¶
"In 120 m turn left onto Bahnhofstraße, then keep right after 300 m, 4200 m / 600 s to the destination":
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 in total -- fits with a large margin into the ATT MTU of 256 bytes negotiated by
setMTU(256) (253 bytes usable).
GPS position service¶
A BLE service of its own, independent of the navigation service above. The position data
comes straight from the phone's GPS chip (LocationManager.GPS_PROVIDER), not from
OsmAnd -- so it also works when OsmAnd is not running or not installed at all.
UUIDs¶
- Service:
66b5835c-9be6-43d1-b24a-f9337c0fcb7f - Characteristic:
10c49e7b-4808-4d63-9b68-9ba6c385db0dProperties:INDICATE,READ
Deliberately NOT advertised in the advertising packet (31-byte legacy limit -- see the
comment in BikeComputerGattServer, the navigation service alone already fills the main
packet). The bike computer finds it after connecting through normal GATT service
discovery, just as it finds e.g. the battery service of the other sensors without an
advertising entry.
Kind of transmission¶
As for the navigation service: indicate primarily, read as a fallback, heartbeat every 5 s -- clocked independently of the navigation heartbeat and state.
Important for a re-implementation of the firmware: both services share one single
BluetoothGattServer process on the app side. Android (and BLE platforms in general)
allow only one indicate/notify "in flight" per connected device at a time, also across
characteristics and services -- onNotificationSent() does not say which characteristic
has just finished. BikeComputerGattServer therefore serialises navigation and position
frames through a common in-flight gate. For the ESP32 as central this does not matter
(it simply receives two independent indicate streams); it is only relevant if the app
side is ever rebuilt.
Frame format¶
Byte 0: protocol version (currently 1)
Byte 1: message type
Byte 2..: TLV entries (only for POSITION_UPDATE)
Message types:
| Value | Name | Meaning |
|---|---|---|
| 0x00 | HELLO | sign of life/version check, no further data |
| 0x01 | POSITION_UPDATE | valid GPS fix, followed by TLV entries |
| 0x02 | POSITION_NONE | no fix (yet) -- GPS off, permission missing, or no satellite reception yet |
TLV entry: tag (1 byte) | length N (1 byte) | value (N bytes)
| Tag | Name | Length | Format | Condition |
|---|---|---|---|---|
| 0x01 | LATITUDE_E7 | 4 | int32 LE, latitude × 1e7 | always |
| 0x02 | LONGITUDE_E7 | 4 | int32 LE, longitude × 1e7 | always |
| 0x03 | ALTITUDE_M | 4 | int32 LE, metres above the ellipsoid | only if the fix delivers an altitude |
| 0x04 | SPEED_CMS | 4 | uint32 LE, cm/s | only if the fix delivers a speed |
| 0x05 | BEARING_DEG_X100 | 2 | uint16 LE, degrees × 100 (0..35999) | only if the fix delivers a bearing |
| 0x06 | ACCURACY_M_X10 | 2 | uint16 LE, metres × 10 (horizontal accuracy) | only if the fix delivers an accuracy |
| 0x07 | FIX_AGE_MS | 4 | uint32 LE, milliseconds since this fix | always |
| 0x08 | UTC_TIME_MS | 8 | uint64 LE, UTC time of the fix in ms since 1970-01-01 (Location.getTime()) |
if the fix has a time (> 0) |
| 0x09 | HEART_RATE_BPM | 1 | uint8, beats/min | only if TrailBridge knows a heart rate, see "Sensor values and simulation mode" |
| 0x0A | CADENCE_RPM | 1 | uint8, revolutions/min, 0 = coasting | as 0x09, for the cadence |
| 0x0B | SIM_FLAGS | 1 | uint8 bit field, see below | only if something in this frame is simulated (otherwise the tag is missing) |
| 0x0C | BARO_HEIGHT_DM | 4 | int32 LE, altitude in decimetres above sea level | only if TrailBridge knows a barometric altitude, see below |
| 0x0D | POWER_W | 2 | uint16 LE, watts (0 = coasting) | only if TrailBridge knows a power value, see below |
| 0x0E | MSL_ALTITUDE_DM | 4 | int32 LE, altitude in decimetres above sea level (geoid) | only for a real fix whose altitude the phone knows above sea level (Android 14+), see below |
E7 fixed point for latitude/longitude (instead of floating point) for the same reason as everywhere else in the TLV format: a fixed, platform-independent byte layout, no trouble with float endianness or NaN. ±90°/±180° × 1e7 both fit comfortably into a signed int32 (at most ±2.147 billion).
FIX_AGE_MS lets the firmware recognise whether the last fix is fresh or the heartbeat is
only repeating an old state (tunnel, loss of satellites and the like) -- it is recomputed
on every send from SystemClock.elapsedRealtime() -
location.getElapsedRealtimeNanos()/1_000_000, so it is also correct for heartbeat
resends and not frozen at the original time of the fix.
UTC_TIME_MS is the time source for the bike computer's clock when there is no WiFi
(NTP). With GPS_PROVIDER it is the satellite time, i.e. independent of the phone's
clock. The current time at sending is UTC_TIME_MS + FIX_AGE_MS -- both values come
from the same frame, so the receiver does not have to know when the fix was made. The
BLE latency (typically < 100 ms) is not taken into account. TrailBridge sends the GPS time only
if it agrees with the phone's clock (±5 s, GpsTime), else the phone's time (as the time of
the fix: the phone's now minus FIX_AGE_MS): right after being switched on the GNSS chip can
deliver a time that is off by minutes up to hours (test ride 2026-10-04: 14.8 h behind, with a
correct position). The firmware checks the result once more against its own clock (ClockSync). Added later without a new
version: firmware that does not know the tag skips it by its length; an app without the
tag simply does not set a clock.
Example¶
Fix at 52.5163° N, 13.3777° E, altitude 34 m, 4.2 m/s, bearing 87.5°, accuracy ±5 m, 320 ms ago:
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 in total (50 with UTC_TIME_MS) -- fits easily into the negotiated ATT MTU.
Elevation profile service¶
A third, independent BLE service, only active while TrailBridge plays an imported GPX
route and the route has elevation data. The profile is sent all the time -- on flat
ground and downhill, too, it is always of interest (test ride 2026-10-04) -- as a
rolling window of the road ahead, starting at the rider. A climb in it is not
guessed from the points by the firmware but announced by TrailBridge with its whole
extent (tags 0x07..0x0A, see below): it never ends before the summit, and category and
length stay those of the foot, even when the rider is already in the middle and the foot
is no longer in the frame. The flag ROLLING (tag 0x06) marks such frames.
A new frame goes out
- at the start and after the profile was taken back (rider off the route),
- when the climb to announce changes: its foot comes within 500 m (the window then runs from the rider to the summit), or its summit is reached (the window without an announcement, the road beyond the summit),
- when the rider has passed the middle of a frame that does not reach as far as it should: the window moves on (window without a climb: 200 steps of 25 m = 5 km; a climb that does not fit in one frame even on the coarsest raster).
Nothing in between. PROFILE_NONE only when the route ends or the rider leaves it --
no longer at the summit: there the next frame simply lacks the announcement and the
firmware knows the climb is over. No heartbeat -- the frame sent last stays available
by read and goes to newly subscribing devices.
Older TrailBridge versions (without the flag) send only the profile of a climb, from the
position to the summit, and PROFILE_NONE at the summit; the firmware then finds the
climb in the profile itself (see below) and stays compatible.
What a climb is¶
TrailBridge searches for the climbs when loading the route (25 m raster, smoothed over
about 125 m; ElevationProfile.findClimbs):
- Foot: the first point from which the route rises by at least 3 m over the next 100 m. A gentle beginning therefore already belongs to the climb.
- Summit: the highest point before the route either drops by more than 30 m or does not rise any further for 2 km (on average less than 0.5 % from the highest point so far) -- or ends. A shorter dip or a shorter flat section belongs to the climb.
- Below 10 m of altitude difference between foot and summit it is not a climb.
Example Galibier: from the south (36 km over the Lautaret, with flat sections) it is one climb. From the north it is two -- after the Col du Télégraphe the road descends for almost 5 km and about 165 m to Valloire.
Only for frames without ROLLING (older TrailBridge versions) the firmware
(ClimbProfile.cpp) finds foot and summit in the profile sent with the same criteria; the
end of the profile counts as the summit for it. With ROLLING the announcement alone
counts; TrailBridge applies the criteria of this section.
UUIDs¶
- Service:
3c1f6a90-5b2e-4d7a-9c48-e0a1b7d25f63 - Characteristic:
a84e0d17-6f3b-4c52-8e9d-1b70c2f4a596Properties:INDICATE,READ
Like the position service not in the advertising, discovery after connecting. Shares the in-flight gate with the other services (see above).
Frame format¶
Byte 0: protocol version (currently 1)
Byte 1: message type
Byte 2..: TLV entries (only for PROFILE_UPDATE)
| Value | Name | Meaning |
|---|---|---|
| 0x00 | HELLO | state after connecting, no profile |
| 0x01 | PROFILE_UPDATE | profile, followed by TLV entries; replaces an earlier one |
| 0x02 | PROFILE_NONE | show no (climb) profile any more |
| Tag | Name | Length | Format |
|---|---|---|---|
| 0x01 | START_REMAINING_DISTANCE_M | 4 | uint32 LE, remaining distance of the route to the destination at the first profile point |
| 0x02 | STEP_M | 1 | distance between the profile points in metres: 25, 50, ... 250 (see "Raster") |
| 0x03 | BASE_ALT_DM | 2 | int16 LE, altitude of the first point in decimetres |
| 0x04 | DELTAS_DM | N | N × int8: change of altitude from point k to point k+1, in units of DELTA_SCALE_DM dm |
| 0x05 | DELTA_SCALE_DM | 1 | uint8, optional: unit of the deltas in decimetres. If the tag is missing, 1 applies |
| 0x06 | FLAGS | 1 | uint8, optional: bit 0 ROLLING = rolling window of the road ahead; a climb is only the one announced in 0x07..0x0A. If the tag is missing: frame of an older TrailBridge (profile up to the summit) |
| 0x07 | CLIMB_FOOT_REMAINING_M | 4 | uint32 LE, remaining distance of the route to the destination at the foot of the announced climb (may lie behind the first point, i.e. be larger than START_REMAINING_DISTANCE_M) |
| 0x08 | CLIMB_SUMMIT_REMAINING_M | 4 | uint32 LE, remaining distance at the summit (may lie beyond the last point) |
| 0x09 | CLIMB_FOOT_ALT_DM | 2 | int16 LE, altitude of the foot in decimetres |
| 0x0A | CLIMB_SUMMIT_ALT_DM | 2 | int16 LE, altitude of the summit in decimetres |
The four climb tags come all together or not at all; if they are missing with
ROLLING set, there is no climb (neither under the rider nor at most 500 m ahead). The
climb that is announced is the one the rider is on or whose foot is at most 500 m ahead,
until its summit is reached (to half a raster step). Repeated frames of the same climb
carry the same values -- the firmware recognises it by them and keeps category and
display stable. Category = length × mean gradient of the whole climb (foot to summit), not
of the rest.
Point k lies at a remaining distance of START_REMAINING_DISTANCE_M - k × STEP_M, its
altitude is BASE_ALT_DM + DELTA_SCALE_DM × Σ DELTAS_DM[0..k-1] (in dm). There are N+1
points. The altitudes are smoothed over about 125 m (GPX altitudes are noisy).
Raster: the whole rest of the climb should fit into one frame. Up to N × 25 m (5 km
with N = 200) STEP_M is 25; for longer climbs it becomes the smallest multiple of 25 m
with which it fits, at most 250 m (50 km with N = 200; a 36 km climb comes with 200 m).
The raster is laid backwards from the summit: the last point is the summit, the first
lies at the rider's position or less than one step behind it (only right at the start of
the route can it lie just in front of the rider).
On a coarse raster a steep step no longer fits into an int8 as dm (±12.7 m; at 200 m
that would be 6.35 %). TrailBridge then sends DELTA_SCALE_DM with the smallest value
with which all deltas fit (e.g. 2 = deltas in 0.2 m). With STEP_M = 25 the tag is
always missing; frames for climbs up to 5 km therefore look as before.
If a climb does not fit into one frame even with 250 m (small MTU, or more than 50 km),
the profile ends N steps before the summit. The next piece follows when the rider has
reached the middle of the piece sent -- on the same raster (same STEP_M, distance of
the start points a multiple of it), so that the firmware can join the pieces.
Position in the profile without an odometer: the firmware knows
REMAINING_DISTANCE_M from the navigation frame (0x08); the rider's position in the
profile is START_REMAINING_DISTANCE_M - REMAINING_DISTANCE_M metres from the first
point. This holds along the route, not as the crow flies, and survives reconnects. If the
value lies outside 0..N × STEP_M, the profile is stale or not reached yet.
Length and MTU: N depends on the negotiated MTU (payload ATT_MTU - 3):
N = min(200, payload - 20) (with ROLLING another 23 bytes for the flag and the climb tags: payload - 43); below 8 points (200 m) TrailBridge sends no profile. The
app takes the minimum over all connected devices and, without information, assumes the
256 that the firmware requests.
The firmware skips unknown tags as everywhere (respect the length).
Example¶
Profile from 4200 m of remaining distance, start at 34.5 m, three steps (+1.2 m, +1.3 m,
−0.2 m) -- older form, without 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
Rolling window with an announced climb: foot at 5000 m of remaining distance at 100.0 m, summit at 3500 m at 136.0 m (the points in between as above):
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 (behind the first point)
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
Without a climb: the same frame without the four tags 0x07..0x0A.
A long climb on a coarse raster: from 36 000 m of remaining distance, start at 1200 m, 200 m steps, 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)
Route overview service¶
A fourth BLE service, only active while TrailBridge plays an imported GPX route. It tells
what still lies ahead of the rider on the route: the destination, the waypoints of
the GPX file (wpt) and the climbs. Unlike the other services it is only read -- no
indicate, no CCCD, no heartbeat.
Why read instead of indicate¶
- The content rarely changes: when the route starts and whenever a waypoint or a summit is reached. The firmware computes distance and time to each entry from the nav frame and the anchors of the overview (see "Distance and time") -- the overview does not have to be transmitted again for that.
- The length: an indication holds at most
ATT_MTU - 3bytes (253). A value that is read may be up to 512 bytes long; the client fetches it in pieces (read blob, "long read").readValue()of the ESP32 BLE library does that by itself, with NimBLE as with Bluedroid. Ten waypoints with names do not fit into an indication, but into a read. - The in-flight gate: across all services only one indication is under way at a time. A large overview would hold up turn instructions; a read bypasses that.
A read lacks the signal "there is something new". OVERVIEW_REVISION (tag 0x0E) in the
nav frame provides it; that frame comes every second or every 5 s anyway.
UUIDs¶
- Service:
7ee954a6-7a19-4b48-b052-f00d00e01e58 - Characteristic:
f031faa3-083d-4818-9eca-38420be835d0Properties:READ
Like the other additional services not in the advertising, discovery after connecting.
Sequence¶
- A nav frame carries
OVERVIEW_REVISION, and the value differs from theREVISIONof the stored overview (or none is stored yet): read the characteristic. Not in the indicate callback --readValue()blocks. - Store the frame read together with its
REVISION. If the next nav frame shows a different revision, read again. - A nav frame without
OVERVIEW_REVISION(navigation from OsmAnd),NAV_NONEor the end of the connection: discard the overview.
TrailBridge always provides the new value first and then sends the nav frame with the new revision. The revision is 1..255 and counts on with every new overview, also across routes; 255 is followed by 1, 0 does not occur.
Frame format¶
Byte 0: protocol version (currently 1)
Byte 1: message type
Byte 2..: TLV entries (only for OVERVIEW)
| Value | Name | Meaning |
|---|---|---|
| 0x00 | HELLO | state after the app starts, no overview |
| 0x01 | OVERVIEW | overview, followed by TLV entries |
| 0x02 | OVERVIEW_NONE | no route active, no overview |
| Tag | Name | Length | Format |
|---|---|---|---|
| 0x01 | REVISION | 1 | uint8, 1..255 -- the OVERVIEW_REVISION of the nav frames this overview belongs to |
| 0x02 | WAYPOINTS_AHEAD | 1 | uint8, number of waypoints ahead, not counting the destination (255 = 255 or more) |
| 0x03 | CLIMBS_TOTAL | 1 | uint8, number of climbs of the whole route (0 = none, or the route has no elevation data) |
| 0x04 | WAYPOINT | 9 + N | one waypoint or the destination, see below; occurs several times |
| 0x05 | CLIMB | 11 | one climb, see below; occurs several times |
Value of WAYPOINT:
| Byte | Meaning |
|---|---|
| 0 | flags -- bit 0 = DESTINATION (the destination of the route), bits 1-7 reserved (0, to be ignored) |
| 1-4 | REMAINING_AT_M, uint32 LE: remaining distance of the route to the destination at this waypoint (0 for the destination) |
| 5-8 | REMAINING_TIME_AT_S, uint32 LE: remaining time to the destination at this waypoint, seconds (0 for the destination); 0xFFFFFFFF = unknown, the GPX has no time stamps |
| 9.. | name, UTF-8, at most 32 bytes, no null terminator |
Value of CLIMB:
| Byte | Meaning |
|---|---|
| 0 | number of the climb, 1-based in riding direction over the whole route |
| 1-2 | altitude gain from foot to summit, uint16 LE, metres |
| 3-6 | length from foot to summit, uint32 LE, metres |
| 7-10 | FOOT_REMAINING_AT_M, uint32 LE: remaining distance of the route to the destination at the foot of the climb |
If a CLIMB value is longer than 11 bytes, the firmware ignores the rest; it skips
unknown tags as everywhere.
Order and length: after the three header tags comes the destination, then waypoints
and climbs mixed in the order in which the rider reaches them (a climb counts at its
foot). Each of the two lists is therefore "nearest first". The frame is at most 512
bytes long. What does not fit is missing at the far end and moves up as soon as
something at the front is reached. WAYPOINTS_AHEAD and CLIMBS_TOTAL say how many
there really are -- if there are more than the frame contains, the list is shortened.
Waypoints¶
- The source is the
wptelements of the GPX file. A waypoint counts if it lies at most 100 m beside the route; it then sits at the nearest point of the route. If the route passes it twice, the first time counts. - A waypoint without a name is called "Wegpunkt N" (waypoint N) -- N is its number among all waypoints of the route, in riding direction from 1. The destination is called "Ziel" (destination).
- When the rider reaches a waypoint, it drops out of the overview (new revision). It only comes back when the rider falls back more than 100 m before it.
Distance and time¶
The overview only carries anchors. Distance and time follow with the last nav frame -- like the position in the elevation profile, without an odometer and across reconnects:
distance = REMAINING_DISTANCE_M - REMAINING_AT_M
time = REMAINING_TIME_S - REMAINING_TIME_AT_S (time anchor known)
time = REMAINING_TIME_S × distance / REMAINING_DISTANCE_M (time anchor 0xFFFFFFFF)
For the destination these are exactly REMAINING_DISTANCE_M and REMAINING_TIME_S in
both cases. A negative distance means: the waypoint has just been reached, the new
overview is on its way -- do not show it any more. A negative time is 0. In the third
line the product does not always fit into 32 bits -- compute in 64 bits; with
REMAINING_DISTANCE_M = 0 the time is 0.
Where the time comes from: if the GPX file has time stamps at its points,
TrailBridge takes the remaining time from them -- for REMAINING_TIME_S in the nav
frame as well as for the time anchors. For a recorded ride that is its time (every stop
counts for at most 30 s), for a planned route the router's estimate, provided the
router writes it into the file; it knows the climbs. BRouter (bikerouter.de) only writes
it if the profile sets assign showtime = true, otherwise only the total time is in a
comment. How fast the rider really is does not enter into this time.
Without time stamps REMAINING_TIME_S is the remaining distance divided by the smoothed
current speed, the time anchors are 0xFFFFFFFF, and the third line distributes the
remaining time by distance. That estimate knows nothing about the climbs in between.
Off the route REMAINING_DISTANCE_M and REMAINING_TIME_S stand still, and so do
distances and times.
Climbs¶
A climb is what "What a climb is" above describes; altitude gain and length apply to the
whole climb from foot to summit, even if the rider is already on it. The list begins
with the climb the rider is currently on, otherwise with the next one. At the summit the
climb drops out of the overview (new revision). Number and CLIMBS_TOTAL give "climb 3
of 7".
The distance to the foot is REMAINING_DISTANCE_M - FOOT_REMAINING_AT_M. If it is 0
or negative, the rider is already on the climb; the summit is then still
length + distance metres away (the distance is negative there). Climbs have no time
anchor. The profile of a climb comes as before through the elevation profile service,
500 m before its foot.
Example¶
Revision 3, two waypoints ahead, the second of two climbs not ridden yet, the GPX has time stamps: the destination, "Bäcker" 30.5 km and 6100 s before the destination, climb 2 (450 m of gain over 8.2 km, foot 22 km before the destination), a nameless waypoint 12 km and 2400 s before the destination:
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: no. 2, 450 m gain, 8200 m, foot at 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. If the nav frame reports REMAINING_DISTANCE_M = 36000 and
REMAINING_TIME_S = 7200 with it, the Bäcker is 5.5 km and 1100 s away, the foot of
climb 2 14 km, the nameless waypoint 24 km and 4800 s, the destination 36 km and
7200 s. Without time stamps in the GPX the three waypoints would carry FF FF FF FF as
their time anchor.
Navigation from a GPX route¶
When TrailBridge plays a GPX route itself, it sends perfectly normal NAV_UPDATE frames
in the navigation service (format above, unchanged), computed from the GPS position.
Differences from OsmAnd: no lanes (0x0A-0x0D), street names only if the GPX contains
them. Off the route (> 50 m) MANEUVER = UNKNOWN (255) is sent, MANEUVER_DISTANCE_M is
then the distance to the route and STREET_NAME "Abseits der Route" (off the route).
Every frame also carries OVERVIEW_REVISION (0x0E), off the route as well.
REMAINING_TIME_S comes from the time stamps of the GPX if it has any, see "Distance
and time".
Sensor values and simulation mode¶
Besides the position, the position frame can also carry sensor values that
TrailBridge knows: heart rate (0x09), cadence (0x0A), barometric altitude (0x0C) and
power (0x0D); the speed is in SPEED_CMS (0x04). Whether the values are real or made up
is said by a tag of its own, so that both are possible side by side:
BARO_HEIGHT_DM is the altitude the bike computer otherwise has from its barometer, in
decimetres (ALTITUDE_M only has whole metres -- too coarse to compute a gradient from
it over a few dozen metres of distance). The gradient is not transmitted: the bike
computer computes it itself as always from altitude and distance covered. POWER_W is
the pedalling power of a power meter.
MSL_ALTITUDE_DM is the altitude of a real GPS fix above sea level, in decimetres
(Location.getMslAltitudeMeters(), Android 14 and later). ALTITUDE_M, in contrast, is the
altitude above the ellipsoid and is about 45-50 m off in Germany. The bike computer
calibrates its barometer against it ("GPS" button of the height calibration). The tag is
missing if the phone does not know the altitude above sea level, and for made-up positions
(SIM_POSITION). A GPS altitude is only good to about 10 m.
SIM_FLAGS (0x0B), uint8 bit field. If the tag is missing (or 0), everything in the frame is real.
| Bit | Name | Meaning if set |
|---|---|---|
| 0 (0x01) | SIM_POSITION | latitude/longitude/altitude/bearing are made up, not from the GPS chip (e.g. GPX test ride) |
| 1 (0x02) | SIM_SENSORS | SPEED_CMS is the speed of a simulated speed sensor; heart rate, cadence, barometric altitude and power are simulated -- and the absence of 0x09/0x0A/0x0C/0x0D means "this sensor does not exist" |
| 2-7 | -- | reserved, 0, to be ignored by the firmware |
Consequences for the firmware:
- Simulated data must never be treated as a real measurement. Ride data that arises
with SIM_POSITION or SIM_SENSORS set is to be marked as simulated in the log
(
LOG_SIMULATED). - SIM_SENSORS: only a simulator build (
BC_SIM) feeds speed, heart rate, cadence, altitude and power into its sensor simulator (SimSensors, likesim <km/h> <rpm> <bpm>; the altitude replaces the barometer, the gradient results from it); the normal firmware ignores the values, so that no fake kilometres end up in odometer and statistics. End of the simulation = the next frame without SIM_SENSORS,POSITION_NONE, a stale fix (FIX_AGE_MS> 5 s) or the end of the connection -> "sensors off". - Without SIM_SENSORS 0x09/0x0A/0x0C/0x0D are real sensor values passed on by TrailBridge (e.g. a heart-rate strap on the phone). Not built yet -- the app does not send anything like that so far, the firmware does not evaluate it. The tag and the semantics are already fixed here so that this can be added without breaking the contract.
- The time (
UTC_TIME_MS) stays the phone's real time in the simulation as well -- the bike computer's clock is supposed to stay right.
GPX test ride¶
TrailBridge can "ride" the loaded GPX route instead of sending the real GPS position:
once a second a fix along the route, with SIM_FLAGS = 0x03 (SIM_POSITION |
SIM_SENSORS). SPEED_CMS, HEART_RATE_BPM, CADENCE_RPM, POWER_W come from the GPX
(timestamps, TrackPointExtension hr/cad, power); where they are missing, from the
calculation or -- on request -- from randomly varying values that fit the route (heart
rate follows the load, cadence drops on the climb, 0 when coasting/standing; power
results from speed, gradient and acceleration, 0 when coasting); without emulation the
tag is missing, the sensor "is not there". BARO_HEIGHT_DM is the smoothed altitude of
the route (missing if the GPX has no elevation data). If the test ride is paused, frames
keep coming: speed 0, cadence 0. Navigation and elevation profile service run from the
simulated position as on a real ride. The end of the test ride is the first frame without
SIM_FLAGS or POSITION_NONE.
Example, test ride at 21.6 km/h, heart rate 132, cadence 85 (excerpt, only the last tags of the frame):
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
Why indicate instead of notify¶
With indicate the receiver (ESP32) confirms every packet at ATT level, with notify it does not. A missed turn instruction is more annoying than a few milliseconds of extra latency -- hence indicate.
Compatibility¶
Byte 0 (version number) allows the firmware to recognise a future protocol v2 and, if necessary, fall back to a simpler rendering instead of displaying misinterpreted data.