diff --git a/windows/daemon/src/aap.rs b/windows/daemon/src/aap.rs index 254b77665..01644a5f9 100644 --- a/windows/daemon/src/aap.rs +++ b/windows/daemon/src/aap.rs @@ -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; diff --git a/windows/daemon/src/hr.rs b/windows/daemon/src/hr.rs index 97669feff..98ecb5737 100644 --- a/windows/daemon/src/hr.rs +++ b/windows/daemon/src/hr.rs @@ -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], ]; @@ -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; @@ -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() diff --git a/windows/daemon/src/main.rs b/windows/daemon/src/main.rs index c9a3ef3c8..7ca05932b 100644 --- a/windows/daemon/src/main.rs +++ b/windows/daemon/src/main.rs @@ -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; @@ -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) { @@ -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] = [ @@ -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"); @@ -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). @@ -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; } diff --git a/windows/drivers/aap/L2cap.c b/windows/drivers/aap/L2cap.c index 7fdaeb5e6..cc2edc991 100755 --- a/windows/drivers/aap/L2cap.c +++ b/windows/drivers/aap/L2cap.c @@ -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.