windows: heart rate works — request a 1691-byte MTU on the AAP channel - #9
Merged
Merged
Conversation
… 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>
This was referenced Sep 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
0x1001without an MTU option, so the inbound MTU stayed at the 672-byte L2CAP default.devmotion6(~521 B) andactivity(~573 B) made it,HEARTRATE/HEARTRATEv2(~920 B) never did. So service 19 answeredkIOReturnBadArgument, 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.How it was found
android/rewrite: init sequence, featuresd7, country code, magic keys, seq encoding, noHRM_STATE, and an authenticated+encrypted channel. The result was always ACK and 0 samples.MTU: 1691and a 921-byte HR descriptor arrives.ConfigInis the inbound direction: it becomes the MTU option of our Configure Request.ConfigOutonly caps what we send. This was verified on the air; the first attempt usedConfigOutand changed nothing.Changes
L2cap.c):ConfigIn.Flags = CFG_MTU,Mtu = {672, 1691, 1691}. The channel is also openedCF_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.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 tails10 00 80and20 80 00(upstream PR Add experimental AirPods heart-rate monitoring and RSSI librepods-org/librepods#702).aap.rs/main.rs: service0x0Eis 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
Follow-ups (not in this PR)
drivers/aap/prebuilt), which still has the old 672-byte MTU.CF_LINK_AUTHENTICATED | CF_LINK_ENCRYPTEDare needed, or MTU alone suffices.windows/docs/heart-rate.md(done in 8b59487, together with the README lines).BadArgumenton this firmware; 84→20 is the working path.🤖 Generated with Claude Code