Add experimental AirPods heart-rate monitoring - #702
Conversation
…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>
…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>
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.
…nnecting when switching devices.
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.
|
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
What I send (mirroring
|
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. |
|
Thanks, that's really helpful — especially points 2 and 3. I implemented and tested your suggestions:
Result: across ~3 minutes and several full channel rebuilds, the AirPods ACK the heart-rate stream ( 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 And what the AirPods send back — ACK only, then silence: My unit is an AirPods Pro 3 (A3063), firmware I have a sanitized pcap of one enable cycle (just the L2CAP frames, no identifiers) if you'd like to diff it against yours. |
|
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. |
…_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.
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.
|
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: while the constant I'm currently sending is: So the full packet on my side is basically: I assumed 08 e3 46 is just the protobuf sequence/request ID, so in theory changing it shouldn't matter |
|
Tried it — I set my HR start seq to your exact Same result: the AirPods ACK service 19 on every cycle (and every channel rebuild), but never send a data frame — 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:
No pressure at all — you've already been incredibly helpful. Even just the firmware version would be a great data point. Thanks again! |
|
I think I might found something nice from the Android buetooth stack that might be a possible issue from my kernel. 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 I modified our kernel profile driver to open the channel with BRB_L2CA_OPEN_ENHANCED_CHANNEL requesting ERTM
So I've now ruled out the enable sequence, the sequence id, in-ear, disconnect/reconnect, and the L2CAP mode. At this Either way, thanks a lot for all the pointers, the protocol side is 100% confirmed correct thanks to your help |
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.
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