Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions windows/daemon/src/aap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,12 @@ pub const STREAM_HEART_RATE_LEGACY: u8 = 0x13; // HEARTRATE — older firmware
/// so it is NOT part of the HR enable — the ~150 frames/window we saw were motion,
/// never PPG. Kept for reference only.
pub const STREAM_DEVMOTION6: u8 = 0x10;
/// Stream id for head tracking — data type 14. Head tracking lives on the same
/// 0x17 sensor service as heart rate, so a running head-tracking stream may be
/// what blocks the computed HR; stopping it first (period 0) is worth trying.
pub const STREAM_HEAD_TRACKING: u8 = 0x0E;
/// Stream id 14 — the ACTIVITY classifier (RTBuddy SensorServiceType 14; its
/// descriptor names it "activity"), NOT head tracking as this was once labelled.
/// It reports a 23-byte record at ~5 Hz whose byte 12 is the activity state
/// (3 = still, 0/1/2 while moving). Head tracking uses DEVMOTION6 (16). Kept for
/// reference; heart rate does not need it stopped.
pub const STREAM_ACTIVITY: u8 = 0x0E;

/// One-second sampling period, in microseconds — the cadence iOS uses for heart rate.
pub const PERIOD_HEART_RATE_US: u32 = 1_000_000;
Expand Down
16 changes: 14 additions & 2 deletions windows/daemon/src/hr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ const MAX_RTBUDDY_PAYLOAD_LENGTH: usize = 16 * 1024;
const LIVE_SENSOR_DATA_LOG_TYPES: [u64; 2] = [1, 3];

/// Different exact status trailers depending on whether one or both earbuds
/// participate in the session.
const KNOWN_HEART_RATE_STATUS_TAILS: [[u8; 3]; 4] = [
/// participate in the session. `10 00 80` / `20 80 00` arrived with the iOS 27
/// firmware (upstream PR #702, 5e20986).
const KNOWN_HEART_RATE_STATUS_TAILS: [[u8; 3]; 6] = [
[0x10, 0x00, 0x00],
[0x10, 0x00, 0x80],
[0x20, 0x00, 0x00],
[0x20, 0x80, 0x00],
[0x20, 0x02, 0x80],
[0x20, 0x82, 0x80],
];
Expand All @@ -40,6 +43,12 @@ const FIELD_COMMAND_PAYLOAD: u32 = 3;
const HEART_RATE_REPORT_SERVICES: [u64; 3] = [84, 20, 19];
const HEART_RATE_PAYLOAD_LENGTH: usize = 18;
const HEART_RATE_BPM_OFFSET: usize = 1;
/// Byte 2 is the sensor's confidence: ~20 while the PPG warms up, then 160..240
/// once it locks (measured on Windows 2026-09-29; matches SAGIRIxr's PR #702
/// data). Warm-up readings can be wildly off yet still inside 30..220 (SAGIRIxr saw
/// 169 and 137 at rest), so gate on confidence rather than trusting the range.
const HEART_RATE_CONFIDENCE_OFFSET: usize = 2;
const MIN_HEART_RATE_CONFIDENCE: u8 = 0x80;
const HEART_RATE_STATUS_TAIL_OFFSET: usize = 15;
const MIN_BPM: u8 = 30;
const MAX_BPM: u8 = 220;
Expand Down Expand Up @@ -290,6 +299,9 @@ fn is_valid_heart_rate_payload(payload: &[u8]) -> bool {
if bpm < MIN_BPM || bpm > MAX_BPM {
return false;
}
if payload[HEART_RATE_CONFIDENCE_OFFSET] < MIN_HEART_RATE_CONFIDENCE {
return false;
}
KNOWN_HEART_RATE_STATUS_TAILS.iter().any(|tail| {
tail.iter()
.enumerate()
Expand Down
44 changes: 21 additions & 23 deletions windows/daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,9 +747,10 @@ fn set_mic(ctx: &Ctx, on: bool) {
}

// ---- HR retry constants (mirror the Android HeartRateMonitor companion) ----
/// Wait this long for a decoded reading before re-enabling (Android's
/// FIRST_SAMPLE_TIMEOUT).
const HR_FIRST_SAMPLE_TIMEOUT_MS: u64 = 8_000;
/// Wait this long for a decoded reading before re-enabling. Android uses 8 s, but
/// the decoder now drops the PPG warm-up (confidence < 0x80), and the first locked
/// sample lands ~6-8 s after the start — 12 s avoids re-enabling mid-warm-up.
const HR_FIRST_SAMPLE_TIMEOUT_MS: u64 = 12_000;
/// Gap after HRM_STATE before the stream start (Android's START_COMMAND_DELAY).
const HR_START_COMMAND_DELAY_MS: u64 = 120;

Expand Down Expand Up @@ -840,13 +841,13 @@ fn spawn_hr_retry(ctx: &Ctx) {

/// Keep re-sending the enable sequence and waiting for a REAL heart-rate reading,
/// mirroring the Android HeartRateMonitor loop. One attempt = full enable + up to
/// FIRST_SAMPLE_TIMEOUT waiting for a *decoded reading*. Crucially, mere ACKs / a
/// live-but-empty stream do NOT end the campaign: the whole failure mode is that the
/// AirPods ACK service 19 yet never stream data. Plain re-enable retries repeat over
/// the SAME channel, up to HR_MAX_ATTEMPTS, then give up — we never rebuild/reconnect
/// the L2CAP channel, because the audio + mic links ride it and must never be
/// collapsed for a feature that (on this firmware) never yields data. Runs until a
/// reading lands, the user turns HR off, or the attempts are spent.
/// FIRST_SAMPLE_TIMEOUT waiting for a *decoded reading*. Mere ACKs / a live-but-empty
/// stream do NOT end the campaign: the buds ACK a start they will never serve (e.g.
/// when the AAP channel's MTU is too small for the HR descriptor — see the driver's
/// L2cap.c). Plain re-enable retries repeat over the SAME channel, up to
/// HR_MAX_ATTEMPTS, then give up — we never rebuild/reconnect the L2CAP channel,
/// because the audio + mic links ride it and must not be collapsed for an optional
/// feature. Runs until a reading lands, the user turns HR off, or the attempts are spent.
fn hr_retry_campaign(ctx: &Ctx) -> HrOutcome {
let mut attempt: u32 = 0;
while ctx.hr_on.load(Ordering::Relaxed) {
Expand All @@ -859,11 +860,9 @@ fn hr_retry_campaign(ctx: &Ctx) -> HrOutcome {
return HrOutcome::GiveUp;
}
};
// beforeFirstStart (Android): stop head tracking up front — it shares the
// sensor service — and settle 220 ms, BEFORE the session init, matching the
// working client's ordering exactly (the PR author confirmed his flow).
let _ = drv.send(&aap::sensor_stream(next_hr_seq(), aap::STREAM_HEAD_TRACKING, 0));
thread::sleep(Duration::from_millis(220));
// (No "stop head tracking" first any more: the service it stopped, 0x0E, is the
// ACTIVITY classifier, not head tracking, and heart rate streams fine next to
// running motion services.)
// AACP 1.3 session init (connect0/caps0/connect4/caps4), re-sent every attempt
// so each retry re-establishes the session before the enable.
let init: [(&[u8], u64); 4] = [
Expand Down Expand Up @@ -916,12 +915,12 @@ fn hr_retry_campaign(ctx: &Ctx) -> HrOutcome {
attempt += 1;
let streaming = ctx.hr_stream_live.load(Ordering::Relaxed);
log(&format!(
"HR retry: attempt={attempt} — no reading in 8s (stream_frames={streaming})"
"HR retry: attempt={attempt} — no reading in {}s (stream_frames={streaming})",
HR_FIRST_SAMPLE_TIMEOUT_MS / 1000
));
// No reading after this attempt. Do NOT rebuild / reconnect the L2CAP channel:
// on this firmware the buds only ever ACK service 19 and never stream, so
// reconnecting to "try again" is pointless churn (it just re-opens the audio
// link). Give up after HR_MAX_ATTEMPTS; the user toggles HR off/on to retry.
// that just re-opens the audio link for an optional feature. Give up after
// HR_MAX_ATTEMPTS; the user toggles HR off/on to retry.
if attempt >= HR_MAX_ATTEMPTS {
log("HR: no reading after the enable retries (ACKs only) — giving up; \
toggle HR off/on to retry");
Expand Down Expand Up @@ -1325,7 +1324,7 @@ fn run_receiver(ctx: Ctx) {
// Frames carrying the type-19 heart-rate signature `08 13 1a 12` (vs the
// 50 Hz type-16 raw-PPG flood, which shares the RTBuddy prefix).
let mut hr_type19 = 0u32;
let mut hr_type14 = 0u32; // head-tracking frames (sensor-service contention)
let mut hr_type14 = 0u32; // activity-classifier (svc 14) frames
// Diagnose stale-"connected": throttled log of the raw driver status when
// it isn't a clean 2, so we can see what "cased" vs "both-out-resting"
// actually report (the teardown decision hinges on them differing).
Expand Down Expand Up @@ -1402,9 +1401,8 @@ fn run_receiver(ctx: Ctx) {
if hr::contains_frame_prefix(data) {
hr_frames += 1;
ctx.hr_stream_live.store(true, Ordering::Relaxed);
// Count head-tracking (type 14: `08 0e 1a`) frames too,
// to see whether it's still streaming and stealing the
// sensor service from the computed heart rate.
// Count activity-classifier (type 14: `08 0e 1a`) frames
// too — diagnostic only; they don't block heart rate.
if data.windows(3).any(|w| w == [0x08, 0x0e, 0x1a]) {
hr_type14 += 1;
}
Expand Down
37 changes: 26 additions & 11 deletions windows/drivers/aap/L2cap.c
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,32 @@ LpConnect(

brb->BtAddress = Address;
brb->Psm = Psm;
// CF_ROLE_EITHER only. Tried adding CF_LINK_ENCRYPTED (the AACP socket is opened
// auth/encrypt on Android) — it triggers a re-authentication at open time that
// the controller rejects (HCI status 0x27), failing the connect, exactly the
// race a LibrePods dev described. The paired link is already encrypted de-facto
// (battery/ANC work), so requiring it explicitly only breaks the open; it is not
// the heart-rate blocker.
brb->ChannelFlags = CF_ROLE_EITHER;

// Flags == 0 => let the stack negotiate default MTU/flush/QoS.
brb->ConfigOut.Flags = 0;
brb->ConfigIn.Flags = 0;
// Authenticated + encrypted, like the AACP socket upstream android/rewrite opens
// (auth=true, encrypt=true). An earlier attempt with CF_LINK_ENCRYPTED alone hit
// a re-authentication the controller rejected (HCI 0x27); with both flags on a
// freshly paired link the open succeeds (2026-09-29). This is the configuration
// heart rate was verified with — whether HR strictly needs these flags (vs only
// the MTU below) is not yet isolated.
brb->ChannelFlags = CF_ROLE_EITHER | CF_LINK_AUTHENTICATED | CF_LINK_ENCRYPTED;

// HEART RATE (2026-09-29): advertise an incoming MTU of 1691 in our Configure
// Request, like Android (Fluoride) and Bumble do. With Flags == 0 the stack sent
// no MTU option, so the channel ran at the 672-byte default — and the AirPods
// only publish the RTBuddy services whose descriptor fits the MTU: devmotion6
// (~521 B) and activity (~573 B) made it, HEARTRATE/HEARTRATEv2 (~920 B) never
// did, so svc 19 answered kIOReturnBadArgument and no BPM ever arrived. On the
// same PC under Bumble with MTU 1691 the buds pushed the HR descriptors at once
// and streamed BPM. The daemon's receive buffer is 8 KiB, so 1691 fits.
//
// bthport semantics (verified on the air, HCI ETW 2026-09-29): ConfigIn is the
// INBOUND direction — it is what goes into OUR Configure Request as the MTU we
// can receive. ConfigOut only caps what we send (it showed up as MTU 1691 in our
// Configure *Response*, while our request still carried no MTU → 672 inbound).
brb->ConfigOut.Flags = 0;
brb->ConfigIn.Flags = CFG_MTU;
brb->ConfigIn.Mtu.Min = 672; // L2CAP default; accept down to it
brb->ConfigIn.Mtu.Preferred = 1691; // what Android/Bumble request
brb->ConfigIn.Mtu.Max = 1691;
brb->IncomingQueueDepth = 10; // MS-recommended default

// Be notified when the remote tears the channel down.
Expand Down
Loading