AI-generated — show prompt
A dark, moody technical illustration of a reverse-engineering bench seen across a desk. On the desk: an open laptop whose screen shows a decompiled protobuf schema (message RunningDynamics { float vertical_oscillation = 1; int32 cadence = 2; float ground_contact_time = 3; }) above scrolling rows of hex bytes and BLE packet captures; a small bare ESP32 dev board wired up with a few jumper leads; a USB Bluetooth sniffer dongle with a tiny blinking LED; and a Garmin-style multisport watch lying face-up, its round screen glowing amber with Running Dynamics readouts (vertical oscillation 8.1 cm, cadence 165 spm, ground contact 280 ms). A single bright electric-blue Bluetooth signal arc connects the ESP32 and the dongle to the watch, with faint hex bytes and protobuf field names streaming along it. Color palette: deep navy (#101218) background, electric blue (#468CDC) for the Bluetooth link and the on-screen code, warm amber for the watch readout. Technical illustration style, slightly stylized, cinematic low desk lighting, 16:9 landscape, with darker uncluttered space along the top suitable for text overlay.
A bare ESP32 dev board that has never been near a human chest made my Garmin Fenix 8 display vertical oscillation, ground contact time, left/right balance, and cadence as native Running Dynamics: on the watch’s own activity screens, written into the FIT file’s native fields, the same ones a real Garmin strap fills.
I should say this up front: I’m not a reverse engineer. I knew what needed to happen (capture the strap, decode the transport, find where the watch decides a sensor “can do RD”, reproduce that) but mostly not how to do each step. So I directed and the AI executed. Without it I doubt I’d have finished this. On its own, though, it had no idea where to go: it would chase a wrong theory for days until I pulled it back. That split, me on direction, the model on mechanics, is as much the story here as the protocol is.
Getting there took a couple of weeks: a chest strap worn while jogging around my kitchen, a Bluetooth sniffer that saw nothing useful, a couple dozen throwaway probe scripts, a wall I couldn’t get through by reading the strap, and a decompiled Android app that broke it open. It ended up working both directions, too: once the fake strap could write RD into a watch, I got a pure Python client to read real Running Dynamics back out of an HRM 600 with no Garmin watch in the link.
This is the follow-up to Two BLE identities on one nRF52832. That post got one chip to be two Bluetooth devices so a Garmin watch pairs my sensor as a heart-rate monitor and a Connect IQ data field reaches it too. This is the prerequisite I skipped over there: working out what the strap actually says on the wire so my sensor can say it too. I said I’d open-source it. Here it is.
What I was actually after
I’m building a chest running sensor, and the pitch is that a Garmin watch treats it as one native sensor: HR, pace, cadence, and the full Running Dynamics set (vertical oscillation, ground contact time, balance, vertical ratio, step length) flowing into the native screens and the FIT recording over a single connection. No second app, no second pairing.
Standard Bluetooth can’t do that. A watch takes HR from any strap (0x180D) and pace/cadence from any footpod (Running Speed and Cadence, 0x1814), but that’s two logical sensors and zero Running Dynamics: no standard BLE profile carries vertical oscillation or ground contact time. RD isn’t fully locked, to be fair: over ANT+ a non-Garmin sensor can feed it to a Garmin watch today. The lock is specifically the Bluetooth path, and I need Bluetooth, on hardware that ships BLE-only. So the target became the HRM 600, the current strap that does all of it over one BLE link, and the job was to learn how it earns that “yes, you can do RD” verdict from the watch.
What Gadgetbridge already mapped
A big chunk of Garmin’s Bluetooth stack is already mapped, and that part isn’t mine. Gadgetbridge has documented the transport in detail: GFDI (Garmin Fit Data Interface), the Multi-Link multiplexer, the COBS framing, the protobuf “Smart” container. But all of it is the watch-to-phone direction, with a phone standing in for Garmin Connect. The reverse, a sensor pushing data up into a watch, is barely touched: the closest public work I found was an ESP32 exposing a plain HR service (0x180D), exactly the heart-rate-only baseline I wanted to get past. Nobody had made a device speak Garmin’s multiplexer to push Running Dynamics into a watch. That’s the part I had to work out.
The bench, and the first surprise
The first job was the boring part: pair the strap to a box I control (a Raspberry Pi 4 with a CC2652P dongle running Sniffle), pull the bond key, dump the GATT tree. The HRM 600 pairs LE Secure Connections, Just Works (no display, so no numeric comparison), key size 16. Standard.
The Pi’s own Bluetooth couldn’t even see the strap while WiFi was up: the Cypress combo chip starves BT receive windows, so I needed an ugly “rfkill block wifi, pair, unblock” dance script. With WiFi off it showed up at -35 dBm instantly.
The GATT tree had the prize: a proprietary Garmin service 6a4e2800-667b-11e3-949a-0800200c9a66 with three notify/write characteristic pairs (0x2810/0x2820, 0x2811/0x2821, 0x2812/0x2822), the Multi-Link service Gadgetbridge documents. I wore the strap, jogged in the kitchen for three minutes, subscribed to all three notify characteristics, and got:
HR (0x2A37): 362 notifications, 63-131 bpm ← standard, working
RSC (0x2A53): 361 notifications, real speed/cadence ← standard, working
0x2810/11/12: ZERO notifications ← nothing
The proprietary streams stayed silent. Subscribing isn’t enough: something has to switch them on, and that activation only happens between a real watch and the strap, encrypted, where I couldn’t see it.
Why the sniffer was useless
Obvious next move: sniff a real Fenix 8 pairing and read the activation off the wire. It’s encrypted (LE Secure Connections, bonded), so every application byte is ciphertext without that bond’s keys. Sniffle gave me advertising, the pairing handshake, timing, reconnects: everything except the watch-to-strap conversation I needed.
The capture wasn’t a total loss. I pulled the FIT file off the watch after a short run: no surprise that the strap’s RD landed in native FIT fields rather than developer fields (that’s the normal flow, and the whole reason this path is worth having), but it pinned down which fields:
stance_time 257-269 ms ← proprietary, ground contact time
stance_time_balance 35.59-43.68% ← proprietary, L/R balance
vertical_oscillation 22.2 mm ← proprietary
vertical_ratio 0.46% ← proprietary
step_length 49 mm ← proprietary
Plus three FIT message types my parser had no names for (unknown_233, unknown_534, unknown_325). The output was never in doubt: a real strap does this every run. The question was the protocol that produces it.
The probe saga: brute force meets Gadgetbridge
With no way to read the activation, I poked the strap directly from Python (bleak), wearing it and running while a script hammered the Multi-Link channels. A couple dozen throwaway probes over two days, each a small hypothesis, JSONL capture logs piling up.
Multi-Link is a real multiplexer with a register protocol: write a REGISTER frame naming a service code, the strap hands back a handle, that service streams on it. Gadgetbridge’s CommunicatorV2.java gave me the frame layout and the service-code enum (1 = GFDI, 6 = real-time HR, and so on). My early probes failed for a dumb reason: I misread status=0 as failure when it means success, and burned three probes on “nothing works” while registration had been fine the whole time.
Registering GFDI (service 1) produced a heartbeat: one 37-byte message every 5 seconds, COBS-framed (Garmin’s variant, leading and trailing 0x00), wrapping a GFDI envelope of type 5024 DEVICE_INFORMATION with “HRM 600” in it twice. An idle “here’s who I am” ping, not RD.
I enumerated every service code from 1 to 128. The strap accepts a small set ({1, 4, 6, 8, 10, 15, 22, 24}) and rejects the rest. None streamed RD on its own: 15 is an echo channel, 22 knows one opcode, 24 hangs up on bad input. No secret RD service code hiding at a high number, so activation wasn’t a service code at all.
The breakthrough came from sending the SystemEvents Gadgetbridge fires on connect. I rebuilt the GFDI codec in Python (CRC-16 and COBS ported out of Gadgetbridge, about 60 lines), sent a SYNC_READY, and the strap acked, then pushed back two protobuf requests of its own:
PROTOBUF_REQUEST 1, body ends: f2 01 0a 0a 08 0a 02 08 16 0a 02 08 17
^tag30 ^=22 ^=23
PROTOBUF_REQUEST 2, body: 6a 02 72 00
^tag13 ^empty nested tag14
The strap was asking me something, and it referenced 22 and 23. So activation isn’t a magic byte written to a handle: it’s a protobuf request/response conversation the strap starts after the watch announces itself.
The wall that would not move
I knew the question. I didn’t know the answer, so I guessed four ways: echo the request back; a convention-correct “status OK”; a GFDI-level ACK only; that ACK plus a suspected trailing byte. All four got accepted at the BLE level and ignored at the application level: the strap re-sent its two requests every 5 seconds and disconnected after three rounds, identically for every shape.
That sameness was the tell. Bad framing would fail differently per byte layout; identical failures meant the strap was rejecting my content, not my framing. I was sending a syntactically valid message that meant nothing. The meaning of Smart.field_30 lives inside closed Garmin firmware, and no amount of staring at the wire would reveal it. The hardware had told me everything it could.
The decompile that broke it
What broke the wall ran offline, no strap connected: I decompiled the Garmin Connect Android app. It ships its protobuf schema as generated Java. The 312 MB APK decompiled to about 57,000 files, including com/garmin/proto/generated/ with 180 protobuf classes (Gadgetbridge has 16 of them), field numbers preserved. So I grepped for the two mysteries:
FIELD_NUMBER = 30 → GDIEventSharingExtension.java → Smart.field_30 = EventSharingService
FIELD_NUMBER = 14 → GDICore.java → CoreService.field_14 = CONNECTION_READY_NOTIFICATION
Smart.field_30 is Garmin’s EventSharing service, a subscribe/notify alert system. The strap had sent me a SubscribeRequest, and the two numbers it referenced were alert types:
alert type 22 = ACCESSORY_UTILITIES_ACTIVITY_STATE (activity start/stop/pause)
alert type 23 = RUNNING_ALGORITHM_INPUT (watch → strap input)
The name RUNNING_ALGORITHM_INPUT is watch-to-strap input, not the cooked output: the dynamics come back up on a different alert, type 21 RUNNING_MEASUREMENT, which I only got straight later from the proxy. The real prize was the payload schema, GDIRunning.Dynamics, with units baked into the field names:
field 1 vertical_oscillation_1_4ths_mm (units of 0.25 mm)
field 2 ground_contact_time_ms
field 3 stance_time_1_4ths_percent
field 4 ground_contact_balance_1_32ths_percent (units of 1/32 %)
field 5 vertical_ratio_1_32ths_percent
field 6 step_length_mm
field 8 cadence_1_32_strides_per_min
field 9 step_count
Every metric from the FIT file mapped to one of these, scaling and all. STEP_SPEED_LOSS_DATA (field 11) is the unknown_233 my parser couldn’t name. The decompiled schema was a better source than the wire ever would have been: it carries Garmin’s own field numbers and unit names.
The MTU wall, and the bit I got wrong
Now I had the exact 17-byte protobuf to send back, and couldn’t deliver it the way I was trying. The SubscribeResponse envelope is 41 bytes; the ATT MTU sat at the BLE default of 23 (20 data bytes per write). The strap never initiates MTU negotiation, and neither bluez nor CoreBluetooth did by default in my probes, so anything over ~20 bytes hung or got refused. I confirmed it on both stacks and decided the read side was a dead end.
That was wrong, and the mistake wasn’t the protobuf, it was the startup path. I’d been waking the strap with a SYNC_READY/5043 protobuf-transport shortcut, but a real watch does the early setup with compact Core/EventSharing frames. The working client flow: ACK DEVICE_INFORMATION, answer the strap’s 0x8132 preamble with a Core FeatureCapabilitiesRequest, ACK the response, subscribe to strap-side type 20 and 21, answer the strap’s 22/23 subscription, feed type 22/23 input. Replay that watch-side flow and the real strap streams type 21. One live Mac run:
gfdi_compact_smart=664
field1013_heart_rate=362
field1014_running_measurement=254
type21_nonzero_while_moving=166
So an open central can read the real HRM 600’s RD stream. The server side (OpenRD) pushes fake RD into a watch; the client side pulls real RD out of the strap.
The firmware track that went nowhere
In parallel, mostly through Codex, I tried to pull the HRM 600 firmware for Ghidra. That whole track produced nothing usable, and I’d rather say so than pretend this was a clean line of wins. Garmin’s update API serves no HRM 600 firmware (5.20 was the launch version, and the API only offers deltas to older versions that don’t exist). I replayed Garmin Express requests and pulled Fenix 8, Epix Pro, and Edge 1050 packages: every modern one is a .gsp wrapping a single high-entropy bundle.gsp (~7.9999 bits/byte, encrypted, not Ghidra-loadable). An old loose Fenix 5S .gcd did decompile and confirmed the watch implements Multi-Link in firmware (discovery paths for all three 0x281x/0x282x pairs, strings like Run Dynamics and the wonderful Cannot download HR data over BLE. Switch to ANT.), but it’s the wrong generation for the modern EventSharing path. Net firmware contribution to the result: roughly zero. The APK was the way through, not Ghidra.
Being the strap: the transparent proxy
To see the real conversation decrypted, I built a transparent proxy on an ESP32: central toward the strap, peripheral toward the watch, forwarding every byte. A proxy isn’t a sniffer: it’s a real endpoint on both legs, so it decrypts each side because it is each side.
It needed one non-obvious fix. When the watch dropped mid-setup, the proxy kept the upstream strap link alive, so the strap advanced its reliable state while the watch was gone and the next watch connection landed mid-stream. Tying the links together (watch drops, drop the strap too, rebuild) fixed it, and the full flow fell out:
watch → strap register Multi-Link service 1, get handle 0x81
strap → watch GFDI DEVICE_INFORMATION (5024)
watch → strap ACK (5000), identifies as "fenix 8 - 51mm"
strap → watch 0x8132 session/core preamble
watch → strap Core FeatureCapabilitiesRequest (running?)
strap → watch FeatureCapabilitiesResponse: running supported
... EventSharing subscribe 22/23, then 20/21, both SUCCESS
strap → watch type 20 HR + type 21 Running Measurement, ~1 Hz
And the cooked dynamics from a real run:
vertical_oscillation: 80.0 mm ground_contact_time: 298 ms
stance_time: 39.5% balance: 43.06%
cadence: 77.56 strides/min speed: 2.42 m/s distance: 381.75 m
The protocol, the part that’s actually complex
Stripped down, the stack the watch insists on is four layers:
Garmin Multi-Link one GATT service multiplexing several Garmin "services"
└ GFDI envelope [len:2 LE][msg_type:2 LE][counter:2 LE][payload][crc16:2 LE]
└ COBS Garmin variant: leading + trailing 0x00
└ Smart compact protobuf container (the "Smart" message)
└ EventSharing alert subscribe/notify, types 20-23
The hard part isn’t any single layer. It’s that the watch will pair, encrypt, show HR, even show a half-right “HRM Pace & Distance” shell, and still silently refuse RD because one thing upstream was off. RD only switches on if the watch both sees FeatureCapabilitiesResponse say “I do Running” and then subscribes to alert type 21. Miss the capability handshake and you get a sensor that looks 90% working and is 0% useful.
And RD is a two-way conversation: the watch streams algorithm input down (type 23: GPS speed, grade), the strap streams cooked dynamics up (type 21). The units are gloriously weird, vertical oscillation in quarter-millimetres, balance in 1/32 of a percent, cadence in 1/32 strides per minute. Get the scaling wrong and the watch shows garbage or drops the record.
The bugs that ate days
Three details cost more time than the entire decompile, and none are written down anywhere.
0x39 vs 0x3a. Compact GFDI frames come in two flavours: request/notification ends 0x39, response 0x3a. I sent FeatureCapabilitiesResponse as 0x39 because it carried a protobuf payload like everything else. The watch stayed connected, kept sending algorithm input, looked happy, and never subscribed to type 21. The fix was one byte, plus echoing the request’s counter back. That’s reverse engineering in one line: working versus not is one byte you find by staring at two captures side by side.
The 0x8132 preamble. After the device-info ACK, the strap sends a session/core frame before the watch will ask for capabilities. Skip it, send an unsolicited capabilities response, and the watch takes the link but never enters the RD path. The ordering is load-bearing.
Type 20 isn’t standard HR. My simulator sent standard BLE heart rate (0x2A37) and type 21 RD, but not Garmin EventSharing HR (type 20). The real strap sends both, and until I emitted type 20 too the watch wouldn’t fully classify the device. Same number, two different channels, and the watch wants both.
OpenRD: the result
With the flow mapped I dropped the real strap. OpenRD is an ESP32 (NimBLE) that implements the GATT layout, the Multi-Link transport, the GFDI envelopes, the EventSharing handshake, and a synthetic motion profile (walk, easy, steady, tempo, recovery, looping every 240 s) driving HR, RSC, type-20 HR and type-21 RD from one coherent invented run. Off the Fenix activity screen, matched to the firmware log:
cadence 165 spm
left/right balance 50
vertical oscillation 8.1 cm (firmware log: vo=81.2mm)
ground contact time ~280 ms
pace ~6:40/km
Native Running Dynamics on the real screens, from a dev board feeding made-up data over one connection.
One result I didn’t expect: branding isn’t the gate. I rebuilt OpenRD with a fresh non-Garmin static address, no Garmin scan response, debranded device-information, and an accessory string of OPENRD-001, and the watch still gave it the full HR + Pace/Distance + RD treatment. The gate is the GFDI/EventSharing protocol, not the product identity. A sensor doesn’t have to pretend to be a Garmin device to integrate natively; it just has to speak the protocol.
The repo also has the inverse: scripts/hrm600_client.py, a Python/Bleak client that reads real type-21 packets off an owned HRM 600 and can feed it 22/23 input. That’s probably the most useful piece for Gadgetbridge, and I’m planning to propose the sensor-side RD support upstream as a PR: they map the watch-to-phone direction, the strap-side read and the RD decoder are the missing piece.
What the AI couldn’t do
I said up top this was AI-directed, so here’s the sharp edge of it. The models were great at the grind: diffing hundreds of log lines for the one byte that changed, porting CRC-16 and COBS out of Gadgetbridge, grepping 57,000 Java files for FIELD_NUMBER = 30. They were not the source of a single insight, and twice they rode a wrong model for days (the type 21/23 direction, and an “early real pace reading” that was actually my own firmware’s synthetic output).
The one thing that mattered most was skepticism. The watch showed cadence ~160 and GCT ~300 ms off my fake device and the model called it done. I didn’t: cadence the watch gets from its own motion sensor, and even ground contact time it could be computing itself rather than taking from my device, so a plausible number on screen proves nothing about where it came from. The proof was the watch showing the exact synthetic values I was injecting (vo=81.2mm as 8.1 cm, gct=281ms as ~280 ms): it can’t invent my arbitrary inputs, so it had to be relaying the type-21 stream. Prove it in the data.
What I’m not publishing
This is interoperability research on hardware I own. The repo has the protocol notes, the decoders, and the firmware. It has no Garmin firmware, APK decompiles, raw captures, FIT files, or bond keys. OpenRD identifies as itself, not as a Garmin product. Garmin is a trademark of Garmin Ltd., and this isn’t affiliated with them. Credit to Gadgetbridge for the transport groundwork.
Code and full protocol notes: codeberg.org/samdumont/openrd-ble-running-dynamics
Still open
- the exact meaning of every
0xFE1Fadvertising byte (it varies on the real strap and I haven’t proven what it gates) - accessory utility type 22 semantics
- calibration and reset flows
step_speed_loss_data(field 11): a real schema field the strap advertises but never sent in my captures- whether every watch firmware version behaves the same
If you’re chasing the same Garmin RD path from a non-Garmin device, the protocol notes should save you the worst of the dead ends. The other half, making one chip carry all this and a custom data field without the connections fighting, is in the nRF52832 post.