Skip to content

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.cpp therefore keeps its basic structure, only the parser changes)

UUIDs

  • Service: f7ac2b76-986b-45fd-8e44-f116a61f319d
  • Characteristic: 7473da02-2de8-4f48-9e46-21b36380c176 Properties: 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 0x0200 to 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

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-9ba6c385db0d Properties: 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

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. 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. It is event-driven: TrailBridge sends the profile of a climb as soon as its foot is at most 500 m ahead -- from the rider's position to the summit, in one frame -- and sends PROFILE_NONE when the summit is reached (and no further climb follows directly), the route ends or the rider leaves the route. No heartbeat -- the frame sent last stays available by read and goes to newly subscribing devices.

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.

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. The defaults of both sides must match.

UUIDs

  • Service: 3c1f6a90-5b2e-4d7a-9c48-e0a1b7d25f63
  • Characteristic: a84e0d17-6f3b-4c52-8e9d-1b70c2f4a596 Properties: 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

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); 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):

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

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)

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).

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.

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, like sim <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.