Skip to content

Add experimental AirPods heart-rate monitoring - #702

Open
thibaup wants to merge 13 commits into
librepods-org:mainfrom
thibaup:heart-rate-monitoring
Open

Add experimental AirPods heart-rate monitoring#702
thibaup wants to merge 13 commits into
librepods-org:mainfrom
thibaup:heart-rate-monitoring

Conversation

@thibaup

@thibaup thibaup commented Aug 3, 2026

Copy link
Copy Markdown

Adds an experimental Heart-rate test under Settings. It initializes the RTBuddy HEARTRATE stream and shows structurally validated samples in a live graph based on the protocol work in https://github.com/tomppi/airpods_rtbuddy_v37_probe. I also fixed a compatibility issue found during testing.

Tested with AirPods Pro 3 on a OnePlus 15

arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 7, 2026
…epods-org#702)

hr.rs = a faithful Rust port of RtBuddyHeartRateDecoder (frame reassembly across
chunks + a bounded protobuf/varint parser + the full HEARTRATE validation: log
types {1,3}, service 19, 18-byte payload, known status tail @15, BPM @offset 1 in
30..=220). aap.rs gets the enable sequence (AACP 1.3 CONNECT/CAPABILITIES init +
HRM_STATE 0x30 + the RTBuddy START/STOP frames). IPC gains Snapshot.heart_rate +
Command::SetHeartRate{on}. run_receiver feeds each recv chunk to the decoder only
when HR is on (opt-in, off by default — it drains battery), publishes new BPM via
push_state, and re-arms/clears across reconnects. Pro 3 only; experimental —
needs on-hardware verification of the init timing + the stop path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 7, 2026
…epods-org#702)

The RTBuddy stream almost never starts on the first enable (frame_prefix_hits=0 on
hardware). Mirror the Android HeartRateMonitor: on SetHeartRate{on}, a dedicated
retry thread runs a 15s campaign — each attempt STOP-resets + re-sends the init
frames + HRM_STATE + START, then polls hr_got_sample for 8s; on the first decoded
BPM it goes live (run_receiver keeps decoding), else it backs off (500/1000/2000ms)
and retries until the window is spent. hr_got_sample is the run_receiver<->retry
rendezvous; hr_retrying guards against stacking two campaigns. No L2CAP reconnect
(service-level re-enable only). Reconnect re-arm goes through the same path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 7, 2026
Captured from a real iOS Fitness/Strava HR session via PacketLogger: at workout
start the iPhone sends 04 00 04 00 44 00 04 00 02 00 03 07 (opcode 0x44, value
0x07) alongside the SensorDataWX frames — a command LibrePods never mapped. The
PR librepods-org#702 enable (HRM_STATE + START) only yields empty status heartbeats; this is
the suspected 'enter workout sampling mode' trigger. Sent right before START,
mirroring the iPhone ordering. Experimental — validating on hardware.
arctumn added a commit to arctumn/librepods that referenced this pull request Aug 8, 2026
Cross-checked against upstream PR librepods-org#702, the Android implementation tested on
AirPods Pro 3 and the only client known to actually produce heart-rate samples.
Its start frame is:

  17 00 00 00 10 00 10 00 08 E3 46 42 0B 08 13 10 02 1A 05 01 40 42 0F 00

That is form A with the bare 0x13 — identical to the fresh-session iOS capture
and to the original constant in this file, differing only in the sequence
varint. Three independent sources now agree on the frame.

But its enable sequence has a step this daemon lost:

  connect0 -> caps0 -> connect4 -> caps4 -> HRM_STATE(0x30) -> start frame

39fde48 removed HRM_STATE, justified by my finding that control id 0x30 appears
zero times in the iOS captures. The observation is correct and the inference was
wrong: iOS is a reference for the wire format, Android is the reference for a
working client. Restored, in Android's position.

Also noted for follow-up, not implemented here: Android stops head tracking and
waits 220 ms before the first heart-rate start, because both live on the 0x17
service. This daemon has no head-tracking conflict handling at all.

NOT COMPILED -- cargo is unavailable here and the daemon is Windows-only.
@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

Hi @thibaup — thanks a lot for this PR, it's been an excellent reference.

I'm working on the cross-platform (Windows) port of LibrePods (#710) and tried to bring up heart-rate using the exact sequence from this branch. I can now see both sides of the conversation, and I'm stuck at a point where the AirPods accept the stream but never send a reading — so I wanted to compare notes with someone who has it working.

Setup

  • AirPods Pro 3 (A3063), AAP Firmware Version (Active) = 81.2675000075000000.6877 (Marketing Version 8454800), standalone — iPhone powered off, no other Apple device connected.
  • On Windows the AAP channel (L2CAP PSM 0x1001) is reached through a small kernel profile driver (the MS Bluetooth stack doesn't allow a user-mode L2CAP client connect), but everything above the transport is identical to the Android/Linux path.
  • Captured with btvs.exe (Microsoft Bluetooth Virtual Sniffer) → Wireshark, using the pabloaul/apple-wireshark aacp + rtbuddy dissectors, so I can read both directions.

What I send (mirroring HeartRateMonitor + AACPManager)

  1. connect_service_0capabilities_service_0
  2. connect_service_4capabilities_service_4
  3. stop head-tracking (SensorDataWX service 0x0e, period 0) — as beforeFirstStart does
  4. HRM_STATE (control 0x30) = true04 00 04 00 09 00 30 01 00 00 00
  5. HEART_RATE_START_1S04 00 04 00 17 00 00001000 1000 08 <seq> 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

The dissector confirms all of these go out, in this order, including the 0x30 control ("Heart Rate Monitor").

What comes back

The AirPods accept everything but never emit a sample:

  • They return the descriptor dump (AccessoryService: devmotion6, HID report descriptor, etc.).
  • They ACK the head-tracking stop: SensorDataWX … Field(9): 08 0e (service 14).
  • They ACK the heart-rate stream: SensorDataWX … Field(9): 08 13 (service 19) — every single enable cycle.
  • But across ~9 minutes and several enable/disable cycles, zero SensorDataWX data frames arrive — every service-19 frame is the ~8-byte ACK, never a sample frame. Decoder reports type19 = 0, no BPM.

So the session, descriptors, and stream request are all accepted; the sensor just never streams.

Questions

  1. On your working setup, do samples start flowing immediately after the 08 13 ACK, or is there another trigger/frame between the ACK and the first data frame?
  2. Does it need an active session/foreground state on a host (HealthKit / a running consumer), or does it truly stream standalone with just HRM_STATE + START?
  3. Any in-ear / fit gating before samples appear — does it need both buds worn, still, skin contact?
  4. Roughly how long from START to the first sample on the OnePlus, and are the values sustained or intermittent?
  5. Anything you send that isn't obvious from this branch (a periodic keepalive, a second control command, a capabilities re-query) that could be the missing piece?

I can share the pcaps / exact byte sequences if that helps. Thanks again for the great work!

@thibaup

thibaup commented Aug 8, 2026

Copy link
Copy Markdown
Author

@arctumn

  1. There can be a small delay after the 08 13 ACK before the first value comes in. Usually it's pretty quick though. That's why I currently use an 8 second window before considering the start failed.

  2. It doesn't need Health Connect or anything like that running. That only receives the HR values afterwards. The AirPods stream directly to LibrePods.

  3. At least one airpod has to be in-ear.

  4. Normally I get the first value within those 8 seconds, usually quite a bit faster. After that the values are sustained, roughly following the 1 second interval.

  5. I'm not sending any extra keepalive or another HR command after START.

if you're getting the 08 13 ACK every time but never any actual samples, a full disconnect/reconnect might be worth trying as well.

Feel free to send the pcap/bytes too, I can compare them with what I'm doing.

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

Thanks, that's really helpful — especially points 2 and 3.

I implemented and tested your suggestions:

  • Aligned my sequence to yours, including stopping head tracking up front (like beforeFirstStart) before the session init.
  • Added the full disconnect/reconnect escalation: after a few enable retries that only get the ACK, I tear down and reopen the L2CAP channel, re-run the whole handshake, and re-arm HR on the fresh channel (repeating every ~45 s).
  • Tested with at least one bud firmly in-ear, iPhone powered off, no other Apple device connected.

Result: across ~3 minutes and several full channel rebuilds, the AirPods ACK the heart-rate stream (SensorDataWX … Field(9) TypeAck: 08 13) on every cycle, but never send a single data frame — no SensorDataWX with the cmd field (tag 3a, service 19 + payload). type19 stays 0 the whole time.

So the enable sequence, the in-ear requirement, and the disconnect/reconnect are all covered, and it still won't stream on this unit.

Here's my exact on-the-wire enable sequence (raw L2CAP payloads from a Windows btvs capture) — as far as I can tell it's byte-identical to your AACPManager constants apart from the seq varint, but you can compare directly:

# TX  stop head-tracking  (SensorDataWX, service 0x0e, period 0)  -- beforeFirstStart
04 00 04 00 17 00 00 00 10 00 10 00 08 86 01 42 0b 08 0e 10 02 1a 05 01 00 00 00 00
# TX  connect_service_0
00 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00
# TX  capabilities_service_0
04 00 00 00 01 00 00
# TX  connect_service_4
00 00 04 00 01 00 03 00 00 00 00 00 00 00 00 00
# TX  capabilities_service_4
04 00 04 00 01 00 00
# TX  HRM_STATE 0x30 = on
04 00 04 00 09 00 30 01 00 00 00
# TX  HEART_RATE_START_1S  (SensorDataWX, service 0x13, period 1s)
04 00 04 00 17 00 00 00 10 00 10 00 08 87 01 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

And what the AirPods send back — ACK only, then silence:

# RX  ACK service 0x0e (head-tracking)
04 00 04 00 17 00 00 00 10 00 08 00 08 0a 10 01 4a 02 08 0e
# RX  ACK service 0x13 (heart-rate)   <-- every cycle, then no data
04 00 04 00 17 00 00 00 10 00 08 00 08 0b 10 01 4a 02 08 13

My unit is an AirPods Pro 3 (A3063), firmware 81.2675000075000000.6877 — do you happen to know your firmware version? Given everything else matches, a firmware difference is my leading suspect now.

I have a sanitized pcap of one enable cycle (just the L2CAP frames, no identifiers) if you'd like to diff it against yours.
hr-enable-clean-zipped-because-gh-limitations.zip

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

I bet is some windows weird bluetooth black magic happening here because everything mimics my iphone, even when checked on my mac tracing the bluetooth packages between the airpod and the iphone.

And your android solution jsut working first time, this feels like a whole disaster from my side. already taked more time here thanm porting from linux, doing windows drivers for bluetooth and hi-rez audio.
😞

arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 8, 2026
…_descriptors

Match the working Android client (upstream PR librepods-org#702): the heart-rate enable now
sends control 0x30 (HRM_STATE=on) between the AACP 1.3 session init and the
stream start, and drops the request_all_descriptors frames (an iOS-capture
guess that never helped and Android does not send).

A btvs + Wireshark capture (rtbuddy/aacp dissector) confirms the AirPods ACK
service 19 every cycle but emit zero data frames on the A3063 test unit even
with 0x30 on the wire — so 0x30 is necessary to match Android but not
sufficient standalone here; the remaining gap is non-protocol (fit / another
Apple host holding the service / firmware). Kept because it mirrors the
working client.
arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 8, 2026
Mirror the working Android HeartRateMonitor loop, which the PR librepods-org#702 author
confirmed: the enable is complete (no keepalive, no HealthKit), at least one bud
must be in-ear, and a full disconnect/reconnect is worth trying.

- Only succeed on a decoded reading; ignore the bare service-19 ACKs (the failure
  mode is ACK-but-no-data), and re-send the full enable every ~8 s while HR is on
  instead of stopping after one attempt.
- After 4 ACK-only attempts, ask run_receiver to rebuild the L2CAP channel (drop +
  reopen in-process, so no driver code-38) and re-arm HR on it — the Android
  requestTransportRecovery.
- Cap that at 3 rebuilds, then give up ("Heart rate unavailable") so we don't
  reconnect forever; reset the budget when HR is re-toggled or on a new connect.
- Stop head tracking up front (beforeFirstStart), matching the author's order.

Tested (bud in-ear, iPhone off): the AirPods ACK service 19 every cycle but never
stream a data frame across several rebuilds — type19 stays 0. Firmware is the
leading suspect vs the author's working unit.
@thibaup

thibaup commented Aug 8, 2026

Copy link
Copy Markdown
Author

I wouldn't be surprised if this is some windows bluetooth weirdness.

There is one small difference though that might be worth testing:

Your HR start packet has:
... 10 00 08 87 01 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

while the constant I'm currently sending is:
... 10 00 08 e3 46 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

So the full packet on my side is basically:
04 00 04 00 17 00 00 00 10 00 10 00 08 e3 46 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

I assumed 08 e3 46 is just the protobuf sequence/request ID, so in theory changing it shouldn't matter

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

Tried it — I set my HR start seq to your exact 08 e3 46 (9059), so my packet is now byte-for-byte identical to yours:

04 00 04 00 17 00 00 00 10 00 10 00 08 e3 46 42 0b 08 13 10 02 1a 05 01 40 42 0f 00

Same result: the AirPods ACK service 19 on every cycle (and every channel rebuild), but never send a data frame — type19 stays 0. So it wasn't the sequence id.

At this point everything above the transport is identical to your client — session init, HRM_STATE, START (identical bytes), in-ear, disconnect/reconnect — and it still won't stream on my unit. Since the enable bytes match exactly, if there's still a difference it has to be below the protocol: either the L2CAP negotiation the Windows Bluetooth stack does (vs Android's), or a firmware difference on my unit.

Two things would help me nail down which, if you have a moment:

  1. Your firmware version — mine reports 81.2675000075000000.6877. If yours differs, that alone could explain it.

  2. A btsnoop HCI log of a working HR session on your OnePlus, if that's easy for you: enable Developer options → Enable Bluetooth HCI snoop log, start HR, let a few readings come in, then grab the log (via a bug report, or adb shell dumpsys bluetooth_manager / the btsnoop_hci.log). With that I can diff the L2CAP setup (MTU / mode / channel config) and the exact frame timing between your working stack and the Windows one — the enable is identical, so any remaining difference should show up there.

No pressure at all — you've already been incredibly helpful. Even just the firmware version would be a great data point. Thanks again!

@arctumn

arctumn commented Aug 8, 2026

Copy link
Copy Markdown

I think I might found something nice from the Android buetooth stack that might be a possible issue from my kernel.
My driver does not use ERTM (Enhanced Retransmission Mode) like the android one does!! Linux uses the same isntructions as us but android uses other thigns, let me rebuild my windows driver.
I'm going to try to implemnt ERTM from this lets 🙏 it works!
https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/bthddi/ns-bthddi-_brb_l2ca_open_enhanced_channel.

Quick update after going a fair bit deeper.

Since everything above the transport was byte-identical to your client, I chased the one thing left below it: the
L2CAP channel mode. Android/Fluoride opens its classic L2CAP sockets in ERTM (obex_l2c_etm_opt), whereas the Windows
side was negotiating plain Basic mode (a btvs capture confirmed our config carried only MTU + Flush Timeout, no
Retransmission-and-Flow option). So my theory was: maybe the AirPods only stream the continuous HR sensor data over an
ERTM channel.

I modified our kernel profile driver to open the channel with BRB_L2CA_OPEN_ENHANCED_CHANNEL requesting ERTM
(CM_RETRANSMISSION_AND_FLOW), test-signed it and captured the result. Two conclusions, both killing the theory:

  1. Windows bthport never actually puts the ERTM option on the wire when a profile driver requests it as an L2CAP
    client. Across two builds (retransmission/monitor timeouts at 0, then at 2000/12000 with a zeroed config and MPS > 0),
    our Configure Request still went out Basic-only, and the enhanced-open connect just fails. Looks like a hard
    limitation of the MS stack's client-side ERTM support.
  2. Even in the capture where the negotiation ran, the AirPods counter-propose Basic mode, so they're happy on Basic.
    Which means your Android side is almost certainly running AAP over Basic too (its ERTM request gets rejected and falls
    back), i.e. ERTM isn't the differentiator.

So I've now ruled out the enable sequence, the sequence id, in-ear, disconnect/reconnect, and the L2CAP mode. At this
point I'm fairly convinced it's a firmware difference on my specific unit rather than anything in the protocol or the
host. If you ever get a moment to check your firmware version I'd still love to compare, mine reports
81.2675000075000000.6877.

Either way, thanks a lot for all the pointers, the protocol side is 100% confirmed correct thanks to your help

arctumn pushed a commit to arctumn/librepods that referenced this pull request Aug 9, 2026
The upstream PR librepods-org#702 author flagged our incrementing sequence id as the one
remaining byte-level difference from his HEART_RATE_START_1S. Pin it to his exact
value (0x2363 = varint e3 46) so our start frame is byte-for-byte identical, and
so retries re-send the same constant like his client does. Tested: no change
(still ACK-only), but it removes the last byte difference.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants