Skip to content

windows: heart rate works — request a 1691-byte MTU on the AAP channel - #9

Merged
arctumn merged 2 commits into
windows-nativefrom
windows/heart-rate-mtu
Sep 29, 2026
Merged

arctumn merged 2 commits into
windows-nativefrom
windows/heart-rate-mtu

Conversation

@arctumn

@arctumn arctumn commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

Heart rate from AirPods Pro 3 now works on Windows. The blocker was not the AAP/RTBuddy protocol but the L2CAP MTU of the AAP channel.

  • Our driver opened PSM 0x1001 without an MTU option, so the inbound MTU stayed at the 672-byte L2CAP default.
  • The AirPods only publish the RTBuddy sensor services whose descriptor fits the MTU: devmotion6 (~521 B) and activity (~573 B) made it, HEARTRATE/HEARTRATEv2 (~920 B) never did. So service 19 answered kIOReturnBadArgument, 84/20 ACKed but never streamed, and no BPM ever arrived. This also explains the macOS user-space probe and the "only works with Bumble" note in discussion Heart Rate librepods-org/librepods#233.
  • Android (Fluoride) and Bumble request MTU 1691. With the same MTU, Windows gets the HR descriptors and 1 Hz BPM.

How it was found

  1. Ruled out every AAP-level difference to upstream android/rewrite: init sequence, features d7, country code, magic keys, seq encoding, no HRM_STATE, and an authenticated+encrypted channel. The result was always ACK and 0 samples.
  2. Drove the same Intel AX210 with Bumble (WinUSB). HR streamed immediately, and the buds pushed metadata for services 19/20 right after connect. Class of Device and pairing were ruled out as the gate.
  3. Compared descriptor sizes against the 672-byte MTU seen in older btvs captures, then confirmed with an HCI ETW capture that our Configure Request now carries MTU: 1691 and a 921-byte HR descriptor arrives.
  4. On bthport, ConfigIn is the inbound direction: it becomes the MTU option of our Configure Request. ConfigOut only caps what we send. This was verified on the air; the first attempt used ConfigOut and changed nothing.

Changes

  • driver (L2cap.c): ConfigIn.Flags = CFG_MTU, Mtu = {672, 1691, 1691}. The channel is also opened CF_LINK_AUTHENTICATED | CF_LINK_ENCRYPTED (mirrors android/rewrite). This is the configuration HR was verified with; whether HR strictly needs these flags is not yet isolated.
  • daemon hr.rs: drop PPG warm-up readings via the confidence byte (payload[2] >= 0x80; ~20 while warming up, 160–240 once locked). Also accept the iOS 27 status tails 10 00 80 and 20 80 00 (upstream PR Add experimental AirPods heart-rate monitoring and RSSI librepods-org/librepods#702).
  • daemon aap.rs / main.rs: service 0x0E is the ACTIVITY classifier, not head tracking, so it is renamed and no longer stopped before enabling HR. The first-sample timeout goes from 8 s to 12 s, because warm-up is now filtered. Stale "never streams" comments are updated.

The daemon's receive buffer is 8 KiB, so 1691-byte frames fit.

Test

  • AirPods Pro 3 (A3063/A3064), iOS 27 firmware, Windows 11, Intel AX210, driver + daemon from this branch.
  • Probe: START on 84 (answered as service 20) gave 40 valid samples in 40 s, 75–77 bpm, with confidence ramping 160 → 237.
  • App: Heart Rate toggle on showed live BPM (68 at rest, 132 during exercise), one sample per second, stable over minutes.
  • Retest with this branch's exact daemon (no experiment flags): stream live ~7 s after toggling on, first attempt, 81–82 bpm, confidence 236–238, 1 Hz.

Follow-ups (not in this PR)

  • Rebuild the committed prebuilt AAP driver (drivers/aap/prebuilt), which still has the old 672-byte MTU.
  • Isolate whether CF_LINK_AUTHENTICATED | CF_LINK_ENCRYPTED are needed, or MTU alone suffices.
  • Update windows/docs/heart-rate.md (done in 8b59487, together with the README lines).
  • Service 19 still NAKs BadArgument on this firmware; 84→20 is the working path.

🤖 Generated with Claude Code

arctumn and others added 2 commits September 29, 2026 21:18
… heart rate works

The AAP channel was opened with ConfigIn/ConfigOut.Flags == 0, so bthport sent a
Configure Request with no MTU option and the inbound MTU stayed at the 672-byte
L2CAP default. The AirPods only publish the RTBuddy sensor services whose
descriptor fits that MTU: devmotion6 (~521 B) and activity (~573 B) did, the
HEARTRATE/HEARTRATEv2 descriptors (~920 B) never did — so service 19 answered
kIOReturnBadArgument, 84/20 ACKed but never streamed, and no BPM ever arrived.

Android (Fluoride) and Bumble both request MTU 1691. Driving the same AX210 with
Bumble at MTU 1691 made the buds push the HR descriptors immediately and stream
BPM; setting the same MTU here fixes Windows (verified in the app: 1 Hz readings on
service 20, confidence ~235).

On bthport, ConfigIn is the inbound direction (it becomes the MTU option of OUR
Configure Request); ConfigOut only caps what we send — confirmed on the air with an
HCI ETW capture. The channel is also opened authenticated + encrypted, mirroring
upstream android/rewrite; heart rate was verified in this configuration, but whether
it strictly needs those flags is not yet isolated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng stream

- hr.rs: drop PPG warm-up readings by gating on the confidence byte (payload[2]
  >= 0x80; ~20 while warming up, 160-240 once locked) instead of trusting the
  30-220 range alone, and accept the two status tails the iOS 27 firmware added
  (10 00 80, 20 80 00; upstream PR librepods-org#702).
- aap.rs: stream 0x0E is the ACTIVITY classifier (its RTBuddy descriptor says
  "activity"), not head tracking — renamed to STREAM_ACTIVITY.
- main.rs: stop sending a STOP to 0x0E before enabling heart rate (it only turned
  off the activity classifier; HR streams fine beside motion services), give the
  first sample 12 s (warm-up is now filtered, first locked sample lands ~6-8 s),
  and update the comments that said the buds never stream heart rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@arctumn
arctumn merged commit 6fa7a1a into windows-native Sep 29, 2026
1 check passed
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.

1 participant