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.
a9a91bf to
5e20986
Compare
…ion to send out the heart rate data as an Android BLE server
android: add batched and configurable Health Connect heart-rate export
|
Heart rate is streaming here on AirPods Pro 3 (A3065), so adding a few data points — one of them is probably the thing blocking @arctumn. Setup: Xiaomi 14 Pro, HyperOS / Android 14, Snapdragon 8 Gen 3 ( ACK is not a useful success signalI spent a while in exactly @arctumn's state —
I originally thought the fix was to start head tracking manually first, but that's wrong — I ran it both ways (with and without an explicit head-tracking start) and both produced 39 samples, because the app starts head tracking on connect anyway. The condition is that RTBuddy actually came alive on that channel, not who woke it. @arctumn — this is the bit I'd check first: does head tracking produce real DATA frames on your Windows build, or only the There is a dead window right after connectThe first RTBuddy commands after a fresh channel are dropped without even an ACK: ~30 s of silence, then it works. Worth knowing for Service IDs are per-unit, don't hardcode them@arctumn's trace uses service So on this unit 19 isn't a legacy fallback, it's what the accessory advertises. Worth keeping the metadata path as primary. ERTM: ruled out, with the bytes@arctumn already concluded this from the Windows side, but here's the direct evidence from the L2CAP information exchange, in case it saves anyone else the detour: The AirPods don't advertise ERTM or Streaming at all, so no host can be using it with them — the channel is Basic everywhere, including on the working Android setups. For completeness, the negotiated AAP channel here: Two more fields in the 18-byte payloadBeyond
If byte 2 really is confidence, gating on it would be sturdier than the fixed Happy to run more captures on this unit if any of it would help; it's a patched-transport device, so it's a useful third data point next to a native Android stack and the Windows driver. |
|
Follow-up from the same unit (AirPods Pro 3 / A3065, Xiaomi 14 Pro). I went after the power side, since this streams continuously while enabled and Apple only samples during an active workout — 8 h with ANC vs 6.5 h with heart rate on, by their own numbers. Bursts work: idle does not put the sensor back to sleepThe thing I expected to block duty-cycled sampling was a cold-start penalty on every burst. It isn't there, as long as the AACP channel stays up:
No degradation, and 1 Hz with no gaps once it locks. Head tracking produced zero frames during those idle windows, so the fast restart isn't an artifact of something else holding RTBuddy open. The one hard constraint: don't tear the channel down between bursts. On a cold channel RTBuddy drops commands for ~30 s without even acking them, which costs far more than the duty cycle saves. Running it that way here: 5 samples per burst, burst takes 13.7 s end to end, so a five minute gap is roughly a 4% duty cycle. Over four consecutive cycles three were clean and one tripped Byte 2 is worth using for the warm-up cutI mentioned this last time; having now gated on it, the reason it matters is sharper than I put it. The warm-up readings are not out-of-range — the first two samples of a burst were 169 and 137 here — so Gating on Small thing: the heart-rate screens bypass the resource system
Two of them need a small signature change: Happy to send that as a PR against your branch if it's useful — or leave it, if you would rather land the feature first and sweep strings later. |
|
I cant test your theory rn as I'm not in a test environment on windows and haven't being able to be on test mode, so I'll give feedback later, but good someone had the same issue as me and manage to fix it |
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
Edit: This branch also adds experimental RSSI-based proximity estimation